608 lines
34 KiB
Java
608 lines
34 KiB
Java
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);
|
||
}
|
||
}
|