This commit is contained in:
@@ -13,6 +13,8 @@ import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BasePageResult;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -37,6 +39,7 @@ public class FlowUserController {
|
||||
private final FlowInstanceService instanceService;
|
||||
private final FlowAccessGuard accessGuard;
|
||||
private final FlowDiagramService diagramService;
|
||||
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
|
||||
|
||||
/** 我提交的办件 */
|
||||
@GetMapping("/my")
|
||||
@@ -155,6 +158,23 @@ public class FlowUserController {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:flowGuid 或 instanceId");
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图图片流(CAS 会话版"CAS 验证码式嵌入",2026-09-16):流程中心自有页面 <img src>
|
||||
* 直嵌(办件详情页内嵌运行时状态图),免跳 /diagram 全屏页。可见性与 /diagram 同口径。
|
||||
* 外部系统(img 带不了会话)走 /oapi/flow/diagram/url 换 token + /open/flow/diagram/img。
|
||||
*/
|
||||
@GetMapping("/diagram.png")
|
||||
public ResponseEntity<byte[]> diagramPng(BoaUser user,
|
||||
@RequestParam(value = "flowGuid", required = false) String flowGuid,
|
||||
@RequestParam(value = "instanceId", required = false) Long instanceId,
|
||||
@RequestParam(value = "mode", required = false) String mode) {
|
||||
FlowModels.DiagramVo vo = diagram(user, flowGuid, instanceId, mode);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.IMAGE_PNG)
|
||||
.cacheControl(org.springframework.http.CacheControl.noStore())
|
||||
.body(imageService.renderPng(vo));
|
||||
}
|
||||
|
||||
private Long requireSchId(BoaUser user) {
|
||||
if (user == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
|
||||
|
||||
@@ -21,7 +21,12 @@ import java.util.List;
|
||||
* - GET /oapi/flow/{flowGuid}?schId= → data={flowGuid,name,version,nodeSummary}
|
||||
* - POST /oapi/flow/apply → data={instanceId,duplicated}
|
||||
* - GET /oapi/flow/instance?bizType=&bizId=&schId= → data={status,version,instanceId,nodeSummary,traceJson,…}
|
||||
* - 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}
|
||||
* - POST /oapi/flow/callback/reg?schId= → data={secret}
|
||||
* 刻意不提供审批动作(同意/拒绝/退回/移交/催办):那是"人"的决策,必须带登录身份走 CAS 用户面,
|
||||
* 应用间签名不得代签——谁办理谁负责,审计链不能断在应用上。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/oapi/flow")
|
||||
@@ -54,6 +59,42 @@ public class OapiFlowController {
|
||||
return flowService.instanceByBiz(schId, bizType, bizId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件进度(结构化轨迹,SDK FlowProgress 契约):instanceId 或 bizType/bizId 定位。
|
||||
* 消费方渲染时间线(发起申请→各节点办理人行),SDK com.yunhai.platform.openapi.flow.model 同构。
|
||||
*/
|
||||
@GetMapping("/progress")
|
||||
public OapiModels.ProgressVo progress(@RequestParam(value = "instanceId", required = false) Long instanceId,
|
||||
@RequestParam(value = "bizType", required = false) String bizType,
|
||||
@RequestParam(value = "bizId", required = false) String bizId,
|
||||
@RequestParam("schId") Long schId) {
|
||||
return flowService.progress(schId, instanceId, bizType, bizId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图换图(token 模式,"CAS 验证码式嵌入"):img 标签带不了签名,本端点在 app-sign 保护下
|
||||
* 签发短时 token,返回可直接放 {@code <img src>} 的免签路径 {@code /open/flow/diagram/img?token=}。
|
||||
* 口径:instanceId=办件运行时状态图;flowGuid=最高已发布版定义图。
|
||||
*/
|
||||
@GetMapping("/diagram/url")
|
||||
public OapiModels.DiagramUrlVo diagramUrl(@RequestParam(value = "instanceId", required = false) Long instanceId,
|
||||
@RequestParam(value = "flowGuid", required = false) String flowGuid,
|
||||
@RequestParam(value = "mode", required = false) String mode,
|
||||
@RequestParam("schId") Long schId) {
|
||||
return flowService.diagramUrl(schId, instanceId, flowGuid, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回(提交人本人;仅尚无任务被办理时)。submitterId 由业务应用按其办件归属传入,
|
||||
* 本端与办件提交人复核——应用签名不替代"谁在撤"的身份证明。
|
||||
*/
|
||||
@PostMapping("/instance/{instanceId}/withdraw")
|
||||
public void withdraw(@RequestParam("schId") Long schId,
|
||||
@RequestParam("submitterId") Integer submitterId,
|
||||
@PathVariable("instanceId") Long instanceId) {
|
||||
flowService.withdraw(schId, instanceId, submitterId);
|
||||
}
|
||||
|
||||
@PostMapping("/callback/reg")
|
||||
public OapiModels.CallbackRegResultVo registerCallback(
|
||||
@RequestParam("schId") Long schId,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.yunhai.flowcenter.controller.open;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.service.DiagramImageService;
|
||||
import com.yunhai.flowcenter.service.DiagramTokenService;
|
||||
import com.yunhai.flowcenter.service.FlowDiagramService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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/**,用户拍板 2026-09-16,"CAS 验证码式"嵌入)。
|
||||
* <p>
|
||||
* 故意不落 /api/**(CAS 会话)也不落 /oapi/**(app-sign):<img src> 两者都带不了鉴权头;
|
||||
* 免签资格由 10 分钟短时 token 承担(DiagramTokenService,token 由 app-sign 的
|
||||
* GET /oapi/flow/diagram/url 签发)。token 分发点之外,本端点不接受任何业务参数直接定位——
|
||||
* 免签面收窄到"只认 token",定位与可见性全在签发侧(OapiFlowService.diagramUrl)完成。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/open/flow")
|
||||
@RequiredArgsConstructor
|
||||
public class OpenDiagramImageController {
|
||||
|
||||
private final DiagramTokenService tokenService;
|
||||
private final DiagramImageService imageService;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlowDiagramService diagramService;
|
||||
|
||||
@GetMapping("/diagram/img")
|
||||
public ResponseEntity<byte[]> diagramImg(@RequestParam("token") String token) {
|
||||
DiagramTokenService.TokenEntry entry = tokenService.resolve(token);
|
||||
if (entry == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.body("流程图已过期,请刷新页面重新获取".getBytes());
|
||||
}
|
||||
// 办件口径:节点带运行时状态(token 已收 schId,出图前再二次比对防漂移)
|
||||
if (entry.getInstanceId() != null) {
|
||||
FlwInstance instance = instanceRepository.findById(entry.getInstanceId()).orElse(null);
|
||||
if (instance == null || !entry.getSchId().equals(instance.getSchId())) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.body("办件不存在".getBytes());
|
||||
}
|
||||
return png(diagramService.byInstance(instance, entry.getMode()));
|
||||
}
|
||||
// 定义口径(flowGuid 预览)
|
||||
return defRepository.findById(entry.getDefId())
|
||||
.map(def -> entry.getSchId().equals(def.getSchId())
|
||||
? png(diagramService.build(def, null, entry.getMode()))
|
||||
: notFound("流程不存在"))
|
||||
.orElseGet(() -> notFound("流程不存在"));
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> png(FlowModels.DiagramVo vo) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.IMAGE_PNG)
|
||||
.cacheControl(org.springframework.http.CacheControl.noStore())
|
||||
.body(imageService.renderPng(vo));
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> notFound(String msg) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
.body(msg.getBytes());
|
||||
}
|
||||
}
|
||||
@@ -87,4 +87,62 @@ public final class OapiModels {
|
||||
public static class CallbackRegResultVo {
|
||||
private String secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件进度(结构化轨迹,2026-09-16 用户拍板:SDK 对外提供图3/图4时间线数据源)。
|
||||
* steps 按 nodeId 任务到达顺序聚合(同人多任务行保留原始顺序),发起申请固定为第 0 步。
|
||||
* SDK DTO com.yunhai.platform.openapi.flow.model.FlowProgress 与本契约逐字对齐。
|
||||
*/
|
||||
@Data
|
||||
public static class ProgressVo {
|
||||
private Long instanceId;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
/** 办件状态:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 */
|
||||
private Integer status;
|
||||
private String currentNodes;
|
||||
private Integer submitterId;
|
||||
private String submitterName;
|
||||
private String submitTime;
|
||||
private String finishTime;
|
||||
private List<ProgressStepVo> steps;
|
||||
}
|
||||
|
||||
/** 进度步骤(同一节点全部任务行聚合为一步,b 会签时 assignees 多行) */
|
||||
@Data
|
||||
public static class ProgressStepVo {
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
/** 步骤标签:发起申请/待审核/已同意/已拒绝/已退回/已移交/失效(任务状态汇总口径见 FlowProgress.stage) */
|
||||
private String label;
|
||||
/** 节点当前是否在办 */
|
||||
private boolean active;
|
||||
private List<ProgressTaskVo> assignees;
|
||||
}
|
||||
|
||||
/** 进度步骤内的办理人行(多人步骤每人一条;状态取自该行任务真实状态) */
|
||||
@Data
|
||||
public static class ProgressTaskVo {
|
||||
private Long taskId;
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
/** 任务状态:0 待办 / 1 同意 / 2 拒绝 / 3 失效 / 4 已退回 / 5 已移交 */
|
||||
private Integer status;
|
||||
private String statusLabel;
|
||||
private String opinion;
|
||||
private String arriveTime;
|
||||
private String handleTime;
|
||||
/** 多出口节点办理时所选出口名 */
|
||||
private String edgeName;
|
||||
}
|
||||
|
||||
/** 流程图图片流换图响应(token 换图模式:imgUrl 可直接放 <img src>,免 app-sign) */
|
||||
@Data
|
||||
public static class DiagramUrlVo {
|
||||
/** 免签图片端点相对路径(?token=xxx),消费方可拼到本服务的对外网关地址后 */
|
||||
private String imgUrl;
|
||||
/** 相对 token 有效期(秒) */
|
||||
private long expiresIn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.Path2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 流程示意图服务端出图(Java2D,无外部渲染依赖):DiagramVo → PNG 字节流。
|
||||
* <p>
|
||||
* 背景(用户拍板 2026-09-16):外部系统要像嵌 CAS 验证码一样用 {@code <img src>} 直接嵌流程图,
|
||||
* 而 img 标签带不了 app-sign 请求头,因此《流程中心流程图查看方案.md》§二"不做服务端出图"的
|
||||
* 边界对外场景作废——本类在服务端把布局 JSON 画成 PNG;前端手绘 SVG 方案仅流程中心自有页面继续用。
|
||||
* <p>
|
||||
* 布局:自算拓扑最长路径分层(自上而下),忽略设计器画布坐标(那是全屏编辑器坐标系,直接缩放会失真);
|
||||
* 节点颜色按运行时状态 done/active/rejected/pending 上色,图例仅 instance 口径绘制。
|
||||
* 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。
|
||||
*/
|
||||
@Service
|
||||
public class DiagramImageService {
|
||||
|
||||
/** 逻辑坐标常量(输出按 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 NODE_W = 210;
|
||||
private static final int NODE_H = 60;
|
||||
private static final int LEVEL_GAP_Y = 92;
|
||||
private static final int GAP_X = 52;
|
||||
private static final int LEGEND_H = 48;
|
||||
private static final int ARROW = 8;
|
||||
|
||||
private static final Color C_TITLE = new Color(0x33, 0x33, 0x33);
|
||||
private static final Color C_LINE = new Color(0xA6, 0xB1, 0xD4);
|
||||
private static final Color C_EDGE_LABEL = new Color(0x8A, 0x93, 0xB0);
|
||||
private static final Color C_BORDER_DEFAULT = new Color(0xC9, 0xD2, 0xEF);
|
||||
private static final Color C_DONE_BORDER = new Color(0x59, 0x79, 0xEA);
|
||||
private static final Color C_ACTIVE_FILL = new Color(0xFF, 0xF7, 0xE6);
|
||||
private static final Color C_ACTIVE_BORDER = new Color(0xFF, 0x99, 0x00);
|
||||
private static final Color C_REJECT_FILL = new Color(0xFD, 0xEE, 0xEE);
|
||||
private static final Color C_REJECT_BORDER = new Color(0xED, 0x40, 0x14);
|
||||
private static final Color C_PARALLEL_FILL = new Color(0xF8, 0xF8, 0xFF);
|
||||
private static final Color C_PARALLEL_BORDER = new Color(0xA0, 0xA8, 0xC8);
|
||||
private static final Color C_TEXT = new Color(0x33, 0x33, 0x33);
|
||||
private static final Color C_SUB = new Color(0x8A, 0x93, 0xB0);
|
||||
|
||||
/** 出图主入口;instance 口径节点带 state 时自动着色并画图例 */
|
||||
public byte[] renderPng(FlowModels.DiagramVo vo) {
|
||||
BufferedImage img = draw(vo);
|
||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(img, "png", out);
|
||||
return out.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("流程图渲染失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 每层节点居中排布;画布尺寸由层高/最宽层决定
|
||||
Map<Integer, List<FlowModels.NodeVo>> layers = new LinkedHashMap<>();
|
||||
int maxLevel = 0;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
int l = level.get(n.getId());
|
||||
maxLevel = Math.max(maxLevel, l);
|
||||
layers.computeIfAbsent(l, k -> new ArrayList<>()).add(n);
|
||||
}
|
||||
int contentW = maxLevel * (NODE_W + GAP_X) - GAP_X;
|
||||
for (List<FlowModels.NodeVo> layer : layers.values()) {
|
||||
contentW = Math.max(contentW, layer.size() * (NODE_W + GAP_X) - GAP_X);
|
||||
}
|
||||
contentW = Math.max(contentW, NODE_W);
|
||||
boolean hasState = false;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
if (n.getState() != null) {
|
||||
hasState = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
int imgW = contentW + MARGIN * 2;
|
||||
int imgH = MARGIN + TITLE_H + (maxLevel + 1) * NODE_H + maxLevel * LEVEL_GAP_Y
|
||||
+ MARGIN + (hasState ? LEGEND_H : 0);
|
||||
|
||||
BufferedImage canvas = new BufferedImage(imgW * SCALE, imgH * SCALE, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = canvas.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g.scale(SCALE, SCALE);
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(0, 0, imgW, imgH);
|
||||
|
||||
Font labelFont = cjkFont(Font.BOLD, 14);
|
||||
Font subFont = cjkFont(Font.PLAIN, 11);
|
||||
Font titleFont = cjkFont(Font.BOLD, 16);
|
||||
Font smallFont = cjkFont(Font.PLAIN, 12);
|
||||
|
||||
g.setFont(titleFont);
|
||||
g.setColor(C_TITLE);
|
||||
String title = vo.getName() == null || vo.getName().isEmpty() ? "流程示意图" : vo.getName();
|
||||
FontMetrics titleFm = g.getFontMetrics();
|
||||
g.drawString(title, (imgW - titleFm.stringWidth(title)) / 2f, MARGIN + titleFont.getSize() + 6);
|
||||
|
||||
// 节点坐标
|
||||
Map<String, int[]> tl = new HashMap<>();
|
||||
for (Map.Entry<Integer, List<FlowModels.NodeVo>> en : layers.entrySet()) {
|
||||
List<FlowModels.NodeVo> layer = en.getValue();
|
||||
int rowW = layer.size() * (NODE_W + GAP_X) - GAP_X;
|
||||
int y = MARGIN + TITLE_H + en.getKey() * (NODE_H + LEVEL_GAP_Y);
|
||||
for (int i = 0; i < layer.size(); i++) {
|
||||
tl.put(layer.get(i).getId(), new int[]{(imgW - rowW) / 2 + i * (NODE_W + GAP_X), y});
|
||||
}
|
||||
}
|
||||
|
||||
// 连线(先画线后画节点,节点底色盖住线头)
|
||||
Map<String, Map<String, String>> edgeNames = new HashMap<>();
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
edgeNames.computeIfAbsent(e.getFrom(), k -> new HashMap<>()).put(e.getTo(), e.getName());
|
||||
}
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
if (!tl.containsKey(e.getFrom()) || !tl.containsKey(e.getTo())) {
|
||||
continue;
|
||||
}
|
||||
int[] from = tl.get(e.getFrom());
|
||||
int[] to = tl.get(e.getTo());
|
||||
int x1 = from[0] + NODE_W / 2, y1 = from[1] + NODE_H;
|
||||
int x2 = to[0] + NODE_W / 2, y2 = to[1] - ARROW - 1;
|
||||
g.setColor(C_LINE);
|
||||
g.drawLine(x1, y1, x2, y2);
|
||||
drawArrowHead(g, x2, y2);
|
||||
// 多出口分支文案:中点白底小字(单出口连线不标,避免噪音)
|
||||
if (edgeNames.get(e.getFrom()).size() > 1 && e.getName() != null && !e.getName().isEmpty()) {
|
||||
drawMidLabel(g, x1, y1, x2, y2, e.getName(), smallFont);
|
||||
}
|
||||
}
|
||||
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
drawNode(g, n, tl.get(n.getId()), labelFont, subFont);
|
||||
}
|
||||
|
||||
// 图例(仅 instance 口径)
|
||||
if (hasState) {
|
||||
g.setFont(smallFont);
|
||||
FontMetrics sFm = g.getFontMetrics();
|
||||
int ly = imgH - LEGEND_H + 18;
|
||||
int x = MARGIN;
|
||||
String[][] legend = {{"active", "在办"}, {"done", "已办"}, {"pending", "未到"}, {"rejected", "被拒绝"}};
|
||||
for (String[] it : legend) {
|
||||
g.setColor(fillOf(it[0]));
|
||||
g.fillRoundRect(x, ly - 10, 18, 12, 6, 6);
|
||||
g.setColor(borderOf(it[0]));
|
||||
g.setStroke(new BasicStroke(1.2f));
|
||||
g.drawRoundRect(x, ly - 10, 18, 12, 6, 6);
|
||||
g.setColor(C_TEXT);
|
||||
g.drawString(it[1], x + 24, ly + 2);
|
||||
x += 24 + sFm.stringWidth(it[1]) + 24;
|
||||
}
|
||||
}
|
||||
g.dispose();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
private void drawNode(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF) {
|
||||
String type = n.getType() == null ? "" : n.getType();
|
||||
Color fill = fillOf(n);
|
||||
Color border = borderOf(n);
|
||||
boolean pill = type.equals(FlowDefModel.TYPE_START) || type.equals(FlowDefModel.TYPE_END);
|
||||
if (pill) { // 开始/结束:胶囊
|
||||
g.setColor(fill);
|
||||
g.fillRoundRect(p[0], p[1], NODE_W, NODE_H, NODE_H, NODE_H);
|
||||
g.setColor(border);
|
||||
g.setStroke(new BasicStroke(1.5f));
|
||||
g.drawRoundRect(p[0], p[1], NODE_W, NODE_H, NODE_H, NODE_H);
|
||||
g.setFont(labelF);
|
||||
g.setColor(C_TEXT);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
String label = truncate(g, n.getLabel(), NODE_W - 24);
|
||||
g.drawString(label, p[0] + (NODE_W - fm.stringWidth(label)) / 2f,
|
||||
p[1] + (NODE_H - fm.getHeight()) / 2 + fm.getAscent());
|
||||
return;
|
||||
}
|
||||
g.setColor(fill);
|
||||
g.fillRoundRect(p[0], p[1], NODE_W, NODE_H, 12, 12);
|
||||
g.setColor(border);
|
||||
g.setStroke(new BasicStroke("active".equals(n.getState()) ? 2f : 1.4f));
|
||||
g.drawRoundRect(p[0], p[1], NODE_W, NODE_H, 12, 12);
|
||||
// 文本:label 一行 + subLabel 最多两行(截断带 …)
|
||||
FontMetrics lf = g.getFontMetrics(labelF);
|
||||
g.setFont(labelF);
|
||||
g.setColor(C_TEXT);
|
||||
g.drawString(truncate(g, n.getLabel(), NODE_W - 20), p[0] + 10, p[1] + 12 + lf.getAscent());
|
||||
if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) {
|
||||
g.setFont(subF);
|
||||
g.setColor(C_SUB);
|
||||
FontMetrics sf = g.getFontMetrics(subF);
|
||||
List<String> lines = wrap(g, n.getSubLabel(), NODE_W - 20, 2);
|
||||
int firstLineY = p[1] + 12 + lf.getHeight();
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
g.drawString(lines.get(i), p[0] + 10, firstLineY + i * sf.getHeight());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawMidLabel(Graphics2D g, int x1, int y1, int x2, int y2, String s, Font font) {
|
||||
g.setFont(font);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
int sw = fm.stringWidth(s);
|
||||
int midX = (x1 + x2) / 2, midY = (y1 + y2) / 2;
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRect(midX - sw / 2 - 4, midY - fm.getAscent(), sw + 8, fm.getHeight());
|
||||
g.setColor(C_EDGE_LABEL);
|
||||
g.drawString(s, midX - sw / 2f, midY);
|
||||
}
|
||||
|
||||
private void drawArrowHead(Graphics2D g, int x, int y) {
|
||||
Path2D p = new Path2D.Float();
|
||||
p.moveTo(x, y + ARROW);
|
||||
p.lineTo(x - ARROW / 2.0, y);
|
||||
p.lineTo(x + ARROW / 2.0, y);
|
||||
p.closePath();
|
||||
g.setColor(C_LINE);
|
||||
g.fill(p);
|
||||
}
|
||||
|
||||
private Color fillOf(FlowModels.NodeVo n) {
|
||||
String s = n.getState();
|
||||
if ("active".equals(s)) {
|
||||
return C_ACTIVE_FILL;
|
||||
}
|
||||
if ("rejected".equals(s)) {
|
||||
return C_REJECT_FILL;
|
||||
}
|
||||
String t = n.getType() == null ? "" : n.getType();
|
||||
if (t.equals(FlowDefModel.TYPE_PARALLEL_SPLIT) || t.equals(FlowDefModel.TYPE_PARALLEL_JOIN)) {
|
||||
return C_PARALLEL_FILL;
|
||||
}
|
||||
if ("done".equals(s)) {
|
||||
return new Color(0xEE, 0xF3, 0xFF);
|
||||
}
|
||||
return Color.WHITE;
|
||||
}
|
||||
|
||||
private Color borderOf(FlowModels.NodeVo n) {
|
||||
String s = n.getState();
|
||||
if ("active".equals(s)) {
|
||||
return C_ACTIVE_BORDER;
|
||||
}
|
||||
if ("done".equals(s)) {
|
||||
return C_DONE_BORDER;
|
||||
}
|
||||
if ("rejected".equals(s)) {
|
||||
return C_REJECT_BORDER;
|
||||
}
|
||||
String t = n.getType() == null ? "" : n.getType();
|
||||
if (t.equals(FlowDefModel.TYPE_PARALLEL_SPLIT) || t.equals(FlowDefModel.TYPE_PARALLEL_JOIN)) {
|
||||
return C_PARALLEL_BORDER;
|
||||
}
|
||||
return C_BORDER_DEFAULT;
|
||||
}
|
||||
|
||||
private Color fillOf(String state) {
|
||||
FlowModels.NodeVo n = new FlowModels.NodeVo();
|
||||
n.setState(state);
|
||||
return fillOf(n);
|
||||
}
|
||||
|
||||
private Color borderOf(String state) {
|
||||
switch (state) {
|
||||
case "active": return C_ACTIVE_BORDER;
|
||||
case "done": return C_DONE_BORDER;
|
||||
case "rejected": return C_REJECT_BORDER;
|
||||
default: return C_BORDER_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static Font cjkFont(int style, int size) {
|
||||
return new Font("Microsoft YaHei", style, size);
|
||||
}
|
||||
|
||||
/** 贪心按像素宽度折行,最多 maxLines 行;溢出行以 … 结尾 */
|
||||
private static List<String> wrap(Graphics2D g, String text, int width, int maxLines) {
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
List<String> lines = new ArrayList<>();
|
||||
int i = 0;
|
||||
while (i < text.length() && lines.size() < maxLines) {
|
||||
StringBuilder line = new StringBuilder();
|
||||
while (i < text.length()
|
||||
&& fm.stringWidth(line + "" + text.charAt(i)) <= width) {
|
||||
line.append(text.charAt(i));
|
||||
i++;
|
||||
}
|
||||
if (line.length() == 0) { // 单字超宽防死循环
|
||||
line.append(text.charAt(i));
|
||||
i++;
|
||||
}
|
||||
lines.add(line.toString());
|
||||
if (i < text.length() && lines.size() == maxLines) {
|
||||
// 溢出截断
|
||||
String last = lines.get(maxLines - 1);
|
||||
while (last.length() > 1 && fm.stringWidth(last + "…") > width) {
|
||||
last = last.substring(0, last.length() - 1);
|
||||
}
|
||||
lines.set(maxLines - 1, last + "…");
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static String truncate(Graphics2D g, String s, int width) {
|
||||
if (s == null || s.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
if (fm.stringWidth(s) <= width) {
|
||||
return s;
|
||||
}
|
||||
while (s.length() > 1 && fm.stringWidth(s + "…") > width) {
|
||||
s = s.substring(0, s.length() - 1);
|
||||
}
|
||||
return s + "…";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 流程图图片流令牌(用户拍板 2026-09-16,"CAS 验证码式嵌入"):
|
||||
* <p>
|
||||
* {@code <img src>} 发不了 app-sign 三件套,外部系统不能直接拉 /oapi/**。约定:调用方先走
|
||||
* app-sign 的 {@code GET /oapi/flow/diagram/url} 换取短时 token(服务端在此登记 instance/flow
|
||||
* 与过口),再由浏览器持 token 拉免签图片端点 {@code GET /open/flow/diagram/img?token=}。
|
||||
* token 有效期 10 分钟、可重复使用(img 加载/刷新/多端嵌入),到期由惰性清扫回收。
|
||||
* <p>
|
||||
* 单机内存态即可(图片是无状态渲染,token 只做"出图资格"传递,不承载业务数据);
|
||||
* 多实例部署时需改集中存储(当前 flow-center 部署单实例,不引入)。
|
||||
*/
|
||||
@Service
|
||||
public class DiagramTokenService {
|
||||
|
||||
/** 有效期(毫秒):10 分钟,覆盖外部页面打开→渲染→手动刷新的窗口 */
|
||||
static final long TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
@Data
|
||||
public static class TokenEntry {
|
||||
private Long schId;
|
||||
/** 二选一:instanceId(办件口径,带运行时状态)/ defId(定义口径,图示预览) */
|
||||
private Long instanceId;
|
||||
private Long defId;
|
||||
private String mode;
|
||||
private long expireAt;
|
||||
}
|
||||
|
||||
private final Map<String, TokenEntry> tokens = new ConcurrentHashMap<>();
|
||||
|
||||
/** 签发 token(caller 负责 schId/instanceId/defId 的可见性校验,此处只登记) */
|
||||
public String issue(Long schId, Long instanceId, Long defId, String mode) {
|
||||
sweep();
|
||||
String token = UUID.randomUUID().toString().replace("-", "");
|
||||
TokenEntry entry = new TokenEntry();
|
||||
entry.setSchId(schId);
|
||||
entry.setInstanceId(instanceId);
|
||||
entry.setDefId(defId);
|
||||
entry.setMode(mode == null || mode.isEmpty() ? "auto" : mode);
|
||||
entry.setExpireAt(System.currentTimeMillis() + TTL_MS);
|
||||
tokens.put(token, entry);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 校验并取条目(过期即 null,出图端点回 404) */
|
||||
public TokenEntry resolve(String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
TokenEntry entry = tokens.get(token);
|
||||
if (entry == null || entry.getExpireAt() < System.currentTimeMillis()) {
|
||||
tokens.remove(token);
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** 惰性清扫(签发时触发,量小无需定时器) */
|
||||
private void sweep() {
|
||||
long now = System.currentTimeMillis();
|
||||
for (Iterator<Map.Entry<String, TokenEntry>> it = tokens.entrySet().iterator(); it.hasNext(); ) {
|
||||
if (it.next().getValue().getExpireAt() < now) {
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.callback.CallbackAppSigner;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
@@ -29,6 +30,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -62,6 +64,7 @@ public class OapiFlowService {
|
||||
private final FlowEngine flowEngine;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final OpenApiUserClient userClient;
|
||||
private final DiagramTokenService tokenService;
|
||||
|
||||
/** 本应用身份(回调探活补应用签名头;未装配时裸发,接收方 /oapi/* 会 401) */
|
||||
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
|
||||
@@ -116,6 +119,193 @@ public class OapiFlowService {
|
||||
return instance == null ? null : toInstanceVo(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回(托管接入件,2026-09-16 补:原先只有 CAS 用户面 {@code /api/flow/instance/{id}/withdraw},
|
||||
* 业务应用只能让浏览器直调,票据与 CORS 一并外溢)。治理口径:
|
||||
* <ul>
|
||||
* <li>身份:{@code submitterId} 必传——应用间签名只证明"哪个应用在调",不证明"谁在撤",
|
||||
* 对端按 submitterId 与办件提交人**逐字复核**,杜绝业务应用越权代撤他人办件;</li>
|
||||
* <li>租户:schId 收敛 + requireInstance 校验办件归属;</li>
|
||||
* <li>状态:仅审批中(10) 且尚无任务被办理(引擎既有纪律,见 FlowInstanceService.withdraw);</li>
|
||||
* <li>不校验 submitterId 的本校成员资格——提交人身份在发起时已按 §5.5 校验过,撤回不改变归属。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Transactional
|
||||
public void withdraw(Long schId, Long instanceId, Integer submitterId) {
|
||||
if (schId == null || instanceId == null || submitterId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/instanceId/submitterId");
|
||||
}
|
||||
FlwInstance instance = instanceService.requireInstance(schId, instanceId);
|
||||
if (!submitterId.equals(instance.getSubmitterId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅提交人本人可撤回");
|
||||
}
|
||||
instanceService.withdraw(schId, instanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件进度(结构化轨迹):steps=发起申请 + 节点步骤(按任务 id 序;同节点同人多任务行合并为多人步骤)。
|
||||
* 状态标签口径与前端流程轨迹一致(0 待审核 / 1 已同意 / 2 已拒绝 / 3 失效 / 4 已退回 / 5 已移交);
|
||||
* 失效行保留(消费方自行决定是否隐藏——重启重提后的旧节点轨迹即 3/失效)。
|
||||
*/
|
||||
public OapiModels.ProgressVo progress(Long schId, Long instanceId, String bizType, String bizId) {
|
||||
FlwInstance instance;
|
||||
if (instanceId != null) {
|
||||
instance = requireInstance(schId, instanceId);
|
||||
} else {
|
||||
if (bizType == null || bizId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId");
|
||||
}
|
||||
instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见"));
|
||||
}
|
||||
return toProgressVo(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图换图(token 模式):app-sign 校验在本次请求完成(/oapi 强制验签),token 只向浏览器
|
||||
* 传递"出图资格"。二选一:instanceId(办件口径,带运行时状态+可见性收紧)/ flowGuid(定义
|
||||
* 口径,最高已发布版)。userId 无需传——appKey 调用方已视为可信(两级模型,可见性由发起方
|
||||
* 业务边界保证),浏览器侧只认 token。
|
||||
*/
|
||||
public OapiModels.DiagramUrlVo diagramUrl(Long schId, Long instanceId, String flowGuid, String mode) {
|
||||
if (flowGuid != null && !flowGuid.trim().isEmpty()) {
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
schId, flowGuid.trim(), FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程不存在或未发布:" + flowGuid));
|
||||
return issueUrl(schId, null, def.getId(), mode);
|
||||
}
|
||||
if (instanceId != null) {
|
||||
requireInstance(schId, instanceId);
|
||||
return issueUrl(schId, instanceId, null, mode);
|
||||
}
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 flowGuid");
|
||||
}
|
||||
|
||||
private OapiModels.DiagramUrlVo issueUrl(Long schId, Long instanceId, Long defId, String mode) {
|
||||
String token = tokenService.issue(schId, instanceId, defId, mode);
|
||||
OapiModels.DiagramUrlVo vo = new OapiModels.DiagramUrlVo();
|
||||
vo.setImgUrl("/open/flow/diagram/img?token=" + token);
|
||||
vo.setExpiresIn(DiagramTokenService.TTL_MS / 1000);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 进度/换图共用的办件定位(schId 收敛在次) */
|
||||
private FlwInstance requireInstance(Long schId, Long instanceId) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
|
||||
if (!schId.equals(instance.getSchId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "办件不属于该学校");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private OapiModels.ProgressVo toProgressVo(FlwInstance instance) {
|
||||
OapiModels.ProgressVo vo = new OapiModels.ProgressVo();
|
||||
vo.setInstanceId(instance.getId());
|
||||
vo.setBizType(instance.getBizType());
|
||||
vo.setBizId(instance.getBizId());
|
||||
vo.setBizTitle(instance.getBizTitle());
|
||||
vo.setStatus(instance.getStatus());
|
||||
vo.setCurrentNodes(instance.getCurrentNodes());
|
||||
vo.setSubmitterId(instance.getSubmitterId());
|
||||
vo.setSubmitterName(instance.getSubmitterName());
|
||||
vo.setSubmitTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
|
||||
vo.setFinishTime(instance.getFinishTime() == null ? null : instance.getFinishTime().toString());
|
||||
|
||||
Set<String> activeNodes = new LinkedHashSet<>();
|
||||
if (instance.getCurrentNodes() != null && !instance.getCurrentNodes().isEmpty()) {
|
||||
activeNodes.addAll(Arrays.asList(instance.getCurrentNodes().split(",")));
|
||||
}
|
||||
// 第 0 步固定"发起申请"(对外时间线图3/图4口径)
|
||||
OapiModels.ProgressStepVo submit = new OapiModels.ProgressStepVo();
|
||||
submit.setNodeId("submit");
|
||||
submit.setNodeName("发起申请");
|
||||
submit.setActive(false);
|
||||
submit.setLabel("发起申请");
|
||||
OapiModels.ProgressTaskVo submitter = new OapiModels.ProgressTaskVo();
|
||||
submitter.setUserId(instance.getSubmitterId());
|
||||
submitter.setUserName(instance.getSubmitterName());
|
||||
submitter.setStatus(1);
|
||||
submitter.setStatusLabel("已提交");
|
||||
submitter.setArriveTime(vo.getSubmitTime());
|
||||
submit.setAssignees(new ArrayList<>(java.util.Collections.singletonList(submitter)));
|
||||
List<OapiModels.ProgressStepVo> steps = new ArrayList<>();
|
||||
steps.add(submit);
|
||||
|
||||
// 步骤聚合:nodeId 保序;同节点多任务行(会签/退回重到/失效清理)并入同一多人步骤
|
||||
Map<String, OapiModels.ProgressStepVo> stepsByNode = new HashMap<>();
|
||||
for (FlwTask t : taskRepository.findByInstanceIdOrderByIdAsc(instance.getId())) {
|
||||
OapiModels.ProgressStepVo step = stepsByNode.computeIfAbsent(t.getNodeId(), k -> {
|
||||
OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo();
|
||||
s.setNodeId(t.getNodeId());
|
||||
s.setNodeName(t.getNodeName());
|
||||
s.setAssignees(new ArrayList<>());
|
||||
steps.add(s);
|
||||
return s;
|
||||
});
|
||||
OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo();
|
||||
row.setTaskId(t.getId());
|
||||
row.setUserId(t.getAssigneeId());
|
||||
row.setUserName(t.getAssigneeName());
|
||||
row.setStatus(t.getStatus());
|
||||
row.setStatusLabel(taskLabel(t.getStatus()));
|
||||
row.setOpinion(t.getOpinion());
|
||||
row.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString());
|
||||
row.setHandleTime(t.getHandleTime() == null ? null : t.getHandleTime().toString());
|
||||
row.setEdgeName(t.getEdgeName());
|
||||
step.getAssignees().add(row);
|
||||
}
|
||||
// 步骤 label:在办节点=待审核,其余取办理人行的汇总(任一拒绝=已拒绝 > 任一同意=已同意 > 全失效=失效)
|
||||
for (OapiModels.ProgressStepVo s : steps) {
|
||||
if (s == submit) {
|
||||
continue;
|
||||
}
|
||||
s.setActive(activeNodes.contains(s.getNodeId()));
|
||||
s.setLabel(s.isActive() ? "待审核" : stepLabel(s.getAssignees()));
|
||||
}
|
||||
vo.setSteps(steps);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String taskLabel(Integer status) {
|
||||
if (status == null) {
|
||||
return "";
|
||||
}
|
||||
switch (status) {
|
||||
case 0: return "待审核";
|
||||
case 1: return "已同意";
|
||||
case 2: return "已拒绝";
|
||||
case 3: return "失效";
|
||||
case 4: return "已退回";
|
||||
case 5: return "已移交";
|
||||
default: return String.valueOf(status);
|
||||
}
|
||||
}
|
||||
|
||||
private String stepLabel(List<OapiModels.ProgressTaskVo> rows) {
|
||||
boolean denied = false, approved = false, invalid = true;
|
||||
for (OapiModels.ProgressTaskVo r : rows) {
|
||||
int s = r.getStatus() == null ? -1 : r.getStatus();
|
||||
if (s == 2) {
|
||||
denied = true;
|
||||
}
|
||||
if (s == 1) {
|
||||
approved = true;
|
||||
}
|
||||
if (s == 0 || s == 1 || s == 2 || s == 4 || s == 5) {
|
||||
invalid = false;
|
||||
}
|
||||
}
|
||||
if (denied) {
|
||||
return "已拒绝";
|
||||
}
|
||||
if (approved) {
|
||||
return "已同意";
|
||||
}
|
||||
return invalid ? "失效" : "待审核";
|
||||
}
|
||||
|
||||
/** 回调注册(uk: sch+appKey+flowGuid;重复注册=更新配置并重置 secret;注册时探活) */
|
||||
@Transactional
|
||||
public OapiModels.CallbackRegResultVo registerCallback(Long schId, String appKey,
|
||||
|
||||
@@ -169,6 +169,60 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
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(), "待办任务须全部失效");
|
||||
|
||||
// 重复撤回(已 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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 流程图服务端出图(2026-09-16 "CAS 验证码式嵌入"):
|
||||
* renderPng 恒出非空 PNG;布局对环/孤儿/缺失端点防御出图(外部 img 场景无错误页可跳)。
|
||||
*/
|
||||
class DiagramImageServiceTest {
|
||||
|
||||
private final DiagramImageService service = new DiagramImageService();
|
||||
|
||||
private FlowModels.NodeVo node(String id, String type, String label, String sub, String state) {
|
||||
FlowModels.NodeVo n = new FlowModels.NodeVo();
|
||||
n.setId(id);
|
||||
n.setType(type);
|
||||
n.setLabel(label);
|
||||
n.setSubLabel(sub);
|
||||
n.setState(state);
|
||||
return n;
|
||||
}
|
||||
|
||||
private FlowModels.EdgeVo edge(String from, String to, String name) {
|
||||
FlowModels.EdgeVo e = new FlowModels.EdgeVo();
|
||||
e.setFrom(from);
|
||||
e.setTo(to);
|
||||
e.setName(name);
|
||||
return e;
|
||||
}
|
||||
|
||||
private FlowModels.DiagramVo vo(List<FlowModels.NodeVo> nodes, List<FlowModels.EdgeVo> edges) {
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
vo.setName("教师入职审批");
|
||||
vo.setMode("auto");
|
||||
vo.setNodes(nodes);
|
||||
vo.setEdges(edges);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_instanceVo_producesPngWithStateColors() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("n1", "START", "开始", null, null),
|
||||
node("n2", "APPROVAL", "审核节点", "张三、李四 · 任一人通过", null),
|
||||
node("n3", "APPROVAL", "科室审批", "教学处 · 3人 · 时限24h", "active"),
|
||||
node("n4", "END", "结束", null, null)));
|
||||
List<FlowModels.EdgeVo> edges = new ArrayList<>(Arrays.asList(
|
||||
edge("n1", "n2", null), edge("n2", "n3", null), edge("n3", "n4", null)));
|
||||
byte[] png = service.renderPng(vo(nodes, edges));
|
||||
// PNG 头 89 50 4E 47
|
||||
assertEquals(0x89, png[0] & 0xFF);
|
||||
assertEquals(0x50, png[1] & 0xFF);
|
||||
assertTrue(png.length > 2000, "渲染结果过小,疑似空图");
|
||||
// 状态无关的纯定义图也恒出图(flowGuid 口径)
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
n.setState(null);
|
||||
}
|
||||
assertNotEquals(0, service.renderPng(vo(nodes, edges)).length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_defensive_missingEndpointAndCycle_stillProducesImage() {
|
||||
// 成环 + 指向不存在端点的边:不抛,仍出图(外部 img 场景防御)
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("n1", "START", "开始", null, null),
|
||||
node("n2", "APPROVAL", "审核节点", null, "done"),
|
||||
node("n3", "APPROVAL", "孤儿节点", null, null)));
|
||||
List<FlowModels.EdgeVo> edges = new ArrayList<>(Arrays.asList(
|
||||
edge("n1", "n2", null), edge("n2", "n1", null), edge("n2", "ghost", null)));
|
||||
assertDoesNotThrow(() -> service.renderPng(vo(nodes, edges)));
|
||||
}
|
||||
}
|
||||
@@ -366,7 +366,7 @@ class FlowDiagramServiceTest {
|
||||
// 真守卫(同 FlowAccessGuardTest 口径):提交人/办理人/本校管理员
|
||||
FlowAccessGuard accessGuard = new FlowAccessGuard(taskRepository);
|
||||
controller = new FlowUserController(queryService, taskService, instanceService,
|
||||
accessGuard, diagramService);
|
||||
accessGuard, diagramService, mock(com.yunhai.flowcenter.service.DiagramImageService.class));
|
||||
}
|
||||
|
||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||
|
||||
Reference in New Issue
Block a user