Spring Boot 流程引擎服务:流程定义/实例/任务、审批人解析、 回调分发、oapi 接口及单元测试。 保留远程 .gitea/workflows/build.yml 与 docker/Dockerfile, 新增 .gitignore 排除 target/ 构建产物。
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.service.FlowInstanceService;
|
||||
import com.yunhai.flowcenter.service.OapiFlowService;
|
||||
import com.yunhai.flowcenter.support.FlowTestSupport;
|
||||
import com.yunhai.flowcenter.support.OapiSignedClient;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 回调分发 IT:注册(探活)→ 代发起 → AFTER_COMMIT 事件 → 落 flw_callback_log →
|
||||
* 异步推送 X-Flw-* 四头 → stub 接收端验签 + payload 字段断言;推送失败持续重试(T1.1:
|
||||
* 越过退避表封顶 1h 不死信、挂起告警恰一次、瞬时失败自动治愈);挂起可视/标记死信/rePush 复活(T1.2)。
|
||||
* 注意:本类必须单类独跑(平台 IT 判据)。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
class CallbackDispatchIT extends FlowTestSupport {
|
||||
|
||||
private static final String APP_KEY = "100018";
|
||||
private static final String APP_SECRET = "svc-hall-dev-secret-100018";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private OapiFlowService oapiFlowService;
|
||||
@Autowired
|
||||
private FlowInstanceService instanceService;
|
||||
@Autowired
|
||||
private CallbackPushService pushService;
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.service.FlowCallbackAdminService callbackAdminService;
|
||||
@Autowired
|
||||
private FlwCallbackRegRepository regRepository;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private HttpServer stubServer;
|
||||
private int stubPort;
|
||||
private final CopyOnWriteArrayList<Received> received = new CopyOnWriteArrayList<>();
|
||||
|
||||
/** 计数版 UserMsgClient:只数挂起告警(hang:* msgNo),不真正外发(@Primary 注入 CallbackPushService) */
|
||||
static final CountingUserMsgClient STUB_USER_MSG = new CountingUserMsgClient();
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public CountingUserMsgClient countingUserMsgClient() {
|
||||
return STUB_USER_MSG;
|
||||
}
|
||||
}
|
||||
|
||||
static class CountingUserMsgClient extends com.yunhai.flowcenter.notify.UserMsgClient {
|
||||
final java.util.concurrent.atomic.AtomicInteger hangAlerts =
|
||||
new java.util.concurrent.atomic.AtomicInteger();
|
||||
|
||||
CountingUserMsgClient() {
|
||||
super(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendUserMsg(Integer schId, List<Integer> userIds, Integer msgType, String bizType,
|
||||
String bizId, String title, String content, String jumpPath, String msgNo,
|
||||
Integer expireDays) {
|
||||
if (msgNo != null && msgNo.startsWith("hang:")) {
|
||||
hangAlerts.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class Received {
|
||||
String body;
|
||||
String eventId;
|
||||
String timestamp;
|
||||
String nonce;
|
||||
String signature;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
cleanDb();
|
||||
received.clear();
|
||||
STUB_USER_MSG.hangAlerts.set(0);
|
||||
stubServer = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
stubPort = stubServer.getAddress().getPort();
|
||||
stubServer.createContext("/callback/flow", exchange -> {
|
||||
Received r = new Received();
|
||||
r.body = readBody(exchange);
|
||||
r.eventId = exchange.getRequestHeaders().getFirst("X-Flw-EventId");
|
||||
r.timestamp = exchange.getRequestHeaders().getFirst("X-Flw-Timestamp");
|
||||
r.nonce = exchange.getRequestHeaders().getFirst("X-Flw-Nonce");
|
||||
r.signature = exchange.getRequestHeaders().getFirst("X-Flw-Signature");
|
||||
received.add(r);
|
||||
byte[] resp = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, resp.length);
|
||||
exchange.getResponseBody().write(resp);
|
||||
exchange.close();
|
||||
});
|
||||
stubServer.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
stubServer.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullChain_register_apply_pushesSignedEvents() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t20", "回调全链", linearDef("ybb", "审核"));
|
||||
// 1. 注册(探活命中 stub)→ secret 下发
|
||||
OapiModels.CallbackRegResultVo reg = oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow", null));
|
||||
assertTrue(reg.getSecret() != null && reg.getSecret().length() >= 16);
|
||||
|
||||
// 2. 代发起 → flow.submitted + flow.task.created 两事件先后落库并推送
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-1\",\"bizTitle\":\"张三的回调件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
org.springframework.http.ResponseEntity<JsonNode> applyResp =
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
// 3. 轮询 stub:submitted + task.created 两事件都到齐(探活 ping 不计)
|
||||
waitUntil(() -> received.stream().anyMatch(r -> r.body.contains("flow.submitted"))
|
||||
&& received.stream().anyMatch(r -> r.body.contains("flow.task.created")), 10_000);
|
||||
boolean hasSubmitted = false;
|
||||
boolean hasTaskCreated = false;
|
||||
for (Received r : received) {
|
||||
JsonNode payload = readJson(r.body);
|
||||
String event = payload.get("event").asText();
|
||||
if ("ping".equals(event)) {
|
||||
continue; // 注册探活 ping 不参与事件协议断言
|
||||
}
|
||||
if ("flow.submitted".equals(event)) {
|
||||
hasSubmitted = true;
|
||||
}
|
||||
if ("flow.task.created".equals(event)) {
|
||||
hasTaskCreated = true;
|
||||
}
|
||||
// 协议断言:必带字段
|
||||
assertEquals("1", payload.get("schId").asText());
|
||||
assertEquals(def.getFlowGuid(), payload.get("flowGuid").asText());
|
||||
assertEquals("svc_item", payload.get("bizType").asText());
|
||||
assertEquals("cb-1", payload.get("bizId").asText());
|
||||
assertEquals("张三的回调件", payload.get("bizTitle").asText());
|
||||
assertTrue(payload.get("instanceVersion").asInt() >= 1, "instanceVersion 应为办件乐观锁版本");
|
||||
assertNotNull(payload.get("eventId"));
|
||||
assertNotNull(payload.get("occurredTime"));
|
||||
// 签名断言:Base64(HmacSHA256(secret, ts.nonce.body))
|
||||
String expected = hmac(reg.getSecret(), r.timestamp, r.nonce, r.body);
|
||||
assertEquals(expected, r.signature, "推送必须带合法 X-Flw-Signature");
|
||||
assertEquals(r.eventId, payload.get("eventId").asText(), "头与体 eventId 一致");
|
||||
}
|
||||
assertTrue(hasSubmitted, "应有 flow.submitted 事件");
|
||||
assertTrue(hasTaskCreated, "应有 flow.task.created 事件");
|
||||
// 4. 日志表成功态(异步推送落定,轮询等待)
|
||||
assertTrue(logRepository.findAll().size() >= 2);
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()), 10_000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushFailure_persistsRetryBeyondBackoffList() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t21", "持续重试", linearDef("ybb", "审核"));
|
||||
// 只订阅 flow.submitted:单事件单日志行,断言确定
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow", "flow.submitted"));
|
||||
FlwCallbackReg regRow = regRepository.findBySchIdAndAppKeyAndFlowGuid(SCH, APP_KEY, def.getFlowGuid())
|
||||
.orElseThrow(IllegalStateException::new);
|
||||
// 注册指向死端口模拟业务方宕机(探活已过,直接改库)
|
||||
regRow.setCallbackUrl("http://localhost:1/dead");
|
||||
regRepository.save(regRow);
|
||||
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-2\",\"bizTitle\":\"张三的挂起件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
|
||||
// 首推失败:retryCount=1(异步首推,轮询等待)
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
// 手工逐次触发扫描语义再失败 5 轮(共 6 次,越过退避表 5 项)
|
||||
for (int round = 0; round < 5; round++) {
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
}
|
||||
FlwCallbackLog hung = logRepository.findAll().get(0);
|
||||
// T1.1 核心语义:持续重试不自动死信——status 仍=0、retryCount=6、nextRetry≈now+60min(封顶)
|
||||
assertEquals(FlwEnums.CallbackLogStatus.PENDING.getCode(), hung.getStatus(),
|
||||
"越过退避表后必须保持待推持续重试,不得自动死信");
|
||||
assertEquals(6, hung.getRetryCount());
|
||||
assertNotNull(hung.getNextRetryTime());
|
||||
java.time.LocalDateTime expectedNext = java.time.LocalDateTime.now().plusMinutes(60);
|
||||
assertTrue(hung.getNextRetryTime().isAfter(expectedNext.minusMinutes(5))
|
||||
&& hung.getNextRetryTime().isBefore(expectedNext.plusMinutes(5)),
|
||||
"越过退避表后 nextRetryTime 应封顶 +60 分钟: " + hung.getNextRetryTime());
|
||||
assertTrue(logRepository.findAll().stream()
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()),
|
||||
"任何行都不应出现死信(status=2)");
|
||||
assertNotNull(hung.getLastError());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hangAlertSentExactlyOnce() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t23", "挂起告警", linearDef("ybb", "审核"));
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow", "flow.submitted"));
|
||||
FlwCallbackReg regRow = regRepository.findBySchIdAndAppKeyAndFlowGuid(SCH, APP_KEY, def.getFlowGuid())
|
||||
.orElseThrow(IllegalStateException::new);
|
||||
regRow.setCallbackUrl("http://localhost:1/dead");
|
||||
regRepository.save(regRow);
|
||||
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-4\",\"bizTitle\":\"张三的告警件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
|
||||
// 失败到第 6 次(=挂起阈值):告警恰一次
|
||||
for (int round = 0; round < 5; round++) {
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
}
|
||||
assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 6 次失败后挂起告警应恰发送一次");
|
||||
|
||||
// 第 7 次失败:仍是 1(恰一次)
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 7 次失败后告警计数仍应为 1");
|
||||
assertEquals(7, logRepository.findAll().get(0).getRetryCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transientFailureRecovers() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t24", "瞬时失败治愈", linearDef("ybb", "审核"));
|
||||
// 抖动端点:探活(第1次) 200、首推(第2次) 500、其后 200——模拟消费方瞬时故障后恢复
|
||||
AtomicInteger flakyCount = new AtomicInteger(0);
|
||||
stubServer.createContext("/callback/flaky", exchange -> {
|
||||
boolean fail = flakyCount.incrementAndGet() == 2;
|
||||
byte[] resp = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(fail ? 500 : 200, resp.length);
|
||||
exchange.getResponseBody().write(resp);
|
||||
exchange.close();
|
||||
});
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flaky", "flow.submitted"));
|
||||
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-5\",\"bizTitle\":\"张三的抖动件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
|
||||
// 首推 500 → 进入重试(status=0, retryCount=1)
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
// 消费方恢复后(扫描/重推触发)→ 200 → status=1 + pushTime 落定
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
FlwCallbackLog recovered = logRepository.findAll().get(0);
|
||||
assertEquals(FlwEnums.CallbackLogStatus.SUCCESS.getCode(), recovered.getStatus(),
|
||||
"瞬时失败恢复后应自动治愈为成功");
|
||||
assertNotNull(recovered.getPushTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hungList_paged_andMarkDead_thenRepushRevives() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t25", "挂起管理", linearDef("ybb", "审核"));
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow", "flow.submitted"));
|
||||
FlwCallbackReg regRow = regRepository.findBySchIdAndAppKeyAndFlowGuid(SCH, APP_KEY, def.getFlowGuid())
|
||||
.orElseThrow(IllegalStateException::new);
|
||||
regRow.setCallbackUrl("http://localhost:1/dead");
|
||||
regRepository.save(regRow);
|
||||
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-6\",\"bizTitle\":\"张三的管理件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
for (int round = 0; round < 5; round++) {
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
}
|
||||
FlwCallbackLog hung = logRepository.findAll().get(0);
|
||||
assertEquals(6, hung.getRetryCount());
|
||||
|
||||
// 挂起列表:status=0 且 retryCount≥阈值的分页行,不含正常行
|
||||
com.yunhai.platform.utils.model.BasePageResult<FlwCallbackLog> hungPage =
|
||||
callbackAdminService.pageHung(SCH, 1, 10);
|
||||
assertTrue(hungPage.getContent().stream().anyMatch(l -> l.getId().equals(hung.getId())));
|
||||
assertTrue(hungPage.getContent().stream()
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()
|
||||
&& l.getRetryCount() >= 6));
|
||||
|
||||
// 管理端手工标记死信(唯一死信入口)
|
||||
callbackAdminService.markDead(SCH, hung.getId());
|
||||
FlwCallbackLog dead = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new);
|
||||
assertEquals(FlwEnums.CallbackLogStatus.DEAD.getCode(), dead.getStatus());
|
||||
|
||||
// 恢复消费方地址后既有 rePush 复活:置回待推立即推送 → 成功
|
||||
regRow.setCallbackUrl("http://localhost:" + stubPort + "/callback/flow");
|
||||
regRepository.save(regRow);
|
||||
callbackAdminService.rePush(SCH, java.util.Collections.singletonList(hung.getId()));
|
||||
waitUntil(() -> {
|
||||
FlwCallbackLog row = logRepository.findById(hung.getId()).orElse(null);
|
||||
return row != null && row.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode();
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsubscribedEvent_notDelivered() throws InterruptedException {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t22", "订阅过滤", linearDef("ybb", "审核"));
|
||||
// 只订阅 flow.completed:submitted/task.created 不推送
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow",
|
||||
"flow.completed"));
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-3\",\"bizTitle\":\"张三的订阅件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
Thread.sleep(1500);
|
||||
// stub 收到的注册探活 ping 不算事件推送
|
||||
long eventPushes = received.stream()
|
||||
.filter(r -> !r.body.contains("\"event\":\"ping\"")).count();
|
||||
assertEquals(0, eventPushes, "未订阅事件不应推送");
|
||||
}
|
||||
|
||||
/** 组注册请求体(registerCallback 返回下发 secret) */
|
||||
private OapiModels.CallbackRegBody regBody(String flowGuid, String url, String eventTypes) {
|
||||
OapiModels.CallbackRegBody body = new OapiModels.CallbackRegBody();
|
||||
body.setFlowGuid(flowGuid);
|
||||
body.setCallbackUrl(url);
|
||||
body.setEventTypes(eventTypes);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 轮询等待(替代 Awaitility,离线环境无该依赖) */
|
||||
private static void waitUntil(java.util.function.BooleanSupplier cond, long timeoutMs) {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (cond.getAsBoolean()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertTrue(cond.getAsBoolean(), "等待条件超时");
|
||||
}
|
||||
|
||||
private static String readBody(HttpExchange exchange) {
|
||||
try (InputStream in = exchange.getRequestBody()) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
return out.toString("UTF-8");
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode readJson(String s) {
|
||||
try {
|
||||
return objectMapper.readTree(s);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 回调推送配置默认值冻结测试(T1.1 防误改):退避表 1/5/30/120/360、挂起告警阈值 6、
|
||||
* 封顶间隔 60 分钟、告警接收人默认空(不告警)。
|
||||
*/
|
||||
class CallbackPropertiesTest {
|
||||
|
||||
@Test
|
||||
void defaultsFrozen() {
|
||||
CallbackProperties p = new CallbackProperties();
|
||||
assertEquals(5, p.getPushPoolSize());
|
||||
assertEquals(Arrays.asList(1L, 5L, 30L, 120L, 360L), p.getRetryBackoffMinutes());
|
||||
assertEquals(6, p.getHangAlertRetries(), "挂起告警阈值默认 6(越过退避表 5 次后的第一次失败)");
|
||||
assertEquals(60L, p.getCapBackoffMinutes(), "越过退避表后的封顶重试间隔 60 分钟");
|
||||
assertEquals("", p.getAlertUserIds());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 发布静态校验单测(概览 §5.2 约束):把"任务无主/死图"拦在发布期。
|
||||
*/
|
||||
class DefValidatorTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private FlowDefModel parse(String json) {
|
||||
return FlowDefModel.parse(MAPPER, json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validLinearDef_passes() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingEnd_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":[{\"id\":\"start\",\"type\":\"START\"}],"
|
||||
+ "\"edges\":[]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), Collections.emptySet()));
|
||||
assertTrue(e.getMessage().contains("END"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoStarts_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},{\"id\":\"start2\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[]}";
|
||||
assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), Collections.emptySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanNode_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"},"
|
||||
+ "{\"id\":\"n_orphan\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("孤儿"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingDeptMapping_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"gjc\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("gjc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cycle_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"n2\"},"
|
||||
+ "{\"from\":\"n2\",\"to\":\"n1\"},{\"from\":\"n2\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("环"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usersAssignee_passesWithoutMapping() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[11,12]}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json), Collections.emptySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyUserIds_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"USERS\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), Collections.emptySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewSample_def() {
|
||||
// 概览 §5.1 缩样(并行+分支内串行):配齐 6 个 DEPT_KEY 映射应通过
|
||||
String json = "{"
|
||||
+ "\"code\":\"staff\",\"name\":\"教职工入职\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"人事处登记\"},"
|
||||
+ "{\"id\":\"n_hr\",\"type\":\"APPROVAL\",\"name\":\"人才办审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"rsd_rcb\"},\"countersign\":\"ALL\"},"
|
||||
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"t_gjc\",\"type\":\"APPROVAL\",\"name\":\"国际处\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"gjc\"}},"
|
||||
+ "{\"id\":\"t_jwc\",\"type\":\"APPROVAL\",\"name\":\"教务处\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"jwc\"}},"
|
||||
+ "{\"id\":\"t_ybb\",\"type\":\"APPROVAL\",\"name\":\"医保办\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"t_bwc\",\"type\":\"APPROVAL\",\"name\":\"保卫处\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"bwc\"}},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n_hr\"},{\"from\":\"n_hr\",\"to\":\"split1\"},"
|
||||
+ "{\"from\":\"split1\",\"to\":\"t_gjc\"},{\"from\":\"split1\",\"to\":\"t_jwc\"},"
|
||||
+ "{\"from\":\"split1\",\"to\":\"t_ybb\"},{\"from\":\"t_ybb\",\"to\":\"t_bwc\"},"
|
||||
+ "{\"from\":\"t_bwc\",\"to\":\"join1\"},{\"from\":\"t_gjc\",\"to\":\"join1\"},"
|
||||
+ "{\"from\":\"t_jwc\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json),
|
||||
new HashSet<>(Arrays.asList("rsd_rcb", "gjc", "jwc", "ybb", "bwc")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwBranch;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.service.FlowInstanceService;
|
||||
import com.yunhai.flowcenter.service.FlowTaskService;
|
||||
import com.yunhai.flowcenter.support.FlowTestSupport;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 引擎闭环 IT(真实 MySQL bsch_flowcenter_test;不开测试事务——AFTER_COMMIT 推进
|
||||
* 监听器依赖真实提交):激活/ALL 会签合并/并行分支/拒绝/撤回/催办/R4 重启重提/乐观锁。
|
||||
* 注意:本类必须单类独跑(平台 IT 判据,与他类合跑共享库必假红)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class FlowEngineIT extends FlowTestSupport {
|
||||
|
||||
@Autowired
|
||||
private FlowInstanceService instanceService;
|
||||
@Autowired
|
||||
private FlowTaskService taskService;
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cleanDb();
|
||||
}
|
||||
|
||||
private FlowInstanceService.ApplyOutcome apply(String flowGuid, String bizId,
|
||||
Integer submitterId, String submitterName) {
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
SCH, flowGuid, 1).orElseThrow(IllegalStateException::new);
|
||||
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
|
||||
SCH, def, "svc_item", bizId, "张三的测试办件", "http://biz/1", "{\"k\":\"v\"}",
|
||||
submitterId, submitterName, null);
|
||||
return instanceService.apply(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_createsInstanceAndCountersignTasks_allApprove_completes() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("ybb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t1", "会签流程", linearDef("ybb", "联合审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-1", 201, "张三");
|
||||
assertFalse(outcome.isDuplicated());
|
||||
|
||||
FlwInstance instance = instanceRepository.findById(outcome.getInstanceId()).orElse(null);
|
||||
assertNotNull(instance);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instance.getStatus());
|
||||
// ALL 会签:每办理人一条任务
|
||||
List<FlwTask> tasks = taskRepository.findByInstanceIdAndStatus(instance.getId(), 0);
|
||||
assertEquals(2, tasks.size());
|
||||
assertEquals("n1", instance.getCurrentNodes());
|
||||
|
||||
// 第一人同意:节点未清零,仍在途
|
||||
taskService.handle(SCH, tasks.get(0).getId(), true, "同意", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instance.getId()).get().getStatus());
|
||||
// 第二人同意:全部清零 → 推进 END → 办件完成
|
||||
taskService.handle(SCH, tasks.get(1).getId(), true, null, 102);
|
||||
FlwInstance after = instanceRepository.findById(instance.getId()).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), after.getStatus());
|
||||
assertNotNull(after.getFinishTime());
|
||||
assertTrue(after.getVersion() >= 2, "完成时乐观锁版本应递增");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anyReject_endsRejected_andInvalidatesOthers() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("ybb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t2", "会签拒绝", linearDef("ybb", "联合审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-2", 201, "张三");
|
||||
List<FlwTask> tasks = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0);
|
||||
|
||||
taskService.handle(SCH, tasks.get(0).getId(), false, "材料不全", 101);
|
||||
FlwInstance instance = instanceRepository.findById(outcome.getInstanceId()).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(), instance.getStatus());
|
||||
assertNotNull(instance.getFinishTime());
|
||||
// 同批另一待办已失效
|
||||
List<FlwTask> all = taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
|
||||
assertEquals(FlwEnums.TaskStatus.REJECTED.getCode(), all.get(0).getStatus());
|
||||
assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), all.get(1).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_requiresOpinion() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t3", "意见必填", linearDef("ybb", "审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-3", 201, "张三");
|
||||
FlwTask task = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0).get(0);
|
||||
assertThrows(BizException.class, () -> taskService.handle(SCH, task.getId(), false, " ", 101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdraw_beforeAnyHandle_ok_afterHandle_forbidden() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t4", "撤回", linearDef("ybb", "审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-4", 201, "张三");
|
||||
instanceService.withdraw(SCH, outcome.getInstanceId());
|
||||
assertEquals(FlwEnums.InstanceStatus.WITHDRAWN.getCode(),
|
||||
instanceRepository.findById(outcome.getInstanceId()).get().getStatus());
|
||||
|
||||
// 重提重启后再办理,随后撤回必须被拒(已有任务被办理)
|
||||
FlowInstanceService.ApplyOutcome reapplied = apply(def.getFlowGuid(), "biz-4", 201, "张三");
|
||||
FlwTask task = taskRepository.findByInstanceIdAndStatus(reapplied.getInstanceId(), 0).get(0);
|
||||
taskService.handle(SCH, task.getId(), true, "ok", 101);
|
||||
assertThrows(BizException.class, () -> instanceService.withdraw(SCH, reapplied.getInstanceId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reapply_inFlight_duplicated_noNewInstance() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t5", "在途幂等", linearDef("ybb", "审核"));
|
||||
FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-5", 201, "张三");
|
||||
FlowInstanceService.ApplyOutcome second = apply(def.getFlowGuid(), "biz-5", 201, "张三");
|
||||
assertTrue(second.isDuplicated());
|
||||
assertEquals(first.getInstanceId(), second.getInstanceId());
|
||||
// 在途命中只返回不重建,任务不翻倍
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reapply_afterReject_restartsSameInstance_withHistoryKept() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t6", "R4 重启", linearDef("ybb", "审核"));
|
||||
FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-6", 201, "张三");
|
||||
FlwInstance before = instanceRepository.findById(first.getInstanceId()).get();
|
||||
int versionBefore = before.getVersion();
|
||||
|
||||
// 拒绝 → 终态
|
||||
FlwTask task = taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).get(0);
|
||||
taskService.handle(SCH, task.getId(), false, "不合格", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(),
|
||||
instanceRepository.findById(first.getInstanceId()).get().getStatus());
|
||||
|
||||
// R4:重提复用同一办件行重启推进
|
||||
FlowInstanceService.ApplyOutcome second = apply(def.getFlowGuid(), "biz-6", 201, "张三");
|
||||
assertTrue(second.isDuplicated());
|
||||
assertEquals(first.getInstanceId(), second.getInstanceId());
|
||||
FlwInstance after = instanceRepository.findById(first.getInstanceId()).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), after.getStatus());
|
||||
assertTrue(after.getVersion() > versionBefore, "重启后 instanceVersion 必须单调递增");
|
||||
// 轨迹连续:历史拒绝任务保留 + 新一轮任务待办
|
||||
List<FlwTask> all = taskRepository.findByInstanceIdOrderByIdAsc(first.getInstanceId());
|
||||
assertEquals(FlwEnums.TaskStatus.REJECTED.getCode(), all.get(0).getStatus());
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size());
|
||||
assertNull(after.getFinishTime());
|
||||
// 二轮通过 → 完成
|
||||
FlwTask newTask = taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).get(0);
|
||||
taskService.handle(SCH, newTask.getId(), true, "通过", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(first.getInstanceId()).get().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelSplitJoin_bothBranchesArrive_thenComplete() {
|
||||
mapDept("ka", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t7", "并行", parallelDef("ka", "kb"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-7", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
// split 激活两条分支
|
||||
List<FlwBranch> branches = instanceRepository.findById(instanceId)
|
||||
.map(i -> i.getId()).map(id -> branchRows(id)).orElse(null);
|
||||
assertNotNull(branches);
|
||||
assertEquals(2, branches.size());
|
||||
// 甲科先过:分支一到达汇聚点,办件仍在途
|
||||
FlwTask taskA = tasksOf(instanceId, 101).get(0);
|
||||
taskService.handle(SCH, taskA.getId(), true, "同意", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
// 乙科再过:全部到达 → JOIN 放行 → END → 完成
|
||||
FlwTask taskB = tasksOf(instanceId, 102).get(0);
|
||||
taskService.handle(SCH, taskB.getId(), true, "同意", 102);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_inParallelBranch_failsWholeInstance() {
|
||||
mapDept("ka", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t8", "并行拒绝", parallelDef("ka", "kb"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-8", 201, "张三");
|
||||
FlwTask taskB = tasksOf(outcome.getInstanceId(), 102).get(0);
|
||||
taskService.handle(SCH, taskB.getId(), false, "乙科不同意", 102);
|
||||
FlwInstance instance = instanceRepository.findById(outcome.getInstanceId()).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(), instance.getStatus());
|
||||
// 另一分支的待办被失效(失效后不在待办集合,按全量任务查找)
|
||||
FlwTask taskA = allTasks(outcome.getInstanceId()).stream()
|
||||
.filter(t -> t.getAssigneeId().equals(101)).findFirst().get();
|
||||
assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(),
|
||||
taskRepository.findById(taskA.getId()).get().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentApprove_sameNode_onlyOnePathAdvances() throws Exception {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("ybb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t9", "并发会签", linearDef("ybb", "会签审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-9", 201, "张三");
|
||||
List<FlwTask> tasks = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0);
|
||||
// 两线程同时办理各自任务(推进互斥由乐观锁保证)
|
||||
Thread t1 = new Thread(() -> taskService.handle(SCH, tasks.get(0).getId(), true, "ok", 101));
|
||||
Thread t2 = new Thread(() -> taskService.handle(SCH, tasks.get(1).getId(), true, "ok", 102));
|
||||
t1.start();
|
||||
t2.start();
|
||||
t1.join(10_000);
|
||||
t2.join(10_000);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(outcome.getInstanceId()).get().getStatus());
|
||||
// 只应有一轮 END 完成(无重复推进导致的重复任务创建:总任务数=2)
|
||||
assertEquals(2, taskRepository.findByInstanceIdOrderByIdAsc(outcome.getInstanceId()).size());
|
||||
}
|
||||
|
||||
private List<FlwBranch> branchRows(Long instanceId) {
|
||||
return branchRepository.findByInstanceId(instanceId);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.repository.FlwBranchRepository branchRepository;
|
||||
|
||||
private List<FlwTask> allTasks(Long instanceId) {
|
||||
return taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
}
|
||||
|
||||
private List<FlwTask> tasksOf(Long instanceId, Integer assigneeId) {
|
||||
List<FlwTask> out = new java.util.ArrayList<>();
|
||||
for (FlwTask t : taskRepository.findByInstanceIdAndStatus(instanceId, 0)) {
|
||||
if (assigneeId.equals(t.getAssigneeId())) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.yunhai.flowcenter.oapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.support.FlowTestSupport;
|
||||
import com.yunhai.flowcenter.support.OapiSignedClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* /oapi/flow/** 契约 IT(RANDOM_PORT + 手工签名三件套,逐字对齐 svc-hall FlowCenterClient 出站形态):
|
||||
* 签名强制 401 / list|get / apply 幂等 / instance 对账出参 / callback reg 探活与 secret 下发。
|
||||
* 注意:本类必须单类独跑(平台 IT 判据)。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
class OapiFlowContractIT extends FlowTestSupport {
|
||||
|
||||
private static final String APP_KEY = "100018";
|
||||
private static final String APP_SECRET = "svc-hall-dev-secret-100018";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private OapiSignedClient client;
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cleanDb();
|
||||
client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oapi_requiresSignature_401() {
|
||||
assertEquals(401, client.getRaw("/oapi/flow/list?schId=1").getStatusCodeValue());
|
||||
assertEquals(401, client.getWithBadSecret("/oapi/flow/list?schId=1").getStatusCodeValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAndGetFlow_contractShape() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t10", "契约流程", linearDef("ybb", "联审"));
|
||||
ResponseEntity<JsonNode> listResp = client.get("/oapi/flow/list?schId=1", JsonNode.class);
|
||||
assertEquals(200, listResp.getStatusCodeValue());
|
||||
JsonNode data = unwrap(listResp.getBody());
|
||||
assertTrue(data.isArray() && data.size() >= 1);
|
||||
JsonNode row = data.get(0);
|
||||
assertEquals(def.getFlowGuid(), row.get("flowGuid").asText());
|
||||
assertEquals("契约流程", row.get("name").asText());
|
||||
assertEquals(1, row.get("version").asInt());
|
||||
assertTrue(row.get("nodeSummary").asText().contains("联审"));
|
||||
|
||||
ResponseEntity<JsonNode> getResp = client.get(
|
||||
"/oapi/flow/" + def.getFlowGuid() + "?schId=1", JsonNode.class);
|
||||
assertEquals(def.getFlowGuid(), unwrap(getResp.getBody()).get("flowGuid").asText());
|
||||
|
||||
// 未发布 guid → data=null(消费方降级)
|
||||
ResponseEntity<JsonNode> miss = client.get("/oapi/flow/notexist000000?schId=1", JsonNode.class);
|
||||
assertTrue(unwrap(miss.getBody()).isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_contract_andBizUkIdempotency() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t11", "代发起", linearDef("ybb", "审核"));
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-1\",\"bizTitle\":\"张三的XX事项\","
|
||||
+ "\"bizUrl\":\"http://front/#/apply-detail/1\","
|
||||
+ "\"bizSnapshot\":{\"itemTitle\":\"XX事项\",\"summary\":\"摘要\"},"
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
ResponseEntity<JsonNode> resp1 = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
assertEquals(200, resp1.getStatusCodeValue());
|
||||
JsonNode data1 = unwrap(resp1.getBody());
|
||||
assertNotNull(data1.get("instanceId"));
|
||||
assertFalse(data1.get("duplicated").asBoolean());
|
||||
|
||||
// 重复代发起(同 bizType+bizId):HTTP 200 + duplicated:true + 同一办件
|
||||
ResponseEntity<JsonNode> resp2 = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
JsonNode data2 = unwrap(resp2.getBody());
|
||||
assertTrue(data2.get("duplicated").asBoolean());
|
||||
assertEquals(data1.get("instanceId").asLong(), data2.get("instanceId").asLong());
|
||||
|
||||
// 办件与任务确实建好(托管代发起与原生同一条引擎路径)
|
||||
Long instanceId = data1.get("instanceId").asLong();
|
||||
assertEquals(10, instanceRepository.findById(instanceId).get().getStatus());
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_crossTenantSubmitter_rejected() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t12", "跨租户校验", linearDef("ybb", "审核"));
|
||||
// stub 用户 school=1;传 schId=2 触发"提交人不属于该学校"
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-x\",\"bizTitle\":\"t\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
ResponseEntity<JsonNode> resp = client.post("/oapi/flow/apply?schId=2", body, JsonNode.class);
|
||||
assertEquals(200, resp.getStatusCodeValue());
|
||||
JsonNode respBody = resp.getBody();
|
||||
assertNotEquals(0, respBody.get("code").asInt(), "跨租户发起应被业务码拒绝");
|
||||
}
|
||||
|
||||
@Test
|
||||
void instanceByBiz_contract_traceWithAssignee() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t13", "对账", linearDef("ybb", "审核"));
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-9\",\"bizTitle\":\"张三的YY\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", body, JsonNode.class)
|
||||
.getBody()).get("instanceId").asLong();
|
||||
|
||||
ResponseEntity<JsonNode> resp = client.get(
|
||||
"/oapi/flow/instance?bizType=svc_item&bizId=apply-9&schId=1", JsonNode.class);
|
||||
JsonNode data = unwrap(resp.getBody());
|
||||
assertEquals(instanceId, data.get("instanceId").asLong());
|
||||
assertEquals(10, data.get("status").asInt());
|
||||
assertNotNull(data.get("version"));
|
||||
assertTrue(data.get("nodeSummary").asText().contains("审核"));
|
||||
assertTrue(data.get("traceJson").asText().contains("assigneeId"));
|
||||
assertTrue(data.get("traceJson").asText().contains("101"), "轨迹须含 assignee_id(拍板项#3)");
|
||||
assertEquals(101, data.get("assigneeIds").get(0).asInt());
|
||||
|
||||
// 查不到 → data=null(对账方按漂移清理处理)
|
||||
ResponseEntity<JsonNode> miss = client.get(
|
||||
"/oapi/flow/instance?bizType=svc_item&bizId=none&schId=1", JsonNode.class);
|
||||
assertTrue(unwrap(miss.getBody()).isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void callbackReg_returnsSecret() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t14", "回调注册", linearDef("ybb", "审核"));
|
||||
// 本地无回调接收端 → 探活失败应拒绝注册(不可达拦截)
|
||||
String bad = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"callbackUrl\":\"http://localhost:1/nonexistent-callback\"}";
|
||||
ResponseEntity<JsonNode> badResp = client.post(
|
||||
"/oapi/flow/callback/reg?schId=1", bad, JsonNode.class);
|
||||
assertNotEquals(0, badResp.getBody().get("code").asInt(), "不可达回调地址应被探活拦截");
|
||||
}
|
||||
|
||||
private JsonNode unwrap(JsonNode body) {
|
||||
return body.get("data");
|
||||
}
|
||||
|
||||
private static void assertNotEquals(int expected, int actual, String message) {
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(Integer.valueOf(expected), Integer.valueOf(actual), message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 用户面办件可见性守卫口径锁定(三种守卫必须同口径:svc-hall ApplyViewGuard /
|
||||
* Notice NoticeFlowGuard / 本类)。原先只认 type==1 超管,导致"能列办件、点详情 403"。
|
||||
*/
|
||||
class FlowAccessGuardTest {
|
||||
|
||||
private FlwTaskRepository taskRepository;
|
||||
private FlowAccessGuard guard;
|
||||
private FlwInstance instance;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
guard = new FlowAccessGuard(taskRepository);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(anyLong()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
instance = new FlwInstance();
|
||||
ReflectionTestUtils.setField(instance, "id", 100L);
|
||||
instance.setSchId(990001L);
|
||||
instance.setSubmitterId(11);
|
||||
}
|
||||
|
||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(user.getId()).thenReturn(id);
|
||||
when(user.getType()).thenReturn(type);
|
||||
when(user.getRoleIds()).thenReturn(roleIds.length == 0
|
||||
? null : new java.util.ArrayList<>(Arrays.asList(roleIds)));
|
||||
when(user.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitterCanViewOwnInstance() {
|
||||
assertDoesNotThrow(() -> guard.requireInstanceVisible(instance, user(11, 2, 990001, 9)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assigneeCanViewInstance() {
|
||||
FlwTask task = new FlwTask();
|
||||
task.setAssigneeId(22);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(100L)).thenReturn(Collections.singletonList(task));
|
||||
assertDoesNotThrow(() -> guard.requireInstanceVisible(instance, user(22, 2, 990001, 9)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameSchoolAdminByTypeCanView() {
|
||||
assertDoesNotThrow(() -> guard.requireInstanceVisible(instance, user(33, 1, 990001)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameSchoolAdminByRoleIdCanView() {
|
||||
// 校级管理员 roleId∈{2,5,7}:FlowAdminGuard 放行其管理端,可见性守卫必须同口径
|
||||
assertDoesNotThrow(() -> guard.requireInstanceVisible(instance, user(34, 2, 990001, 5)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossSchoolAdminIsDenied() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> guard.requireInstanceVisible(instance, user(35, 1, 888888)));
|
||||
assertEquals("仅提交人/办理人/本校管理员可查看该办件", e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinarySameSchoolUserIsDenied() {
|
||||
assertThrows(BizException.class,
|
||||
() -> guard.requireInstanceVisible(instance, user(36, 2, 990001, 9)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userWithoutCurrentRoleIsDenied() {
|
||||
assertThrows(BizException.class,
|
||||
() -> guard.requireInstanceVisible(instance, user(37, 1, null)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.controller.api.FlowAdminController;
|
||||
import com.yunhai.flowcenter.controller.api.FlowUserController;
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.engine.AssigneeResolver;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.client.OpenApiUserClient;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 流程示意图出图(方案 §3.2 标签解析规则 + §5 TDD C1-C6):
|
||||
* C1/CC3 视图分支与兜底链、C2 UserBase 补名降级、C4 flowGuid 取版、C5/C6 控制器守卫接线。
|
||||
* 科室视图不泄人员名单(USERS 只显"指定办理人"且不调 UserBase)是硬口径。
|
||||
*/
|
||||
class FlowDiagramServiceTest {
|
||||
|
||||
private static final Long SCH = 990001L;
|
||||
|
||||
/** 并行链:start→sp1→n1(DEPT_KEY rs)→j1→end(教师入职缩样) */
|
||||
private static final String DEPT_DEF_JSON = "{"
|
||||
+ "\"code\":\"teacher_in\",\"name\":\"教师入职\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"sp1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"人事处审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"rs\"},\"countersign\":\"ALL\",\"deadlineHours\":48},"
|
||||
+ "{\"id\":\"j1\",\"type\":\"PARALLEL_JOIN\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"sp1\"},{\"from\":\"sp1\",\"to\":\"n1\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"j1\"},{\"from\":\"j1\",\"to\":\"end\"}]}";
|
||||
|
||||
/** ANY 会签变体(无时限) */
|
||||
private static final String ANY_DEF_JSON = "{"
|
||||
+ "\"code\":\"c2\",\"name\":\"任一通过\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"甲审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"rs\"},\"countersign\":\"ANY\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
|
||||
/** USERS 直配变体(Notice 先审后发缩样) */
|
||||
private static final String USERS_DEF_JSON = "{"
|
||||
+ "\"code\":\"notice_audit\",\"name\":\"Notice先审后发\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"甲乙会签\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[11,12,13]}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n2\"},{\"from\":\"n2\",\"to\":\"end\"}]}";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private FlwAssigneeMapRepository mapRepository;
|
||||
private FlwTaskRepository taskRepository;
|
||||
private OpenApiUserClient userClient;
|
||||
private FlowDiagramService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
mapRepository = mock(FlwAssigneeMapRepository.class);
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
userClient = mock(OpenApiUserClient.class);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(anyLong()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
// 真 FlowEngine(真实 JSON 解析通道)+ mock 依赖
|
||||
FlowEngine engine = new FlowEngine(new ObjectMapper(),
|
||||
taskRepository, mock(FlwBranchRepository.class), mock(AssigneeResolver.class),
|
||||
mock(ApplicationEventPublisher.class));
|
||||
service = new FlowDiagramService(defRepository, mapRepository, engine, userClient);
|
||||
}
|
||||
|
||||
private FlwDef def(String json) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId(12L);
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid("g1");
|
||||
def.setCode("c1");
|
||||
def.setName("教师入职");
|
||||
def.setVersion(2);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setContentJson(json);
|
||||
return def;
|
||||
}
|
||||
|
||||
private FlwAssigneeMap mapRow(String deptName, int userId, String userName) {
|
||||
FlwAssigneeMap m = new FlwAssigneeMap();
|
||||
m.setSchId(SCH);
|
||||
m.setDeptKey("rs");
|
||||
m.setDeptName(deptName);
|
||||
m.setUserId(userId);
|
||||
m.setUserName(userName);
|
||||
m.setStatus(0);
|
||||
return m;
|
||||
}
|
||||
|
||||
private BoaUser boaUser(String name) {
|
||||
BoaUser u = mock(BoaUser.class);
|
||||
when(u.getName()).thenReturn(name);
|
||||
return u;
|
||||
}
|
||||
|
||||
private FlowModels.NodeVo node(FlowModels.DiagramVo vo, String id) {
|
||||
return vo.getNodes().stream().filter(n -> id.equals(n.getId())).findFirst()
|
||||
.orElseThrow(() -> new AssertionError("节点缺失:" + id));
|
||||
}
|
||||
|
||||
// ==================== C1:DEPT_KEY 节点科室视图 ====================
|
||||
|
||||
@Test
|
||||
void c1_deptView_deptKeyNode_labelDeptName_subLabelCountAndDeadline() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四")));
|
||||
FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, "dept");
|
||||
FlowModels.NodeVo n1 = node(vo, "n1");
|
||||
assertEquals("人事处", n1.getLabel());
|
||||
assertEquals("科室 · 2人 · 时限48h", n1.getSubLabel());
|
||||
assertEquals("rs", n1.getDeptKey());
|
||||
assertEquals("DEPT_KEY", n1.getAssigneeType());
|
||||
assertEquals("ALL", n1.getCountersign());
|
||||
assertEquals(Integer.valueOf(48), n1.getDeadlineHours());
|
||||
// 科室视图不出现映射成员姓名
|
||||
assertFalse(n1.getSubLabel().contains("张三"));
|
||||
assertFalse(n1.getSubLabel().contains("李四"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_deptView_fallbackChain_deptKeyThenNodeName() {
|
||||
// 映射行全无名 → deptKey 兜底
|
||||
when(mapRepository.findEnabled(SCH, "rs"))
|
||||
.thenReturn(Collections.singletonList(mapRow("", 11, "张三")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("rs", n1.getLabel());
|
||||
assertEquals("科室 · 1人 · 时限48h", n1.getSubLabel());
|
||||
// 无映射行 → 节点名兜底(人数照实为 0)
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Collections.emptyList());
|
||||
n1 = node(service.build(def(DEPT_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("人事处审批", n1.getLabel());
|
||||
assertEquals("科室 · 0人 · 时限48h", n1.getSubLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_anyCountersign_appendsSuffix() {
|
||||
when(mapRepository.findEnabled(SCH, "rs"))
|
||||
.thenReturn(Collections.singletonList(mapRow("人事处", 11, "张三")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(ANY_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("科室 · 1人 · 任一人通过", n1.getSubLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_fixedLabels_andEdgesCarried() {
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, "dept");
|
||||
assertEquals("开始", node(vo, "start").getLabel());
|
||||
assertEquals("并行分发", node(vo, "sp1").getLabel());
|
||||
assertEquals("汇聚等待", node(vo, "j1").getLabel());
|
||||
assertEquals("结束", node(vo, "end").getLabel());
|
||||
assertNull(node(vo, "start").getSubLabel());
|
||||
assertNull(node(vo, "end").getSubLabel());
|
||||
assertEquals(4, vo.getEdges().size());
|
||||
assertEquals("start", vo.getEdges().get(0).getFrom());
|
||||
assertEquals("sp1", vo.getEdges().get(0).getTo());
|
||||
// 出图元信息(前端"按流程定义 vN 生成"与工具条 Tag 数据源)
|
||||
assertEquals("g1", vo.getFlowGuid());
|
||||
assertEquals(Long.valueOf(12L), vo.getDefId());
|
||||
assertEquals("教师入职", vo.getName());
|
||||
assertEquals(Integer.valueOf(2), vo.getVersion());
|
||||
assertEquals(Integer.valueOf(1), vo.getStatus());
|
||||
assertEquals("dept", vo.getMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_autoView_deptKeyNode_rendersDeptStyle() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, null), "n1");
|
||||
assertEquals("人事处", n1.getLabel());
|
||||
assertEquals("科室 · 2人 · 时限48h", n1.getSubLabel());
|
||||
assertEquals("auto", service.build(def(DEPT_DEF_JSON), null, null).getMode());
|
||||
}
|
||||
|
||||
// ==================== C2:USERS 节点人员视图(UserBase 补名降级) ====================
|
||||
|
||||
@Test
|
||||
void c2_userView_usersNode_namesFromUserBase_degradeToId() {
|
||||
BoaUser jia = boaUser("甲老师");
|
||||
BoaUser yi = boaUser("乙老师");
|
||||
when(userClient.getUserById("11")).thenReturn(jia);
|
||||
when(userClient.getUserById("12")).thenReturn(yi);
|
||||
when(userClient.getUserById("13")).thenThrow(new RuntimeException("UserBase 不可达"));
|
||||
FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "user"), "n2");
|
||||
assertEquals("甲乙会签", n2.getLabel());
|
||||
assertEquals("甲老师、乙老师、#13", n2.getSubLabel());
|
||||
assertEquals("USERS", n2.getAssigneeType());
|
||||
assertNull(n2.getDeptKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c2_userBaseReturnsNullUser_degradesToId() {
|
||||
when(userClient.getUserById(anyString())).thenReturn(null);
|
||||
FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "user"), "n2");
|
||||
assertEquals("#11、#12、#13", n2.getSubLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c2_autoView_usersNode_rendersUserStyle() {
|
||||
BoaUser jia = boaUser("甲老师");
|
||||
BoaUser yi = boaUser("乙老师");
|
||||
when(userClient.getUserById("11")).thenReturn(jia);
|
||||
when(userClient.getUserById("12")).thenReturn(yi);
|
||||
when(userClient.getUserById("13")).thenReturn(null);
|
||||
FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "auto"), "n2");
|
||||
assertEquals("甲老师、乙老师、#13", n2.getSubLabel());
|
||||
}
|
||||
|
||||
// ==================== C3:视图分支防泄名单 / DEPT_KEY 人员视图 ====================
|
||||
|
||||
@Test
|
||||
void c3_deptView_usersNode_subLabelOnlyAssigneeHint_neverCallsUserBase() {
|
||||
BoaUser jia = boaUser("甲老师");
|
||||
BoaUser yi = boaUser("乙老师");
|
||||
when(userClient.getUserById("11")).thenReturn(jia);
|
||||
when(userClient.getUserById("12")).thenReturn(yi);
|
||||
FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "dept"), "n2");
|
||||
assertEquals("甲乙会签", n2.getLabel());
|
||||
assertEquals("指定办理人", n2.getSubLabel());
|
||||
assertFalse(n2.getSubLabel().contains("甲老师"));
|
||||
assertFalse(n2.getSubLabel().contains("乙老师"));
|
||||
// 防泄名单硬保证:科室视图连 UserBase 都不查
|
||||
verify(userClient, never()).getUserById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c3_userView_deptKeyNode_listsMemberNames_blankNameDegradesToId() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, null)));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "user"), "n1");
|
||||
// 人员视图:label=节点名,subLabel=映射成员名(空名降级 #id)+ 会签/时限后缀
|
||||
assertEquals("人事处审批", n1.getLabel());
|
||||
assertEquals("张三、#12 · 时限48h", n1.getSubLabel());
|
||||
}
|
||||
|
||||
// ==================== C4:flowGuid 分支取版与参数校验 ====================
|
||||
|
||||
@Test
|
||||
void c4_byGuid_returnsHighestPublishedDef() {
|
||||
FlwDef def = def(DEPT_DEF_JSON);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
SCH, "g1", FlwEnums.DefStatus.PUBLISHED.getCode()))
|
||||
.thenReturn(Optional.of(def));
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
FlowModels.DiagramVo vo = service.byGuid(SCH, "g1", null);
|
||||
assertEquals("教师入职", vo.getName());
|
||||
assertEquals(Integer.valueOf(2), vo.getVersion());
|
||||
assertEquals("auto", vo.getMode());
|
||||
assertNull(vo.getInstanceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c4_byGuid_noPublished_throwsBizException() {
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
anyLong(), anyString(), anyInt()))
|
||||
.thenReturn(Optional.empty());
|
||||
BizException e = assertThrows(BizException.class, () -> service.byGuid(SCH, "g1", "dept"));
|
||||
assertEquals("流程不存在或未发布", e.getMessage());
|
||||
assertEquals(ErrorCode.PARAM_ERROR, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c4_byGuid_crossSchoolDef_forbidden() {
|
||||
FlwDef other = def(DEPT_DEF_JSON);
|
||||
other.setSchId(888888L);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
anyLong(), anyString(), anyInt()))
|
||||
.thenReturn(Optional.of(other));
|
||||
BizException e = assertThrows(BizException.class, () -> service.byGuid(SCH, "g1", "dept"));
|
||||
assertEquals("跨校不可查看", e.getMessage());
|
||||
assertEquals(ErrorCode.FORBIDDEN, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c4_invalidMode_rejected() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.build(def(DEPT_DEF_JSON), null, "xxx"));
|
||||
assertEquals("mode 仅支持 dept/user", e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c4_byInstance_usesDefSnapshotOfInstance() {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(100L);
|
||||
instance.setSchId(SCH);
|
||||
instance.setDefId(12L);
|
||||
when(defRepository.findById(12L)).thenReturn(Optional.of(def(DEPT_DEF_JSON)));
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
FlowModels.DiagramVo vo = service.byInstance(instance, "dept");
|
||||
assertEquals(Long.valueOf(100L), vo.getInstanceId());
|
||||
assertEquals(Long.valueOf(12L), vo.getDefId());
|
||||
assertEquals(Integer.valueOf(2), vo.getVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c6_build_draftDef_ok() {
|
||||
FlwDef draft = def(DEPT_DEF_JSON);
|
||||
draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
FlowModels.DiagramVo vo = service.build(draft, null, "auto");
|
||||
assertEquals(Integer.valueOf(0), vo.getStatus());
|
||||
assertEquals(5, vo.getNodes().size());
|
||||
}
|
||||
|
||||
// ==================== C5:用户面 /api/flow/diagram 控制器守卫 ====================
|
||||
|
||||
@Nested
|
||||
class UserDiagramEndpoint {
|
||||
|
||||
private FlowQueryService queryService;
|
||||
private FlowTaskService taskService;
|
||||
private FlowInstanceService instanceService;
|
||||
private FlowUserController controller;
|
||||
private FlowDiagramService diagramService;
|
||||
|
||||
@BeforeEach
|
||||
void setUpController() {
|
||||
queryService = mock(FlowQueryService.class);
|
||||
taskService = mock(FlowTaskService.class);
|
||||
instanceService = mock(FlowInstanceService.class);
|
||||
diagramService = mock(FlowDiagramService.class);
|
||||
// 真守卫(同 FlowAccessGuardTest 口径):提交人/办理人/本校管理员
|
||||
FlowAccessGuard accessGuard = new FlowAccessGuard(taskRepository);
|
||||
controller = new FlowUserController(queryService, taskService, instanceService,
|
||||
accessGuard, diagramService);
|
||||
}
|
||||
|
||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||
BoaUser u = mock(BoaUser.class, RETURNS_DEEP_STUBS);
|
||||
when(u.getId()).thenReturn(id);
|
||||
when(u.getType()).thenReturn(type);
|
||||
when(u.getRoleIds()).thenReturn(roleIds.length == 0
|
||||
? null : new ArrayList<>(Arrays.asList(roleIds)));
|
||||
when(u.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
return u;
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_flowGuid_sameSchoolUser_ok() {
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
when(diagramService.byGuid(SCH, "g1", "dept")).thenReturn(vo);
|
||||
assertSame(vo, controller.diagram(user(11, 2, SCH.intValue(), 9), "g1", null, "dept"));
|
||||
verify(diagramService).byGuid(SCH, "g1", "dept");
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_flowGuid_notLoggedIn_forbidden() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> controller.diagram(null, "g1", null, null));
|
||||
assertEquals("未登录", e.getMessage());
|
||||
assertEquals(ErrorCode.FORBIDDEN, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_noParam_paramError() {
|
||||
assertThrows(BizException.class,
|
||||
() -> controller.diagram(user(11, 2, SCH.intValue(), 9), null, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_instance_submitter_ok() {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(100L);
|
||||
instance.setSchId(SCH);
|
||||
instance.setSubmitterId(11);
|
||||
when(instanceService.requireInstance(SCH, 100L)).thenReturn(instance);
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
when(diagramService.byInstance(instance, "dept")).thenReturn(vo);
|
||||
assertSame(vo, controller.diagram(user(11, 2, SCH.intValue(), 9), null, 100L, "dept"));
|
||||
verify(diagramService).byInstance(instance, "dept");
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_instance_assignee_ok() {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(100L);
|
||||
instance.setSchId(SCH);
|
||||
instance.setSubmitterId(11);
|
||||
FlwTask task = new FlwTask();
|
||||
task.setAssigneeId(22);
|
||||
when(instanceService.requireInstance(SCH, 100L)).thenReturn(instance);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(100L))
|
||||
.thenReturn(Collections.singletonList(task));
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
when(diagramService.byInstance(instance, "dept")).thenReturn(vo);
|
||||
assertSame(vo, controller.diagram(user(22, 2, SCH.intValue(), 9), null, 100L, "dept"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_instance_unrelatedUser_forbidden() {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(100L);
|
||||
instance.setSchId(SCH);
|
||||
instance.setSubmitterId(11);
|
||||
when(instanceService.requireInstance(SCH, 100L)).thenReturn(instance);
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> controller.diagram(user(36, 2, SCH.intValue(), 9), null, 100L, "dept"));
|
||||
assertEquals("仅提交人/办理人/本校管理员可查看该办件", e.getMessage());
|
||||
verify(diagramService, never()).byInstance(any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== C6:管理面 /api/flow/admin/def/diagram ====================
|
||||
|
||||
@Nested
|
||||
class AdminDefDiagramEndpoint {
|
||||
|
||||
private FlowAdminController controller;
|
||||
private FlowDefAdminService defAdminService;
|
||||
private FlowDiagramService diagramService;
|
||||
|
||||
@BeforeEach
|
||||
void setUpController() {
|
||||
defAdminService = mock(FlowDefAdminService.class);
|
||||
diagramService = mock(FlowDiagramService.class);
|
||||
// 真管理守卫(纯逻辑无依赖)+ 其余依赖 mock
|
||||
controller = new FlowAdminController(new FlowAdminGuard(), defAdminService,
|
||||
mock(FlowMapAdminService.class), mock(FlowCallbackAdminService.class),
|
||||
mock(FlwInstanceRepository.class), diagramService);
|
||||
}
|
||||
|
||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||
BoaUser u = mock(BoaUser.class, RETURNS_DEEP_STUBS);
|
||||
when(u.getId()).thenReturn(id);
|
||||
when(u.getType()).thenReturn(type);
|
||||
when(u.getRoleIds()).thenReturn(roleIds.length == 0
|
||||
? null : new ArrayList<>(Arrays.asList(roleIds)));
|
||||
when(u.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
return u;
|
||||
}
|
||||
|
||||
@Test
|
||||
void c6_admin_draftDef_ok() {
|
||||
FlwDef draft = def(DEPT_DEF_JSON);
|
||||
draft.setId(55L);
|
||||
draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
when(defAdminService.get(SCH, 55L)).thenReturn(draft);
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
when(diagramService.build(draft, null, "auto")).thenReturn(vo);
|
||||
assertSame(vo, controller.defDiagram(user(1, 1, SCH.intValue()), 55L, "auto"));
|
||||
verify(diagramService).build(draft, null, "auto");
|
||||
}
|
||||
|
||||
@Test
|
||||
void c6_nonAdmin_forbidden() {
|
||||
BoaUser ordinary = user(77, 2, SCH.intValue(), 9);
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> controller.defDiagram(ordinary, 55L, "auto"));
|
||||
assertEquals("无流程管理权限", e.getMessage());
|
||||
verify(defAdminService, never()).get(any(), anyLong());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.yunhai.flowcenter.stub;
|
||||
|
||||
import com.yunhai.platform.openapi.client.OpenApiUserClient;
|
||||
import com.yunhai.platform.openapi.model.SysUserGroupDeleteItem;
|
||||
import com.yunhai.platform.openapi.model.SysUserGroupStateRequest;
|
||||
import com.yunhai.platform.openapi.model.SysUserMoveRequest;
|
||||
import com.yunhai.platform.openapi.model.SysUserRequest;
|
||||
import com.yunhai.platform.openapi.model.SysUserTransferSchoolRequest;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* UserBase 客户端测试 stub:getUserById/getUserInfo 返回 school=1(测试学校)的用户,
|
||||
* 供 OapiFlowService submitterId 本校校验(测试统一用 schId=1);其余端点不参与 M1 测试。
|
||||
*/
|
||||
public class StubUserClient implements OpenApiUserClient {
|
||||
|
||||
public static final long TEST_SCH_ID = 1L;
|
||||
|
||||
@Override
|
||||
public BoaUser getUserById(String userId) {
|
||||
return buildUser(Integer.valueOf(userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getUserInfo(int userId) {
|
||||
return buildUser(userId);
|
||||
}
|
||||
|
||||
private static BoaUser buildUser(int userId) {
|
||||
BoaUser user = new BoaUser(userId, "user" + userId, "用户" + userId);
|
||||
user.setSchool(new BoaGroup((int) TEST_SCH_ID, "测试学校"));
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getUserByLoginName(String loginName) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getUserByPhone(String phone) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getUserInfo(String loginName) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BoaModule> getUserModule(int userId, Integer schoolId) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser addUser(SysUserRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser editUser(int id, SysUserRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteUser(int id, int groupId, int schoolId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteUsers(List<SysUserGroupDeleteItem> items) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BoaUser> batchAddUsers(List<SysUserRequest> requests) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser moveUserToGroup(SysUserMoveRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BoaRoleInfo> getUserRoleList(int userId) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaRoleInfo getCurrentIdentity(int userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaRoleInfo switchIdentity(int userId, int roleId, int schoolId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUserPassword(int userId, String oldPassword, String newPassword) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String updateUserProfilePicture(int userId, byte[] fileBytes, String filename) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUserGroupState(SysUserGroupStateRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getUserGroupState(int userId, int groupId, int schoolId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser transferUserToSchool(SysUserTransferSchoolRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changeUserStatus(int userId, boolean disabled) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void batchChangeUserStatus(List<Integer> userIds, boolean disabled) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.yunhai.flowcenter.support;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* IT 公共基类:专用测试库 bsch_flowcenter_test,每例清表(引擎测试不能开测试事务——
|
||||
* AFTER_COMMIT 推进监听器依赖真实提交);提供定义落库/发布、映射注册的快捷方法。
|
||||
*/
|
||||
public abstract class FlowTestSupport {
|
||||
|
||||
public static final long SCH = 1L;
|
||||
|
||||
@Autowired
|
||||
protected FlwDefRepository defRepository;
|
||||
@Autowired
|
||||
protected FlwInstanceRepository instanceRepository;
|
||||
@Autowired
|
||||
protected FlwTaskRepository taskRepository;
|
||||
@Autowired
|
||||
protected FlwAssigneeMapRepository mapRepository;
|
||||
@Autowired
|
||||
protected FlwCallbackRegRepository regRepository;
|
||||
@Autowired
|
||||
protected FlwCallbackLogRepository logRepository;
|
||||
|
||||
protected void cleanDb() {
|
||||
logRepository.deleteAll();
|
||||
regRepository.deleteAll();
|
||||
taskRepository.deleteAll();
|
||||
instanceRepository.deleteAll();
|
||||
defRepository.deleteAll();
|
||||
mapRepository.deleteAll();
|
||||
}
|
||||
|
||||
protected FlwAssigneeMap mapDept(String deptKey, Integer userId, String userName) {
|
||||
FlwAssigneeMap map = new FlwAssigneeMap();
|
||||
map.setSchId(SCH);
|
||||
map.setDeptKey(deptKey);
|
||||
map.setDeptName(deptKey + "科");
|
||||
map.setUserId(userId);
|
||||
map.setUserName(userName);
|
||||
return mapRepository.save(map);
|
||||
}
|
||||
|
||||
protected FlwDef saveAndPublishDef(String code, String name, String contentJson) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase());
|
||||
def.setCode(code);
|
||||
def.setName(name);
|
||||
def.setVersion(1);
|
||||
def.setStatus(1);
|
||||
def.setPublishTime(java.time.LocalDateTime.now());
|
||||
def.setContentJson(contentJson);
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
/** 线性流程:START → deptA 审批(ALL 会签)→ END */
|
||||
protected String linearDef(String deptKey, String nodeName) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"线性测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"" + nodeName + "\","
|
||||
+ "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptKey + "\"},\"countersign\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/** 并行流程:START → split → 两分支(deptA/deptB)→ join → END */
|
||||
protected String parallelDef(String deptA, String deptB) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"并行测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"t_a\",\"type\":\"APPROVAL\",\"name\":\"甲科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
|
||||
+ "{\"id\":\"t_b\",\"type\":\"APPROVAL\",\"name\":\"乙科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
|
||||
+ "{\"from\":\"split1\",\"to\":\"t_a\"},{\"from\":\"split1\",\"to\":\"t_b\"},"
|
||||
+ "{\"from\":\"t_a\",\"to\":\"join1\"},{\"from\":\"t_b\",\"to\":\"join1\"},"
|
||||
+ "{\"from\":\"join1\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
private static String codeOf() {
|
||||
return "tc_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.yunhai.flowcenter.support;
|
||||
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* oapi 契约测试客户端:手工三件套签名(X-App-Key/X-Timestamp/X-Signature,
|
||||
* SignUtil.signRequest 请求级 canonical——与服务端 AppAuthFilter 验签同款)。
|
||||
* 故意不用共享 IDHttp:测试要能看到 401 原始响应。
|
||||
*/
|
||||
public class OapiSignedClient {
|
||||
|
||||
private final String baseUrl;
|
||||
private final String appKey;
|
||||
private final String appSecret;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public OapiSignedClient(String baseUrl, String appKey, String appSecret) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.appKey = appKey;
|
||||
this.appSecret = appSecret;
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(3000);
|
||||
factory.setReadTimeout(5000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
// 4xx/5xx 不抛异常,返回原始响应供断言(401/500 契约用例需要拿到状态码)
|
||||
this.restTemplate.setErrorHandler(new org.springframework.web.client.ResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public <T> ResponseEntity<T> get(String pathAndQuery, Class<T> type) {
|
||||
return exchange(HttpMethod.GET, pathAndQuery, null, type);
|
||||
}
|
||||
|
||||
public <T> ResponseEntity<T> post(String pathAndQuery, Object body, Class<T> type) {
|
||||
return exchange(HttpMethod.POST, pathAndQuery, body, type);
|
||||
}
|
||||
|
||||
private <T> ResponseEntity<T> exchange(HttpMethod method, String pathAndQuery, Object body, Class<T> type) {
|
||||
String target = signTarget(pathAndQuery);
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest(method.name(), target, ts, appSecret);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", sign);
|
||||
if (body != null) {
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, method,
|
||||
body == null ? new HttpEntity<>(headers) : new HttpEntity<>(body, headers), type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与服务端对齐的签名 target:path + "?" + 排序后的 query(SignCanonical.sortRawQuery 语义,
|
||||
* 单参数场景天然有序;多参数时按 key 字典序拼接)。
|
||||
*/
|
||||
private static String signTarget(String pathAndQuery) {
|
||||
int q = pathAndQuery.indexOf('?');
|
||||
if (q < 0) {
|
||||
return pathAndQuery;
|
||||
}
|
||||
String path = pathAndQuery.substring(0, q);
|
||||
String[] pairs = pathAndQuery.substring(q + 1).split("&");
|
||||
java.util.Arrays.sort(pairs);
|
||||
StringBuilder sb = new StringBuilder(path);
|
||||
for (String pair : pairs) {
|
||||
if (sb.indexOf("?") < 0) {
|
||||
sb.append("?");
|
||||
} else if (sb.charAt(sb.length() - 1) != '?') {
|
||||
sb.append("&");
|
||||
}
|
||||
sb.append(pair);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 无签名调用(401 用例) */
|
||||
public ResponseEntity<String> getRaw(String pathAndQuery) {
|
||||
return restTemplate.getForEntity(baseUrl + pathAndQuery, String.class);
|
||||
}
|
||||
|
||||
/** 带错误密钥的调用(401 用例) */
|
||||
public ResponseEntity<String> getWithBadSecret(String pathAndQuery) {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest("GET", signTarget(pathAndQuery), ts, "bad-secret-000000000000");
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", sign);
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.GET,
|
||||
new HttpEntity<>(headers), String.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# 测试环境覆盖(@ActiveProfiles("test")):
|
||||
# 库指向 bsch_flowcenter_test(ddl-auto=update 建表);UserMsg 通知关闭(无需 UserMsg 在跑)
|
||||
spring:
|
||||
jpa:
|
||||
show-sql: true
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/bsch_flowcenter_test?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
|
||||
# IT 保持确定性:不开中央密钥链(trusted-apps 白名单即够),不依赖 Redis
|
||||
yh:
|
||||
app-auth:
|
||||
trust-registry: false
|
||||
|
||||
flow-center:
|
||||
usermsg:
|
||||
enabled: false
|
||||
cors:
|
||||
allowed-origins: ""
|
||||
callback:
|
||||
# 挂起/死信告警接收人(IT 用计数版 @Primary UserMsgClient 截获,不真正外发)
|
||||
alert-user-ids: "999"
|
||||
Reference in New Issue
Block a user