Files
flow-center-service/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
T

608 lines
34 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.entity.FlwRouter;
import com.yunhai.flowcenter.service.DiagramTokenService;
import com.yunhai.flowcenter.service.FlowRouterAdminService;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.flowcenter.support.FlowTestSupport;
import com.yunhai.flowcenter.support.OapiSignedClient;
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.beans.factory.annotation.Value;
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 org.springframework.test.util.ReflectionTestUtils;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
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;
@Autowired
private OapiFlowService flowService;
@Autowired
private FlowRouterAdminService routerAdminService;
@Autowired
private DiagramTokenService tokenService;
/** 签发端点拼 pageUrl 用的前端基址(application.yml 实配值,断言前缀不硬编码端口) */
@Value("${flow-center.embed.front-url}")
private String embedFrontUrl;
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(), "不可达回调地址应被探活拦截");
}
/**
* 撤回托管件契约(2026-09-16 补:此前只有 CAS 用户面端点,业务应用只能浏览器直调):
* 提交人本人 → 200 + 办件 40 + 待办失效;非提交人 → 业务码拒绝(应用签名不替代身份)。
*/
@Test
void withdraw_bySubmitter_whenApproving() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("t15", "撤回", linearDef("ybb", "审核"));
String body = "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-w1\",\"bizTitle\":\"张三的撤回件\","
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", body, JsonNode.class)
.getBody()).get("instanceId").asLong();
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
// 非提交人(202)代撤 → 拒绝
ResponseEntity<JsonNode> forged = client.post(
"/oapi/flow/instance/" + instanceId + "/withdraw?schId=1&submitterId=202", null, JsonNode.class);
assertNotEquals(0, forged.getBody().get("code").asInt(), "非提交人撤回应被拒绝");
assertEquals(10, instanceRepository.findById(instanceId).get().getStatus());
// 提交人本人(201)→ 撤回成功
ResponseEntity<JsonNode> ok = client.post(
"/oapi/flow/instance/" + instanceId + "/withdraw?schId=1&submitterId=201", null, JsonNode.class);
assertEquals(200, ok.getStatusCodeValue());
assertEquals(0, ok.getBody().get("code").asInt(), "撤回成功须返回业务成功码");
assertEquals(40, instanceRepository.findById(instanceId).get().getStatus());
assertEquals(0, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(), "待办任务须全部失效");
// 对外轨迹(progress steps)必须能看到"撤回"这一步(2026-09-18 撤回留痕)
JsonNode progress = unwrap(client.get(
"/oapi/flow/progress?schId=1&instanceId=" + instanceId, JsonNode.class).getBody());
JsonNode steps = progress.get("steps");
JsonNode withdrawStep = null;
for (JsonNode s : steps) {
if ("withdraw".equals(s.get("nodeId").asText())) {
withdrawStep = s;
}
}
assertNotNull(withdrawStep, "对外进度须含撤回步骤:" + steps);
assertEquals("撤回申请", withdrawStep.get("nodeName").asText());
assertEquals("已撤回", withdrawStep.get("label").asText());
assertEquals("已撤回", withdrawStep.get("assignees").get(0).get("statusLabel").asText());
assertEquals(201, withdrawStep.get("assignees").get(0).get("userId").asInt());
// 发起步仍在首位且文案不变(新办件用真实留痕行,老办件走合成步——对外表现一致)
assertEquals("submit", steps.get(0).get("nodeId").asText());
assertEquals("发起申请", steps.get(0).get("label").asText());
// 重复撤回(已 40)→ 拒绝
ResponseEntity<JsonNode> again = client.post(
"/oapi/flow/instance/" + instanceId + "/withdraw?schId=1&submitterId=201", null, JsonNode.class);
assertNotEquals(0, again.getBody().get("code").asInt(), "非审批中状态不可撤回");
}
/** 跨租户撤回:schId 不符 → 拒绝,且不得改动办件 */
@Test
void withdraw_crossTenant_rejected() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("t16", "跨租户撤回", linearDef("ybb", "审核"));
String body = "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-w2\",\"bizTitle\":\"跨租户\","
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", body, JsonNode.class)
.getBody()).get("instanceId").asLong();
ResponseEntity<JsonNode> resp = client.post(
"/oapi/flow/instance/" + instanceId + "/withdraw?schId=2&submitterId=201", null, JsonNode.class);
assertNotEquals(0, resp.getBody().get("code").asInt(), "跨租户撤回应被拒绝");
assertEquals(10, instanceRepository.findById(instanceId).get().getStatus());
}
// ===== 办件进度嵌入页(token 模式,与换图同构):embed/url 签发 + /open/flow/embed/data 消费 =====
/** 嵌入换址契约:签名强制 / pageUrl 形态 / bizType 定位 / 办件不存在业务码 / 未配置 fail-fast */
@Test
void embedUrl_contract() {
assertEquals(401, client.getRaw("/oapi/flow/embed/url?schId=1&instanceId=1").getStatusCodeValue());
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("t30", "嵌入页", linearDef("ybb", "审核"));
Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"notice\",\"bizId\":\"em-1\",\"bizTitle\":\"嵌入办件\","
+ "\"submitterId\":201,\"submitterName\":\"张三\"}", JsonNode.class).getBody())
.get("instanceId").asLong();
// instanceId 直取:pageUrl=前端基址 + hash 路由 + 短时 token,expiresIn 与换图 TTL 同源
JsonNode byInstance = unwrap(client.get(
"/oapi/flow/embed/url?schId=1&instanceId=" + instanceId, JsonNode.class).getBody());
String pageUrl = byInstance.get("pageUrl").asText();
assertTrue(pageUrl.startsWith(embedFrontUrl), "pageUrl 须以配置的前端基址开头:" + pageUrl);
assertTrue(pageUrl.contains("#/embed/instance?token="), "pageUrl 须为嵌入页 hash 深链:" + pageUrl);
assertFalse(extractToken(pageUrl).isEmpty(), "token 不可为空");
assertEquals("&mode=auto", pageUrl.substring(pageUrl.length() - "&mode=auto".length()));
assertEquals(600, byInstance.get("expiresIn").asLong());
// bizType+bizId 定位:与 instanceId 口径指向同一办件
JsonNode byBiz = unwrap(client.get(
"/oapi/flow/embed/url?schId=1&bizType=notice&bizId=em-1", JsonNode.class).getBody());
assertTrue(byBiz.get("pageUrl").asText().contains("#/embed/instance?token="));
// 办件不存在 → 业务码拒绝(与 progress 同口径,不 404 不 500)
ResponseEntity<JsonNode> miss = client.get(
"/oapi/flow/embed/url?schId=1&bizType=notice&bizId=none", JsonNode.class);
assertNotEquals(0, miss.getBody().get("code").asInt(), "办件不存在应业务码拒绝");
}
/** 未配置 front-url → fail-fast 业务异常(非 NPE/空串地址) */
@Test
void embedUrl_frontUrlUnconfigured_failFast() {
Object origin = ReflectionTestUtils.getField(flowService, "embedFrontUrl");
try {
ReflectionTestUtils.setField(flowService, "embedFrontUrl", " ");
BizException ex = assertThrows(BizException.class,
() -> flowService.embedUrl(1L, 1L, null, null, null));
assertTrue(ex.getMessage().contains("嵌入页地址未配置"), "文案须指向配置键:" + ex.getMessage());
} finally {
ReflectionTestUtils.setField(flowService, "embedFrontUrl", origin);
}
}
/** 免签数据端点:无/伪造 token 404;命中 200 + detail 全字段 + no-store;跨校 token 404 */
@Test
void embedData_tokenGated() throws Exception {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("t31", "嵌入数据", linearDef("ybb", "审核"));
Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"notice\",\"bizId\":\"em-2\",\"bizTitle\":\"数据办件\","
+ "\"submitterId\":201,\"submitterName\":\"张三\"}", JsonNode.class).getBody())
.get("instanceId").asLong();
// 免签面:缺 token / 伪造 token 均 404 文本(不 401,不诱导探测)
assertEquals(404, client.getRaw("/open/flow/embed/data").getStatusCodeValue());
ResponseEntity<String> forged = client.getRaw("/open/flow/embed/data?token=forged0000000000");
assertEquals(404, forged.getStatusCodeValue());
assertEquals("链接已过期,请重新打开", forged.getBody());
// 命中:顶层即 InstanceDetail(ResponseEntity 不进信封),trace/bizTitle 全字段可见
String token = extractToken(unwrap(client.get(
"/oapi/flow/embed/url?schId=1&instanceId=" + instanceId, JsonNode.class).getBody())
.get("pageUrl").asText());
ResponseEntity<String> hit = client.getRaw("/open/flow/embed/data?token=" + token);
assertEquals(200, hit.getStatusCodeValue());
JsonNode detail = objectMapper.readTree(hit.getBody());
assertEquals(instanceId.longValue(), detail.get("instanceId").asLong());
assertEquals("数据办件", detail.get("bizTitle").asText());
assertNotNull(detail.get("trace"), "嵌入数据须含轨迹");
assertTrue(detail.get("trace").size() >= 1);
assertEquals("no-store", hit.getHeaders().getCacheControl());
// viewerId=null:本人待办字段不填充(嵌入页只读,不泄办理面板)
assertTrue(detail.get("myPendingTaskIds").isNull() || detail.get("myPendingTaskIds").size() == 0);
// 跨校隔离:token 内 schId 与办件归属不符 → 404(同图片流防漂移口径)
String crossToken = tokenService.issue(2L, instanceId, null, "auto");
assertEquals(404, client.getRaw("/open/flow/embed/data?token=" + crossToken).getStatusCodeValue());
}
/** 从 pageUrl 提取 token(#/embed/instance?token=xxx&mode=yyy) */
private String extractToken(String pageUrl) {
int from = pageUrl.indexOf("token=") + "token=".length();
int to = pageUrl.indexOf("&mode=");
return pageUrl.substring(from, to);
}
// ===== 应用内审批(第三步):tasks/todo + instances + approve/reject =====
/** 待办分页(bizType 过滤 + 出参含 biz 定位/快照/版本);查询不限办理人(办理才校验) */
@Test
void todoTasks_pagedAndFilteredByBizType() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("t20", "待办查询", linearDef("ybb", "审核"));
String noticeBody = "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"notice\",\"bizId\":\"td-1\",\"bizTitle\":\"通知审核\","
+ "\"bizUrl\":\"http://front/#/notice/1\","
+ "\"bizSnapshot\":{\"title\":\"通知审核\",\"summary\":\"摘要\"},"
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
String itemBody = "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"svc_item\",\"bizId\":\"td-2\",\"bizTitle\":\"事项审核\","
+ "\"submitterId\":202,\"submitterName\":\"李四\"}";
unwrap(client.post("/oapi/flow/apply?schId=1", noticeBody, JsonNode.class).getBody());
unwrap(client.post("/oapi/flow/apply?schId=1", itemBody, JsonNode.class).getBody());
// 全量待办
ResponseEntity<JsonNode> all = client.get(
"/oapi/flow/tasks/todo?schId=1&assigneeId=101", JsonNode.class);
assertEquals(200, all.getStatusCodeValue());
assertEquals(0, all.getBody().get("code").asInt());
JsonNode allData = unwrap(all.getBody());
assertEquals(2, allData.get("total").asInt());
JsonNode firstRow = allData.get("content").get(0);
assertNotNull(firstRow.get("taskId").asLong());
assertNotNull(firstRow.get("instanceId").asLong());
assertNotNull(firstRow.get("instanceVersion"));
assertEquals("notice", firstRow.get("bizType").asText());
assertEquals("td-1", firstRow.get("bizId").asText());
assertTrue(firstRow.get("bizSnapshot").asText().contains("摘要"));
assertEquals("审核", firstRow.get("nodeName").asText());
assertEquals(101, firstRow.get("assigneeId").asInt());
// bizType 过滤
ResponseEntity<JsonNode> onlyItem = client.get(
"/oapi/flow/tasks/todo?schId=1&assigneeId=101&bizType=svc_item", JsonNode.class);
JsonNode itemData = unwrap(onlyItem.getBody());
assertEquals(1, itemData.get("total").asInt());
assertEquals("td-2", itemData.get("content").get(0).get("bizId").asText());
}
/** 办件分页(bizType/status 过滤;查询不做办理人校验) */
@Test
void instances_pagedByBizTypeAndStatus() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("t21", "办件查询", linearDef("ybb", "审核"));
unwrap(client.post("/oapi/flow/apply?schId=1", "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"notice\",\"bizId\":\"in-1\",\"bizTitle\":\"办件一\","
+ "\"submitterId\":201,\"submitterName\":\"张三\"}", JsonNode.class).getBody());
unwrap(client.post("/oapi/flow/apply?schId=1", "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"svc_item\",\"bizId\":\"in-2\",\"bizTitle\":\"办件二\","
+ "\"submitterId\":202,\"submitterName\":\"李四\"}", JsonNode.class).getBody());
JsonNode byType = unwrap(client.get(
"/oapi/flow/instances?schId=1&bizType=notice", JsonNode.class).getBody());
assertEquals(1, byType.get("total").asInt());
assertEquals("办件一", byType.get("content").get(0).get("bizTitle").asText());
JsonNode approving = unwrap(client.get(
"/oapi/flow/instances?schId=1&status=10", JsonNode.class).getBody());
assertEquals(2, approving.get("total").asInt());
JsonNode finished = unwrap(client.get(
"/oapi/flow/instances?schId=1&status=20", JsonNode.class).getBody());
assertEquals(0, finished.get("total").asInt());
}
/** 办理(第三步 T-C1b):非办理人业务码拒绝不 500;同意推进+opinion 进回调 payload;拒绝必填意见 */
@Test
void approveAndReject_operatorChecked_opinionPropagates() throws Exception {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("t22", "办理", linearDef("ybb", "审核"));
// 本地回调接收 sink(探活 POST + 事件推送都回 200),直接捕获 payload 断言
List<String> receivedPayloads = java.util.Collections.synchronizedList(new java.util.ArrayList<>());
com.sun.net.httpserver.HttpServer sink = com.sun.net.httpserver.HttpServer.create(
new java.net.InetSocketAddress(java.net.InetAddress.getLoopbackAddress(), 0), 0);
sink.createContext("/", exchange -> {
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = exchange.getRequestBody().read(chunk)) > 0) {
buf.write(chunk, 0, n);
}
receivedPayloads.add(new String(buf.toByteArray(), java.nio.charset.StandardCharsets.UTF_8));
exchange.sendResponseHeaders(200, -1);
exchange.close();
});
sink.start();
try {
String regBody = "{\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"callbackUrl\":\"http://localhost:" + sink.getAddress().getPort() + "/cb\"}";
assertEquals(0, client.post("/oapi/flow/callback/reg?schId=1", regBody, JsonNode.class)
.getBody().get("code").asInt(), "回调注册(指向本地 sink)应探活通过");
Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"svc_item\",\"bizId\":\"ap-1\",\"bizTitle\":\"办理件\","
+ "\"submitterId\":201,\"submitterName\":\"张三\"}", JsonNode.class).getBody())
.get("instanceId").asLong();
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
// 缺 operatorName → 业务码拒绝
ResponseEntity<JsonNode> noName = client.post(
"/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101", "{}", JsonNode.class);
assertNotEquals(0, noName.getBody().get("code").asInt(), "缺 operatorName 应被拒绝");
// 非办理人(999)→ 业务码拒绝(code!=0,HTTP 200),任务仍在待办
ResponseEntity<JsonNode> forged = client.post(
"/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=999&operatorName=路人",
"{\"opinion\":\"越权\"}", JsonNode.class);
assertEquals(200, forged.getStatusCodeValue());
assertNotEquals(0, forged.getBody().get("code").asInt(), "非办理人应被业务码拒绝");
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(),
"越权后任务仍在待办");
// 办理人(101)同意 → 任务 1、单节点单人会签放行 → 办件 20;opinion 进回调 payload
ResponseEntity<JsonNode> ok = client.post(
"/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101&operatorName=甲",
"{\"opinion\":\"同意,情况属实\"}", JsonNode.class);
assertEquals(0, ok.getBody().get("code").asInt(), "办理人同意应成功");
assertEquals(1, taskRepository.findById(taskId).get().getStatus());
assertEquals(20, instanceRepository.findById(instanceId).get().getStatus());
assertTrue(awaitPayloadContains(receivedPayloads, "同意,情况属实"),
"opinion 应透传到回调 payload");
// 第二件:拒绝无意见 → 业务码拒绝;带意见 → 任务 2、办件 30
Long id2 = unwrap(client.post("/oapi/flow/apply?schId=1", "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"svc_item\",\"bizId\":\"ap-2\",\"bizTitle\":\"驳回件\","
+ "\"submitterId\":202,\"submitterName\":\"李四\"}", JsonNode.class).getBody())
.get("instanceId").asLong();
Long taskId2 = taskRepository.findByInstanceIdAndStatus(id2, 0).get(0).getId();
ResponseEntity<JsonNode> noOpinion = client.post(
"/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲",
"{}", JsonNode.class);
assertNotEquals(0, noOpinion.getBody().get("code").asInt(), "拒绝必须填写意见");
ResponseEntity<JsonNode> rejected = client.post(
"/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲",
"{\"opinion\":\"材料不全\"}", JsonNode.class);
assertEquals(0, rejected.getBody().get("code").asInt());
assertEquals(2, taskRepository.findById(taskId2).get().getStatus());
assertEquals(30, instanceRepository.findById(id2).get().getStatus());
assertTrue(awaitPayloadContains(receivedPayloads, "材料不全"),
"拒绝意见也应透传到回调 payload");
} finally {
sink.stop(0);
}
}
/**
* 判断服务自注册契约(2026-10-12 SERVICE 收口,yh-flow-callback starter 的落点):未验签 401;
* 登记即持有(新增行带 directHost、regAppKey/serviceKey=签名 appKey);同键重复登记幂等更新
* (status 不被翻正);校验项业务码拒绝。跨应用覆盖拒绝见 FlowRouterAdminServiceRegisterTest。
*/
@Test
void routerReg_selfRegistration_contract() {
String reg = "{\"routerKey\":\"svc-hall.demo-judge\",\"name\":\"演示判断\","
+ "\"judgePath\":\"/oapi/flow/route/judge\","
+ "\"directHost\":\"http://open-gateway-service:10013/svc-hall\"}";
// 未验签(不带签名头)→ 401,不落行(本路径同受 /oapi/* 强制验签保护)
assertEquals(401, client.getRaw("/oapi/flow/router/reg").getStatusCodeValue());
assertFalse(routerRepository.findByRouterKey("svc-hall.demo-judge").isPresent());
// 登记 → 行落库,归属=签名 appKey
assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class)
.getBody().get("code").asInt(), "自注册应成功");
FlwRouter row = routerRepository.findByRouterKey("svc-hall.demo-judge").get();
assertEquals(APP_KEY, row.getRegAppKey());
assertEquals(APP_KEY, row.getServiceKey(), "serviceKey 填归属 appKey(管理端『来源』展示用)");
assertEquals("http://open-gateway-service:10013/svc-hall", row.getDirectHost());
assertEquals("/oapi/flow/route/judge", row.getPath());
assertEquals(1, row.getStatus().intValue());
Long id = row.getId();
// 管理员停用后自注册 → 仍成功但不翻正 status(控制权在管理员)
routerAdminService.setStatus(id, false);
assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class).getBody().get("code").asInt());
assertEquals(0, routerRepository.findByRouterKey("svc-hall.demo-judge").get().getStatus().intValue(),
"停用状态不被自注册翻正");
// 重复登记(改名+换址)→ 同一行更新,不新增
String again = "{\"routerKey\":\"svc-hall.demo-judge\",\"name\":\"演示判断2\","
+ "\"judgePath\":\"/oapi/flow/route/judge2\",\"directHost\":\"http://new-host:9000/svc-hall\"}";
assertEquals(0, client.post("/oapi/flow/router/reg", again, JsonNode.class)
.getBody().get("code").asInt());
FlwRouter updated = routerRepository.findByRouterKey("svc-hall.demo-judge").get();
assertEquals(id, updated.getId(), "幂等 upsert,不应产生第二行");
assertEquals("演示判断2", updated.getName());
assertEquals("http://new-host:9000/svc-hall", updated.getDirectHost());
assertEquals("/oapi/flow/route/judge2", updated.getPath());
// 校验项拒绝
assertNotEquals(0, client.post("/oapi/flow/router/reg",
"{\"routerKey\":\"a.b\",\"name\":\"n\",\"judgePath\":\"oapi/x\",\"directHost\":\"http://h:1\"}",
JsonNode.class).getBody().get("code").asInt(), "judgePath 缺前导 / 应拒绝");
assertNotEquals(0, client.post("/oapi/flow/router/reg",
"{\"routerKey\":\"a.b\",\"name\":\"n\",\"judgePath\":\"/oapi/x\",\"directHost\":\"h:1\"}",
JsonNode.class).getBody().get("code").asInt(), "directHost 非 http(s) 基址应拒绝");
assertNotEquals(0, client.post("/oapi/flow/router/reg",
"{\"name\":\"n\",\"judgePath\":\"/oapi/x\",\"directHost\":\"http://h:1\"}",
JsonNode.class).getBody().get("code").asInt(), "routerKey 缺失应拒绝");
}
/**
* 自注册行天然满足 SERVICE 发布校验(2026-10-12 验收项):DefValidator 只认 routerKey
* 已注册且启用,与 serviceKey 形态无关(自注册行 serviceKey 填的是数值 appKey)。
*/
@Test
void routerReg_selfRegisteredRow_passesServicePublishValidation() {
mapDept("km", 101, "甲");
mapDept("kb", 102, "乙");
String reg = "{\"routerKey\":\"svc-hall.cond-judge\",\"name\":\"条件判断\","
+ "\"judgePath\":\"/oapi/flow/route/judge\",\"directHost\":\"http://h:1/svc-hall\"}";
assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class)
.getBody().get("code").asInt());
FlwDef def = saveAndPublishDef("t17", "自注册引用", conditionDef("svc-hall.cond-judge", "km", "kb"));
assertEquals(1, def.getVersion().intValue(), "引用自注册行的 SERVICE 定义应发布成功");
}
/** 轮询等异步回调推送(AFTER_COMMIT + 推送池),最长约 5s */
private boolean awaitPayloadContains(List<String> payloads, String needle) throws InterruptedException {
for (int i = 0; i < 25; i++) {
for (String p : payloads) {
if (p.contains(needle)) {
return true;
}
}
Thread.sleep(200);
}
return false;
}
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);
}
}