Files
flow-center-service/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
T
chenxing 9cc0b06c29
build / build-push (push) Successful in 1m30s
feat(flow): 工作台与轨迹优化 + 管理端启用回路 + 本地验签配置修复
- 已办去重: 同一办件一行 + doneCount 累计次数(FlwTaskRepository 查询去重)
- 发起/撤回/重提留痕事件行(状态码 6/7, 无需 DDL); /oapi/flow/progress steps 含撤回/重提步骤
- 管理端新增启用回路: POST /admin/map/enable/{id}、POST /admin/def/enable/{id}; GET /api/flow/def/source 源码查询
- 修复 findEnabledByOrdId 未过滤 status 的隐性缺陷; 新增 DeptLabelSupport 科室标签支撑
- 测试: FlowEngineIT 撤回留痕/已办去重断言、OapiFlowContractIT 撤回步骤断言(94 tests 全过)
- 本地验签修复: 本地 UserBase 无 context-path, yh.open.services 显式覆盖服务目录枚举兜底的 /userbase basePath, 否则出站签名与服务端剥离不对齐 → GetUserInfo 等全部「验签失败」; 生产经 Caddy 挂 /userbase 时删除 services 覆盖
2026-09-18 16:56:01 +08:00

253 lines
13 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.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(), "不可达回调地址应被探活拦截");
}
/**
* 撤回托管件契约(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());
}
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);
}
}