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,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);
}
}