feat: 提交流程中心后端代码
build / build-push (push) Successful in 28s

Spring Boot 流程引擎服务:流程定义/实例/任务、审批人解析、
回调分发、oapi 接口及单元测试。
保留远程 .gitea/workflows/build.yml 与 docker/Dockerfile,
新增 .gitignore 排除 target/ 构建产物。
This commit is contained in:
chenxing
2026-09-15 10:42:56 +08:00
parent 0296b2b5dd
commit c1103aeb29
65 changed files with 7119 additions and 0 deletions
@@ -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);
}
}