feat(embed): 新增办件进度嵌入页签发端点与免签数据端点
build / build-push (push) Successful in 10m1s

This commit is contained in:
chenxing
2026-10-03 16:42:21 +08:00
parent 1d2f4a0cb2
commit d28b5d1161
6 changed files with 251 additions and 0 deletions
@@ -3,11 +3,15 @@ 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.service.DiagramTokenService;
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;
@@ -15,12 +19,14 @@ 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;
/**
@@ -41,6 +47,16 @@ class OapiFlowContractIT extends FlowTestSupport {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private OapiFlowService flowService;
@Autowired
private DiagramTokenService tokenService;
/** 签发端点拼 pageUrl 用的前端基址(application.yml 实配值,断言前缀不硬编码端口) */
@Value("${flow-center.embed.front-url}")
private String embedFrontUrl;
private OapiSignedClient client;
@TestConfiguration
@@ -244,6 +260,100 @@ class OapiFlowContractIT extends FlowTestSupport {
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 定位/快照/版本);查询不限办理人(办理才校验) */