Compare commits
6
Commits
2f8daa12a5
...
d28b5d1161
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d28b5d1161 | ||
|
|
1d2f4a0cb2 | ||
|
|
d6898ea674 | ||
|
|
09c3c0c38c | ||
|
|
cacdd4ee14 | ||
|
|
2384da7822 |
@@ -50,6 +50,13 @@
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- 平台 auth starter(0928 后快照)的 WeChatAuthAutoConfiguration 签名引用 StringRedisTemplate,
|
||||
data-redis 为 optional,接入前提=应用自带(见该 starter pom 注释);缺失时 bean 签名解析直接
|
||||
TypeNotPresentException,应用起不来 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<!-- CAS 客户端(UserHome/Cms/svc-hall 同款显式声明) -->
|
||||
<dependency>
|
||||
<groupId>org.jasig.cas.client</groupId>
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.List;
|
||||
* - POST /oapi/flow/instance/{instanceId}/withdraw?schId=&submitterId= → data=null(提交人复核后撤回)
|
||||
* - GET /oapi/flow/progress?instanceId=|bizType=&bizId=&schId= → data={status,steps:[…]}
|
||||
* - GET /oapi/flow/diagram/url?instanceId=|flowGuid=&mode=&schId= → data={imgUrl,expiresIn}
|
||||
* - GET /oapi/flow/embed/url?instanceId=|bizType=&bizId=&mode=&schId= → data={pageUrl,expiresIn}
|
||||
* - POST /oapi/flow/callback/reg?schId= → data={secret}
|
||||
* 应用内审批(2026-09 第三步,原"刻意不提供审批动作"立场由该方案修订:登录身份由调用方
|
||||
* 应用按其 CAS 会话保证并以 operatorId 传入,本端按任务办理人逐字复核,审计链不断):
|
||||
@@ -89,6 +90,20 @@ public class OapiFlowController {
|
||||
return flowService.diagramUrl(schId, instanceId, flowGuid, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件进度嵌入页换址(iframe 场景,token 模式与换图同构):pageUrl 为 flow-center 前端
|
||||
* 绝对地址,子应用直接放 {@code <iframe src>}。用户级可见性(本人/办理人/管理员)由
|
||||
* 子应用后端在调本端点前完成——oapi 层只认 schId,应用签名不替代"谁能看"。
|
||||
*/
|
||||
@GetMapping("/embed/url")
|
||||
public OapiModels.EmbedUrlVo embedUrl(@RequestParam(value = "instanceId", required = false) Long instanceId,
|
||||
@RequestParam(value = "bizType", required = false) String bizType,
|
||||
@RequestParam(value = "bizId", required = false) String bizId,
|
||||
@RequestParam(value = "mode", required = false) String mode,
|
||||
@RequestParam("schId") Long schId) {
|
||||
return flowService.embedUrl(schId, instanceId, bizType, bizId, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回(提交人本人;仅尚无任务被办理时)。submitterId 由业务应用按其办件归属传入,
|
||||
* 本端与办件提交人复核——应用签名不替代"谁在撤"的身份证明。
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.yunhai.flowcenter.controller.open;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.service.DiagramTokenService;
|
||||
import com.yunhai.flowcenter.service.FlowQueryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 免签嵌入页数据端点(/open/flow/embed/**,与图片流 /open/flow/diagram/img 同纪律)。
|
||||
* <p>
|
||||
* {@code <iframe src>} 进不了 /api(CAS)也进不了 /oapi(app-sign):免签资格由 10 分钟
|
||||
* 短时 token 承担(与换图共用 DiagramTokenService 同一 token 池,token 由 app-sign 的
|
||||
* GET /oapi/flow/embed/url 签发)。token 分发点之外,本端点不接受任何业务参数直接定位——
|
||||
* 免签面收窄到"只认 token",定位与可见性全在签发侧(OapiFlowService.embedUrl)完成。
|
||||
* 出参复用管理面办件详情 InstanceDetail 整体(viewerId=null,本人待办字段自然为空),
|
||||
* 不新造 VO——嵌入页信息量与管理面一致,单点维护。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/open/flow/embed")
|
||||
@RequiredArgsConstructor
|
||||
public class OpenEmbedController {
|
||||
|
||||
private final DiagramTokenService tokenService;
|
||||
private final FlowQueryService flowQueryService;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
|
||||
@GetMapping("/data")
|
||||
public ResponseEntity<?> embedData(@RequestParam(value = "token", required = false) String token) {
|
||||
DiagramTokenService.TokenEntry entry = tokenService.resolve(token);
|
||||
if (entry == null) {
|
||||
return notFound("链接已过期,请重新打开");
|
||||
}
|
||||
// 办件口径收窄:defId 口径的换图 token(flowGuid 预览)没有办件,不在此消费
|
||||
if (entry.getInstanceId() == null) {
|
||||
return notFound("链接已过期,请重新打开");
|
||||
}
|
||||
// token 已收 schId,出数据前再二次比对防漂移(同图片流口径,token 无法跨校探数据)
|
||||
FlwInstance instance = instanceRepository.findById(entry.getInstanceId()).orElse(null);
|
||||
if (instance == null || !entry.getSchId().equals(instance.getSchId())) {
|
||||
return notFound("办件不存在");
|
||||
}
|
||||
FlowModels.InstanceDetail detail =
|
||||
flowQueryService.detail(entry.getSchId(), entry.getInstanceId(), null);
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(org.springframework.http.CacheControl.noStore())
|
||||
.body(detail);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> notFound(String msg) {
|
||||
// 显式 UTF-8:text/plain 缺 charset 时浏览器/客户端按 ISO-8859-1 解码中文必乱码
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.contentType(new MediaType("text", "plain", java.nio.charset.StandardCharsets.UTF_8))
|
||||
.body(msg);
|
||||
}
|
||||
}
|
||||
@@ -121,8 +121,11 @@ public final class FlowModels {
|
||||
private String mode;
|
||||
private List<NodeVo> nodes;
|
||||
private List<EdgeVo> edges;
|
||||
/** 画布坐标(nodeId→{x,y},设计器保存时透传;空=查看器自行自动布局) */
|
||||
private Map<String, Map<String, Integer>> layout;
|
||||
/**
|
||||
* 布局单源(2026-10-03 三视图统一):nodeId → {x,y,w,h}(内容区坐标,min 归零)。
|
||||
* 查看端(前端 SVG / PNG)一律以此为准;layout(设计器画布坐标)已随画布退役删除。
|
||||
*/
|
||||
private Map<String, Map<String, Integer>> positions;
|
||||
}
|
||||
|
||||
/** 示意图节点(label/subLabel 已按视图解析好,前端只画不解析;state 仅 instance 口径有值) */
|
||||
|
||||
@@ -146,6 +146,15 @@ public final class OapiModels {
|
||||
private long expiresIn;
|
||||
}
|
||||
|
||||
/** 办件进度嵌入页换址响应(pageUrl 可直接放 <iframe src>,SDK FlowEmbedUrl 同构) */
|
||||
@Data
|
||||
public static class EmbedUrlVo {
|
||||
/** 嵌入页绝对地址(flow-center 前端基址 + hash 路由 + 短时 token) */
|
||||
private String pageUrl;
|
||||
/** 相对 token 有效期(秒) */
|
||||
private long expiresIn;
|
||||
}
|
||||
|
||||
/** 待办任务行(tasks/todo 元素):审批列表页数据源,带办件定位与快照 */
|
||||
@Data
|
||||
public static class TodoTaskVo {
|
||||
|
||||
@@ -19,7 +19,6 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -36,36 +35,26 @@ import java.util.Map;
|
||||
* 配色与前端示意图一致(蓝审批卡/绿开始/灰结束/橙菱形),节点颜色按运行时状态
|
||||
* done/active/rejected/pending 上色,图例仅 instance 口径绘制。
|
||||
* 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。
|
||||
* <p>
|
||||
* 布局已抽至 {@link DiagramLayoutService}(2026-10-03 三视图统一),本类只做 PNG 绘制。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DiagramImageService {
|
||||
|
||||
private final DiagramLayoutService diagramLayoutService;
|
||||
|
||||
public DiagramImageService(DiagramLayoutService diagramLayoutService) {
|
||||
this.diagramLayoutService = diagramLayoutService;
|
||||
}
|
||||
|
||||
/** 逻辑坐标常量(输出按 SCALE 倍导出,保证 <img> 缩放清晰) */
|
||||
private static final int SCALE = 2;
|
||||
private static final int MARGIN = 36;
|
||||
private static final int TITLE_H = 56;
|
||||
private static final int SLOT_W = 210; // 列槽宽(盒型在槽内水平居中)
|
||||
private static final int GAP_X = 84; // 列间距(留给正交折线走线)
|
||||
private static final int ROW_H = 92; // 行槽高(盒型在槽内垂直居中)
|
||||
private static final int LEGEND_H = 48;
|
||||
private static final int ARROW = 9;
|
||||
|
||||
/** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行/条件菱形(取外接矩形) */
|
||||
private static int[] boxOf(String type) {
|
||||
switch (type == null ? "" : type) {
|
||||
case FlowDefModel.TYPE_START:
|
||||
case FlowDefModel.TYPE_END:
|
||||
return new int[]{116, 44};
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT:
|
||||
case FlowDefModel.TYPE_PARALLEL_JOIN:
|
||||
case FlowDefModel.TYPE_CONDITION:
|
||||
return new int[]{132, 78};
|
||||
default:
|
||||
return new int[]{200, 64};
|
||||
}
|
||||
}
|
||||
|
||||
// —— 配色与前端 FlowDiagram.vue 示意图一致(深色线/高对比文字) ——
|
||||
private static final Color C_TITLE = new Color(0x17, 0x23, 0x3D);
|
||||
private static final Color C_LINE = new Color(0x51, 0x5A, 0x6E);
|
||||
@@ -103,75 +92,7 @@ public class DiagramImageService {
|
||||
private BufferedImage draw(FlowModels.DiagramVo vo) {
|
||||
List<FlowModels.NodeVo> nodes = vo.getNodes() == null ? new ArrayList<>() : vo.getNodes();
|
||||
List<FlowModels.EdgeVo> edges = vo.getEdges() == null ? new ArrayList<>() : vo.getEdges();
|
||||
Map<String, FlowModels.NodeVo> byId = new HashMap<>();
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
byId.put(n.getId(), n);
|
||||
}
|
||||
Map<String, List<String>> out = new HashMap<>();
|
||||
Map<String, Integer> inDeg = new LinkedHashMap<>();
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
inDeg.put(n.getId(), 0);
|
||||
}
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
if (byId.containsKey(e.getFrom()) && byId.containsKey(e.getTo())) {
|
||||
out.computeIfAbsent(e.getFrom(), k -> new ArrayList<>()).add(e.getTo());
|
||||
inDeg.merge(e.getTo(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
// 拓扑最长路径分层(层=列,自左向右);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
|
||||
Map<String, Integer> level = new HashMap<>();
|
||||
List<String> queue = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> en : inDeg.entrySet()) {
|
||||
if (en.getValue() == 0) {
|
||||
level.put(en.getKey(), 0);
|
||||
queue.add(en.getKey());
|
||||
}
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
String id = queue.remove(queue.size() - 1);
|
||||
int parent = level.get(id);
|
||||
for (String to : out.getOrDefault(id, new ArrayList<>())) {
|
||||
level.merge(to, parent + 1, Math::max);
|
||||
int d = inDeg.merge(to, -1, Integer::sum);
|
||||
if (d == 0) {
|
||||
queue.add(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
level.putIfAbsent(n.getId(), 0);
|
||||
}
|
||||
|
||||
// 初始网格:列 x 由层号决定,列内按定义顺序纵排、整列块以内容区中线居中
|
||||
Map<Integer, List<FlowModels.NodeVo>> layers = new LinkedHashMap<>();
|
||||
int maxLevel = 0;
|
||||
int maxRows = 1;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
int l = level.get(n.getId());
|
||||
maxLevel = Math.max(maxLevel, l);
|
||||
layers.computeIfAbsent(l, k -> new ArrayList<>()).add(n);
|
||||
}
|
||||
for (List<FlowModels.NodeVo> layer : layers.values()) {
|
||||
maxRows = Math.max(maxRows, layer.size());
|
||||
}
|
||||
int axisH = maxRows * ROW_H;
|
||||
Map<String, int[]> box = new HashMap<>();
|
||||
for (Map.Entry<Integer, List<FlowModels.NodeVo>> en : layers.entrySet()) {
|
||||
List<FlowModels.NodeVo> layer = en.getValue();
|
||||
int blockH = layer.size() * ROW_H;
|
||||
int top0 = (axisH - blockH) / 2;
|
||||
for (int i = 0; i < layer.size(); i++) {
|
||||
FlowModels.NodeVo n = layer.get(i);
|
||||
int[] wh = boxOf(n.getType());
|
||||
box.put(n.getId(), new int[]{
|
||||
en.getKey() * (SLOT_W + GAP_X) + (SLOT_W - wh[0]) / 2,
|
||||
top0 + i * ROW_H + (ROW_H - wh[1]) / 2,
|
||||
wh[0], wh[1]});
|
||||
}
|
||||
}
|
||||
|
||||
// 并行分支组以 SPLIT 中心线为轴垂直居中(内层组先处理,最后整体抬回顶部 0)
|
||||
centerParallelGroups(nodes, out, box);
|
||||
Map<String, int[]> box = diagramLayoutService.layout(nodes, edges);
|
||||
|
||||
// 依据实际包围盒定画布尺寸并整体平移进留白
|
||||
int minX = Integer.MAX_VALUE;
|
||||
@@ -273,136 +194,6 @@ public class DiagramImageService {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开。
|
||||
* SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN);
|
||||
* 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。
|
||||
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 0。
|
||||
*/
|
||||
private void centerParallelGroups(List<FlowModels.NodeVo> nodes, Map<String, List<String>> out,
|
||||
Map<String, int[]> box) {
|
||||
class Group {
|
||||
final String split;
|
||||
final String join;
|
||||
final List<String> members;
|
||||
final int maxX;
|
||||
Group(String split, String join, List<String> members, int maxX) {
|
||||
this.split = split;
|
||||
this.join = join;
|
||||
this.members = members;
|
||||
this.maxX = maxX;
|
||||
}
|
||||
}
|
||||
List<Group> groups = new ArrayList<>();
|
||||
for (FlowModels.NodeVo split : nodes) {
|
||||
if (!FlowDefModel.TYPE_PARALLEL_SPLIT.equals(split.getType()) || !box.containsKey(split.getId())) {
|
||||
continue;
|
||||
}
|
||||
String sid = split.getId();
|
||||
// BFS 序 + 各分支可达集,用于定位配对 JOIN
|
||||
Map<String, Integer> order = new HashMap<>();
|
||||
order.put(sid, 0);
|
||||
List<String> bfs = new ArrayList<>();
|
||||
bfs.add(sid);
|
||||
while (!bfs.isEmpty()) {
|
||||
String cur = bfs.remove(0);
|
||||
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
|
||||
if (!order.containsKey(to)) {
|
||||
order.put(to, order.size());
|
||||
bfs.add(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> heads = out.getOrDefault(sid, new ArrayList<>());
|
||||
List<java.util.Set<String>> reachSets = new ArrayList<>();
|
||||
for (String head : heads) {
|
||||
java.util.Set<String> set = new java.util.HashSet<>();
|
||||
List<String> q = new ArrayList<>();
|
||||
q.add(head);
|
||||
while (!q.isEmpty()) {
|
||||
String cur = q.remove(0);
|
||||
if (set.contains(cur)) {
|
||||
continue;
|
||||
}
|
||||
set.add(cur);
|
||||
q.addAll(out.getOrDefault(cur, new ArrayList<>()));
|
||||
}
|
||||
reachSets.add(set);
|
||||
}
|
||||
java.util.Set<String> common = reachSets.isEmpty() ? new java.util.HashSet<>() : reachSets.get(0);
|
||||
for (java.util.Set<String> set : reachSets) {
|
||||
common = new java.util.HashSet<>(common);
|
||||
common.retainAll(set);
|
||||
}
|
||||
String join = null;
|
||||
int joinOrder = Integer.MAX_VALUE;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.getType()) && box.containsKey(n.getId())
|
||||
&& common.contains(n.getId())) {
|
||||
int o = order.getOrDefault(n.getId(), Integer.MAX_VALUE);
|
||||
if (o < joinOrder) {
|
||||
joinOrder = o;
|
||||
join = n.getId();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (join == null) {
|
||||
continue;
|
||||
}
|
||||
// 组员:SPLIT 起步、不穿过 JOIN 的可达集(含嵌套 SPLIT/JOIN)
|
||||
List<String> members = new ArrayList<>();
|
||||
java.util.Set<String> seen = new java.util.HashSet<>();
|
||||
seen.add(join);
|
||||
List<String> q = new ArrayList<>();
|
||||
q.add(sid);
|
||||
while (!q.isEmpty()) {
|
||||
String cur = q.remove(0);
|
||||
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
|
||||
if (seen.contains(to) || !box.containsKey(to)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(to);
|
||||
members.add(to);
|
||||
q.add(to);
|
||||
}
|
||||
}
|
||||
int maxLevel = 0;
|
||||
for (String m : members) {
|
||||
maxLevel = Math.max(maxLevel, box.get(m)[0]);
|
||||
}
|
||||
groups.add(new Group(sid, join, members, maxLevel));
|
||||
}
|
||||
// maxX 降序 = 内层组先处理
|
||||
groups.sort((a, b) -> Integer.compare(b.maxX, a.maxX));
|
||||
for (Group gp : groups) {
|
||||
int[] s = box.get(gp.split);
|
||||
int axisY = s[1] + s[3] / 2;
|
||||
int top = Integer.MAX_VALUE;
|
||||
int bottom = Integer.MIN_VALUE;
|
||||
for (String m : gp.members) {
|
||||
int[] b = box.get(m);
|
||||
top = Math.min(top, b[1]);
|
||||
bottom = Math.max(bottom, b[1] + b[3]);
|
||||
}
|
||||
int delta = axisY - (top + bottom) / 2;
|
||||
for (String m : gp.members) {
|
||||
box.get(m)[1] += delta;
|
||||
}
|
||||
int[] j = box.get(gp.join);
|
||||
j[1] += axisY - (j[1] + j[3] / 2);
|
||||
}
|
||||
// 上移出界(y<0)的组把整图平移回 0(同 delta 平移不破坏各组居中)
|
||||
int minY = Integer.MAX_VALUE;
|
||||
for (int[] b : box.values()) {
|
||||
minY = Math.min(minY, b[1]);
|
||||
}
|
||||
if (minY < 0) {
|
||||
for (int[] b : box.values()) {
|
||||
b[1] -= minY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawNode(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF) {
|
||||
String type = n.getType() == null ? "" : n.getType();
|
||||
if (FlowDefModel.TYPE_START.equals(type) || FlowDefModel.TYPE_END.equals(type)) {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 流程图布局单源(2026-10-03 三视图统一):自 DiagramImageService 原样搬移(算法与常量不变,
|
||||
* PNG 出图逐像素不变)。拓扑最长路径分层(层=列,自左向右),并行分支组以 SPLIT 中心线为轴
|
||||
* 垂直居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN);输出内容区坐标
|
||||
* (minX/minY 已归零),PNG 渲染与 DiagramVo.positions 共同消费——改布局只改这里。
|
||||
*/
|
||||
@Service
|
||||
public class DiagramLayoutService {
|
||||
|
||||
/** 逻辑坐标常量(盒型在槽内居中;列间距留给正交折线走线) */
|
||||
static final int SLOT_W = 210; // 列槽宽(盒型在槽内水平居中)
|
||||
static final int GAP_X = 84; // 列间距(留给正交折线走线)
|
||||
static final int ROW_H = 92; // 行槽高(盒型在槽内垂直居中)
|
||||
|
||||
/** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行/条件菱形(取外接矩形) */
|
||||
static int[] boxOf(String type) {
|
||||
switch (type == null ? "" : type) {
|
||||
case FlowDefModel.TYPE_START:
|
||||
case FlowDefModel.TYPE_END:
|
||||
return new int[]{116, 44};
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT:
|
||||
case FlowDefModel.TYPE_PARALLEL_JOIN:
|
||||
case FlowDefModel.TYPE_CONDITION:
|
||||
return new int[]{132, 78};
|
||||
default:
|
||||
return new int[]{200, 64};
|
||||
}
|
||||
}
|
||||
|
||||
/** nodes+edges → nodeId→{x,y,w,h}(内容区坐标,minX/minY 已归零)。PNG 与 DiagramVo.positions 共同消费;改布局只改这里。 */
|
||||
public Map<String, int[]> layout(List<FlowModels.NodeVo> nodes, List<FlowModels.EdgeVo> edges) {
|
||||
Map<String, FlowModels.NodeVo> byId = new HashMap<>();
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
byId.put(n.getId(), n);
|
||||
}
|
||||
Map<String, List<String>> out = new HashMap<>();
|
||||
Map<String, Integer> inDeg = new LinkedHashMap<>();
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
inDeg.put(n.getId(), 0);
|
||||
}
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
if (byId.containsKey(e.getFrom()) && byId.containsKey(e.getTo())) {
|
||||
out.computeIfAbsent(e.getFrom(), k -> new ArrayList<>()).add(e.getTo());
|
||||
inDeg.merge(e.getTo(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
// 拓扑最长路径分层(层=列,自左向右);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
|
||||
Map<String, Integer> level = new HashMap<>();
|
||||
List<String> queue = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> en : inDeg.entrySet()) {
|
||||
if (en.getValue() == 0) {
|
||||
level.put(en.getKey(), 0);
|
||||
queue.add(en.getKey());
|
||||
}
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
String id = queue.remove(queue.size() - 1);
|
||||
int parent = level.get(id);
|
||||
for (String to : out.getOrDefault(id, new ArrayList<>())) {
|
||||
level.merge(to, parent + 1, Math::max);
|
||||
int d = inDeg.merge(to, -1, Integer::sum);
|
||||
if (d == 0) {
|
||||
queue.add(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
level.putIfAbsent(n.getId(), 0);
|
||||
}
|
||||
|
||||
// 初始网格:列 x 由层号决定,列内按定义顺序纵排、整列块以内容区中线居中
|
||||
Map<Integer, List<FlowModels.NodeVo>> layers = new LinkedHashMap<>();
|
||||
int maxLevel = 0;
|
||||
int maxRows = 1;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
int l = level.get(n.getId());
|
||||
maxLevel = Math.max(maxLevel, l);
|
||||
layers.computeIfAbsent(l, k -> new ArrayList<>()).add(n);
|
||||
}
|
||||
for (List<FlowModels.NodeVo> layer : layers.values()) {
|
||||
maxRows = Math.max(maxRows, layer.size());
|
||||
}
|
||||
int axisH = maxRows * ROW_H;
|
||||
Map<String, int[]> box = new HashMap<>();
|
||||
for (Map.Entry<Integer, List<FlowModels.NodeVo>> en : layers.entrySet()) {
|
||||
List<FlowModels.NodeVo> layer = en.getValue();
|
||||
int blockH = layer.size() * ROW_H;
|
||||
int top0 = (axisH - blockH) / 2;
|
||||
for (int i = 0; i < layer.size(); i++) {
|
||||
FlowModels.NodeVo n = layer.get(i);
|
||||
int[] wh = boxOf(n.getType());
|
||||
box.put(n.getId(), new int[]{
|
||||
en.getKey() * (SLOT_W + GAP_X) + (SLOT_W - wh[0]) / 2,
|
||||
top0 + i * ROW_H + (ROW_H - wh[1]) / 2,
|
||||
wh[0], wh[1]});
|
||||
}
|
||||
}
|
||||
|
||||
// 并行分支组以 SPLIT 中心线为轴垂直居中(内层组先处理,最后整体抬回顶部 0)
|
||||
centerParallelGroups(nodes, out, box);
|
||||
|
||||
// 归零(2026-10-03 三视图统一新增):全体平移使 minX=minY=0,positions 消费端无需再算包围盒
|
||||
if (!box.isEmpty()) {
|
||||
int minX = Integer.MAX_VALUE;
|
||||
int minY = Integer.MAX_VALUE;
|
||||
for (int[] b : box.values()) {
|
||||
minX = Math.min(minX, b[0]);
|
||||
minY = Math.min(minY, b[1]);
|
||||
}
|
||||
if (minX != 0 || minY != 0) {
|
||||
for (int[] b : box.values()) {
|
||||
b[0] -= minX;
|
||||
b[1] -= minY;
|
||||
}
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开。
|
||||
* SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN);
|
||||
* 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。
|
||||
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 0。
|
||||
*/
|
||||
private void centerParallelGroups(List<FlowModels.NodeVo> nodes, Map<String, List<String>> out,
|
||||
Map<String, int[]> box) {
|
||||
class Group {
|
||||
final String split;
|
||||
final String join;
|
||||
final List<String> members;
|
||||
final int maxX;
|
||||
Group(String split, String join, List<String> members, int maxX) {
|
||||
this.split = split;
|
||||
this.join = join;
|
||||
this.members = members;
|
||||
this.maxX = maxX;
|
||||
}
|
||||
}
|
||||
List<Group> groups = new ArrayList<>();
|
||||
for (FlowModels.NodeVo split : nodes) {
|
||||
if (!FlowDefModel.TYPE_PARALLEL_SPLIT.equals(split.getType()) || !box.containsKey(split.getId())) {
|
||||
continue;
|
||||
}
|
||||
String sid = split.getId();
|
||||
// BFS 序 + 各分支可达集,用于定位配对 JOIN
|
||||
Map<String, Integer> order = new HashMap<>();
|
||||
order.put(sid, 0);
|
||||
List<String> bfs = new ArrayList<>();
|
||||
bfs.add(sid);
|
||||
while (!bfs.isEmpty()) {
|
||||
String cur = bfs.remove(0);
|
||||
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
|
||||
if (!order.containsKey(to)) {
|
||||
order.put(to, order.size());
|
||||
bfs.add(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> heads = out.getOrDefault(sid, new ArrayList<>());
|
||||
List<java.util.Set<String>> reachSets = new ArrayList<>();
|
||||
for (String head : heads) {
|
||||
java.util.Set<String> set = new java.util.HashSet<>();
|
||||
List<String> q = new ArrayList<>();
|
||||
q.add(head);
|
||||
while (!q.isEmpty()) {
|
||||
String cur = q.remove(0);
|
||||
if (set.contains(cur)) {
|
||||
continue;
|
||||
}
|
||||
set.add(cur);
|
||||
q.addAll(out.getOrDefault(cur, new ArrayList<>()));
|
||||
}
|
||||
reachSets.add(set);
|
||||
}
|
||||
java.util.Set<String> common = reachSets.isEmpty() ? new java.util.HashSet<>() : reachSets.get(0);
|
||||
for (java.util.Set<String> set : reachSets) {
|
||||
common = new java.util.HashSet<>(common);
|
||||
common.retainAll(set);
|
||||
}
|
||||
String join = null;
|
||||
int joinOrder = Integer.MAX_VALUE;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.getType()) && box.containsKey(n.getId())
|
||||
&& common.contains(n.getId())) {
|
||||
int o = order.getOrDefault(n.getId(), Integer.MAX_VALUE);
|
||||
if (o < joinOrder) {
|
||||
joinOrder = o;
|
||||
join = n.getId();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (join == null) {
|
||||
continue;
|
||||
}
|
||||
// 组员:SPLIT 起步、不穿过 JOIN 的可达集(含嵌套 SPLIT/JOIN)
|
||||
List<String> members = new ArrayList<>();
|
||||
java.util.Set<String> seen = new java.util.HashSet<>();
|
||||
seen.add(join);
|
||||
List<String> q = new ArrayList<>();
|
||||
q.add(sid);
|
||||
while (!q.isEmpty()) {
|
||||
String cur = q.remove(0);
|
||||
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
|
||||
if (seen.contains(to) || !box.containsKey(to)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(to);
|
||||
members.add(to);
|
||||
q.add(to);
|
||||
}
|
||||
}
|
||||
int maxLevel = 0;
|
||||
for (String m : members) {
|
||||
maxLevel = Math.max(maxLevel, box.get(m)[0]);
|
||||
}
|
||||
groups.add(new Group(sid, join, members, maxLevel));
|
||||
}
|
||||
// maxX 降序 = 内层组先处理
|
||||
groups.sort((a, b) -> Integer.compare(b.maxX, a.maxX));
|
||||
for (Group gp : groups) {
|
||||
int[] s = box.get(gp.split);
|
||||
int axisY = s[1] + s[3] / 2;
|
||||
int top = Integer.MAX_VALUE;
|
||||
int bottom = Integer.MIN_VALUE;
|
||||
for (String m : gp.members) {
|
||||
int[] b = box.get(m);
|
||||
top = Math.min(top, b[1]);
|
||||
bottom = Math.max(bottom, b[1] + b[3]);
|
||||
}
|
||||
int delta = axisY - (top + bottom) / 2;
|
||||
for (String m : gp.members) {
|
||||
box.get(m)[1] += delta;
|
||||
}
|
||||
int[] j = box.get(gp.join);
|
||||
j[1] += axisY - (j[1] + j[3] / 2);
|
||||
}
|
||||
// 上移出界(y<0)的组把整图平移回 0(同 delta 平移不破坏各组居中)
|
||||
int minY = Integer.MAX_VALUE;
|
||||
for (int[] b : box.values()) {
|
||||
minY = Math.min(minY, b[1]);
|
||||
}
|
||||
if (minY < 0) {
|
||||
for (int[] b : box.values()) {
|
||||
b[1] -= minY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -59,6 +60,7 @@ public class FlowDiagramService {
|
||||
private final FlwRouteLogRepository routeLogRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
private final OpenApiUserClient userClient;
|
||||
private final DiagramLayoutService diagramLayoutService;
|
||||
|
||||
/** flowGuid 分支:最高已发布版(查询范式同 OapiFlowService.getFlow) */
|
||||
public FlowModels.DiagramVo byGuid(Long schId, String flowGuid, String mode) {
|
||||
@@ -140,20 +142,17 @@ public class FlowDiagramService {
|
||||
edges.add(ev);
|
||||
}
|
||||
vo.setEdges(edges);
|
||||
// 画布坐标透传(设计器保存;缺失时前端自行 Kahn 布局)
|
||||
if (model.layout != null && !model.layout.isEmpty()) {
|
||||
Map<String, Map<String, Integer>> layout = new HashMap<>();
|
||||
for (Map.Entry<String, FlowDefModel.Layout> en : model.layout.entrySet()) {
|
||||
if (en.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Integer> p = new HashMap<>();
|
||||
p.put("x", en.getValue().x);
|
||||
p.put("y", en.getValue().y);
|
||||
layout.put(en.getKey(), p);
|
||||
}
|
||||
vo.setLayout(layout);
|
||||
}
|
||||
// 布局单源(2026-10-03 三视图统一):与 PNG 同源,查看端以此为准
|
||||
Map<String, Map<String, Integer>> positions = new LinkedHashMap<>();
|
||||
diagramLayoutService.layout(nodes, edges).forEach((id, b) -> {
|
||||
Map<String, Integer> p = new LinkedHashMap<>();
|
||||
p.put("x", b[0]);
|
||||
p.put("y", b[1]);
|
||||
p.put("w", b[2]);
|
||||
p.put("h", b[3]);
|
||||
positions.put(id, p);
|
||||
});
|
||||
vo.setPositions(positions);
|
||||
// instance 口径:按任务轨迹给节点上运行时状态(前端高亮在办节点)
|
||||
if (instanceId != null) {
|
||||
Map<String, String> states = nodeStates(instanceId, model);
|
||||
|
||||
@@ -61,6 +61,14 @@ public class OapiFlowService {
|
||||
@Value("${flow-center.submitter-school-check.enabled:true}")
|
||||
private boolean submitterCheckEnabled;
|
||||
|
||||
/**
|
||||
* 办件进度嵌入页前端基址(flow-center 自身的对外前端地址,本方案新增的第一个前端地址配置:
|
||||
* 此前 UserMsg jumpPath 均为裸相对路径由接收方解析)。未配置即调签发 → fail-fast 业务异常,
|
||||
* 与 callback-url 同纪律——宁可不发也不发一个打不开的地址。
|
||||
*/
|
||||
@Value("${flow-center.embed.front-url:}")
|
||||
private String embedFrontUrl;
|
||||
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
@@ -324,6 +332,43 @@ public class OapiFlowService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件进度嵌入页换址(token 模式,与换图同构):同一 token 两处消费——嵌入页数据
|
||||
* /open/flow/embed/data 与流程图 /open/flow/diagram/img 均免签认领该 token。定位二选一:
|
||||
* instanceId(与办件直接复核)/ bizType+bizId(对账口径,办件不存在与 progress 同文案)。
|
||||
* 用户级可见性(本人/办理人/管理员)由子应用后端在调本端点前完成——oapi 层只认 schId。
|
||||
*/
|
||||
public OapiModels.EmbedUrlVo embedUrl(Long schId, Long instanceId, String bizType, String bizId, String mode) {
|
||||
// 配置缺失优先于办件定位:fail-fast 纪律要的是"无论如何都不发打不开的地址"
|
||||
if (embedFrontUrl == null || embedFrontUrl.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "嵌入页地址未配置:flow-center.embed.front-url");
|
||||
}
|
||||
Long resolvedInstanceId;
|
||||
if (instanceId != null) {
|
||||
resolvedInstanceId = requireInstance(schId, instanceId).getId();
|
||||
} else {
|
||||
if (bizType == null || bizId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId");
|
||||
}
|
||||
OapiModels.InstanceVo vo = instanceByBiz(schId, bizType, bizId);
|
||||
if (vo == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见");
|
||||
}
|
||||
resolvedInstanceId = vo.getInstanceId();
|
||||
}
|
||||
// mode 与 DiagramTokenService 内部缺省同值("auto"),URL 里显式带上仅为前端展示冗余
|
||||
String normMode = mode == null || mode.trim().isEmpty() ? "auto" : mode.trim();
|
||||
String token = tokenService.issue(schId, resolvedInstanceId, null, normMode);
|
||||
String base = embedFrontUrl.trim();
|
||||
if (!base.endsWith("/")) {
|
||||
base = base + "/";
|
||||
}
|
||||
OapiModels.EmbedUrlVo out = new OapiModels.EmbedUrlVo();
|
||||
out.setPageUrl(base + "#/embed/instance?token=" + token + "&mode=" + normMode);
|
||||
out.setExpiresIn(DiagramTokenService.TTL_MS / 1000);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 进度/换图共用的办件定位(schId 收敛在次) */
|
||||
private FlwInstance requireInstance(Long schId, Long instanceId) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId)
|
||||
|
||||
@@ -112,6 +112,14 @@ flow-center:
|
||||
# FLOWCENTER_USERMSG_ENABLED=true FLOWCENTER_USERMSG_BASEURL=http://localhost:8080 mvn -o spring-boot:run
|
||||
enabled: false
|
||||
# base-url: http://localhost:8080
|
||||
# 办件进度嵌入页(iframe 免签嵌入):flow-center 自身前端的对外基址,签发 /oapi/flow/embed/url
|
||||
# 时拼 pageUrl 用({front-url}#/embed/instance?token=…)。本应用此前从不需要自己的前端地址
|
||||
# (UserMsg jumpPath 是裸相对路径由接收方解析),此为本方案新增的第一个前端地址配置。
|
||||
# 本地 vite dev 实际端口 8088(见 local-debug/embed-c0-facts.md,卡册假设的 5173 不符);
|
||||
# 生产形如 https://<base域>/flow-center/front/,由 projman 平台设置登记注入。未配置时签发端点
|
||||
# fail-fast 业务异常(与 callback-url 同纪律),不发出打不开的地址。
|
||||
embed:
|
||||
front-url: http://localhost:8088
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
@@ -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 定位/快照/版本);查询不限办理人(办理才校验) */
|
||||
|
||||
@@ -18,7 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
*/
|
||||
class DiagramImageServiceTest {
|
||||
|
||||
private final DiagramImageService service = new DiagramImageService();
|
||||
private final DiagramImageService service = new DiagramImageService(new DiagramLayoutService());
|
||||
|
||||
private FlowModels.NodeVo node(String id, String type, String label, String sub, String state) {
|
||||
FlowModels.NodeVo n = new FlowModels.NodeVo();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 布局单源 golden 用例(2026-10-03 三视图统一):PNG 与 DiagramVo.positions 共同消费这份几何。
|
||||
* 数值断言 ±1 容差(int 除法);关系断言(列距/居中/归零)与常量解耦,防调参误报。
|
||||
*/
|
||||
class DiagramLayoutServiceTest {
|
||||
|
||||
private final DiagramLayoutService service = new DiagramLayoutService();
|
||||
|
||||
private FlowModels.NodeVo node(String id, String type) {
|
||||
FlowModels.NodeVo n = new FlowModels.NodeVo();
|
||||
n.setId(id);
|
||||
n.setType(type);
|
||||
n.setLabel(id);
|
||||
return n;
|
||||
}
|
||||
|
||||
private FlowModels.EdgeVo edge(String from, String to) {
|
||||
FlowModels.EdgeVo e = new FlowModels.EdgeVo();
|
||||
e.setFrom(from);
|
||||
e.setTo(to);
|
||||
return e;
|
||||
}
|
||||
|
||||
private Map<String, int[]> layout(List<FlowModels.NodeVo> nodes, FlowModels.EdgeVo... edges) {
|
||||
return service.layout(nodes, new ArrayList<>(Arrays.asList(edges)));
|
||||
}
|
||||
|
||||
private int cy(int[] b) {
|
||||
return b[1] + b[3] / 2;
|
||||
}
|
||||
|
||||
@Test
|
||||
void linear_xPitchByColumn_andZeroMin() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("a", FlowDefModel.TYPE_APPROVAL),
|
||||
node("b", FlowDefModel.TYPE_APPROVAL),
|
||||
node("end", FlowDefModel.TYPE_END)));
|
||||
Map<String, int[]> box = layout(nodes, edge("start", "a"), edge("a", "b"), edge("b", "end"));
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
assertNotNull(box.get(n.getId()), "缺坐标:" + n.getId());
|
||||
}
|
||||
// 同宽节点(审批→审批)x 差 = 列距 SLOT_W+GAP_X(±1 容差)
|
||||
assertEquals(DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X, box.get("b")[0] - box.get("a")[0], 1);
|
||||
// 归零:全部坐标非负且最小 x/y 为 0
|
||||
int minX = Integer.MAX_VALUE;
|
||||
int minY = Integer.MAX_VALUE;
|
||||
for (int[] b : box.values()) {
|
||||
assertTrue(b[0] >= 0 && b[1] >= 0);
|
||||
minX = Math.min(minX, b[0]);
|
||||
minY = Math.min(minY, b[1]);
|
||||
}
|
||||
assertEquals(0, minX);
|
||||
assertEquals(0, minY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void conditionBranches_sameColumnBlockVerticallyCentered() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("c", FlowDefModel.TYPE_CONDITION),
|
||||
node("a", FlowDefModel.TYPE_APPROVAL),
|
||||
node("b", FlowDefModel.TYPE_APPROVAL),
|
||||
node("end", FlowDefModel.TYPE_END)));
|
||||
Map<String, int[]> box = layout(nodes,
|
||||
edge("start", "c"), edge("c", "a"), edge("c", "b"), edge("a", "end"), edge("b", "end"));
|
||||
// 两分支同列不同行,列块垂直居中:两盒中心均值 ≈ 内容区中线(单行列的中心同值)
|
||||
assertEquals(box.get("a")[0], box.get("b")[0]);
|
||||
assertTrue(box.get("a")[1] != box.get("b")[1]);
|
||||
assertEquals(cy(box.get("c")), (cy(box.get("a")) + cy(box.get("b"))) / 2, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelGroup_splitAndJoinShareCenterline() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("s", FlowDefModel.TYPE_PARALLEL_SPLIT),
|
||||
node("a", FlowDefModel.TYPE_APPROVAL),
|
||||
node("b", FlowDefModel.TYPE_APPROVAL),
|
||||
node("j", FlowDefModel.TYPE_PARALLEL_JOIN),
|
||||
node("end", FlowDefModel.TYPE_END)));
|
||||
Map<String, int[]> box = layout(nodes,
|
||||
edge("start", "s"), edge("s", "a"), edge("s", "b"),
|
||||
edge("a", "j"), edge("b", "j"), edge("j", "end"));
|
||||
// split 与 join 同一水平中线,且即两分支的对称轴(centerParallelGroups 生效)
|
||||
assertEquals(cy(box.get("s")), cy(box.get("j")), 1);
|
||||
assertEquals(cy(box.get("s")), (cy(box.get("a")) + cy(box.get("b"))) / 2, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedParallel_groupsCentered_noNegativeY() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("os", FlowDefModel.TYPE_PARALLEL_SPLIT),
|
||||
node("is", FlowDefModel.TYPE_PARALLEL_SPLIT),
|
||||
node("c", FlowDefModel.TYPE_APPROVAL),
|
||||
node("p", FlowDefModel.TYPE_APPROVAL),
|
||||
node("q", FlowDefModel.TYPE_APPROVAL),
|
||||
node("ij", FlowDefModel.TYPE_PARALLEL_JOIN),
|
||||
node("oj", FlowDefModel.TYPE_PARALLEL_JOIN),
|
||||
node("end", FlowDefModel.TYPE_END)));
|
||||
Map<String, int[]> box = layout(nodes,
|
||||
edge("start", "os"),
|
||||
edge("os", "is"), edge("os", "c"),
|
||||
edge("is", "p"), edge("is", "q"),
|
||||
edge("p", "ij"), edge("q", "ij"),
|
||||
edge("ij", "oj"), edge("c", "oj"),
|
||||
edge("oj", "end"));
|
||||
// 内层组:is 与 {p,q} 居中;外层组:os/oj 同中线即分支对称轴
|
||||
assertEquals(cy(box.get("is")), (cy(box.get("p")) + cy(box.get("q"))) / 2, 1);
|
||||
assertEquals(cy(box.get("os")), cy(box.get("oj")), 1);
|
||||
// 全图无 y<0(centerParallelGroups 末段抬回)
|
||||
for (int[] b : box.values()) {
|
||||
assertTrue(b[1] >= 0, "出现负 y:" + b[1]);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cycle_defensive_fallbackLevelStillPositionsAllNodes() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("a", FlowDefModel.TYPE_APPROVAL),
|
||||
node("b", FlowDefModel.TYPE_APPROVAL)));
|
||||
Map<String, int[]> box = assertDoesNotThrow(() -> layout(nodes,
|
||||
edge("start", "a"), edge("a", "b"), edge("b", "a")));
|
||||
// 环兜底:不抛异常,所有节点都有坐标
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
assertNotNull(box.get(n.getId()), "缺坐标:" + n.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class FlowDiagramServiceTest {
|
||||
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
|
||||
taskRepository, ccRepository,
|
||||
mock(com.yunhai.flowcenter.repository.FlwRouteLogRepository.class),
|
||||
engine, userClient);
|
||||
engine, userClient, new DiagramLayoutService());
|
||||
}
|
||||
|
||||
private FlwDef def(String json) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.yunhai.flowcenter.stub;
|
||||
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.openapi.base.model.ChannelBindByPhoneRequest;
|
||||
import com.yunhai.platform.openapi.base.model.ChannelBindRequest;
|
||||
import com.yunhai.platform.openapi.base.model.SysUserGroupDeleteItem;
|
||||
import com.yunhai.platform.openapi.base.model.SysUserGroupStateRequest;
|
||||
import com.yunhai.platform.openapi.base.model.SysUserMoveRequest;
|
||||
@@ -137,4 +139,24 @@ public class StubUserClient implements OpenApiUserClient {
|
||||
public void batchChangeUserStatus(List<Integer> userIds, boolean disabled) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getChannelBoundUser(String channel, String openId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser bindChannel(ChannelBindRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser bindChannelByPhone(ChannelBindByPhoneRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unbindChannel(String channel, String openId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user