From 6afc7d7b92f149704ec3ea3b8e844a0e3049775f Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 17 Sep 2026 01:03:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(oapi):=20=E5=8A=9E=E4=BB=B6=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6/=E6=B5=81=E7=A8=8B=E5=9B=BEtoken=E5=87=BA=E5=9B=BE/?= =?UTF-8?q?=E6=89=98=E7=AE=A1=E6=92=A4=E5=9B=9E=20+=20CAS=E7=9B=B4?= =?UTF-8?q?=E5=B5=8C=E7=A4=BA=E6=84=8F=E5=9B=BE=EF=BC=88=E4=B8=A4=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E4=BA=A7=E7=89=A9=E5=90=88=E5=85=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/FlowUserController.java | 20 + .../controller/oapi/OapiFlowController.java | 41 ++ .../open/OpenDiagramImageController.java | 78 ++++ .../yunhai/flowcenter/model/OapiModels.java | 58 +++ .../service/DiagramImageService.java | 378 ++++++++++++++++++ .../service/DiagramTokenService.java | 76 ++++ .../flowcenter/service/OapiFlowService.java | 190 +++++++++ .../flowcenter/oapi/OapiFlowContractIT.java | 54 +++ .../service/DiagramImageServiceTest.java | 82 ++++ .../service/FlowDiagramServiceTest.java | 2 +- 10 files changed, 978 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/yunhai/flowcenter/controller/open/OpenDiagramImageController.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/DiagramTokenService.java create mode 100644 src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java index 4ecdbdb..805bafb 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java @@ -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 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, "未登录"); diff --git a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java index 32542ce..17eef7c 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java @@ -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 } 的免签路径 {@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, diff --git a/src/main/java/com/yunhai/flowcenter/controller/open/OpenDiagramImageController.java b/src/main/java/com/yunhai/flowcenter/controller/open/OpenDiagramImageController.java new file mode 100644 index 0000000..6a6dbec --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/controller/open/OpenDiagramImageController.java @@ -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 验证码式"嵌入)。 + *

+ * 故意不落 /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 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 png(FlowModels.DiagramVo vo) { + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + .cacheControl(org.springframework.http.CacheControl.noStore()) + .body(imageService.renderPng(vo)); + } + + private ResponseEntity notFound(String msg) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .contentType(MediaType.TEXT_PLAIN) + .body(msg.getBytes()); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java index f93974c..a672b98 100644 --- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -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 steps; + } + + /** 进度步骤(同一节点全部任务行聚合为一步,b 会签时 assignees 多行) */ + @Data + public static class ProgressStepVo { + private String nodeId; + private String nodeName; + /** 步骤标签:发起申请/待审核/已同意/已拒绝/已退回/已移交/失效(任务状态汇总口径见 FlowProgress.stage) */ + private String label; + /** 节点当前是否在办 */ + private boolean active; + private List 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 可直接放 ,免 app-sign) */ + @Data + public static class DiagramUrlVo { + /** 免签图片端点相对路径(?token=xxx),消费方可拼到本服务的对外网关地址后 */ + private String imgUrl; + /** 相对 token 有效期(秒) */ + private long expiresIn; + } } diff --git a/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java new file mode 100644 index 0000000..da0e02b --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/DiagramImageService.java @@ -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 字节流。 + *

+ * 背景(用户拍板 2026-09-16):外部系统要像嵌 CAS 验证码一样用 {@code } 直接嵌流程图, + * 而 img 标签带不了 app-sign 请求头,因此《流程中心流程图查看方案.md》§二"不做服务端出图"的 + * 边界对外场景作废——本类在服务端把布局 JSON 画成 PNG;前端手绘 SVG 方案仅流程中心自有页面继续用。 + *

+ * 布局:自算拓扑最长路径分层(自上而下),忽略设计器画布坐标(那是全屏编辑器坐标系,直接缩放会失真); + * 节点颜色按运行时状态 done/active/rejected/pending 上色,图例仅 instance 口径绘制。 + * 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。 + */ +@Service +public class DiagramImageService { + + /** 逻辑坐标常量(输出按 SCALE 倍导出,保证 缩放清晰) */ + 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 nodes = vo.getNodes() == null ? new ArrayList<>() : vo.getNodes(); + List edges = vo.getEdges() == null ? new ArrayList<>() : vo.getEdges(); + Map byId = new HashMap<>(); + for (FlowModels.NodeVo n : nodes) { + byId.put(n.getId(), n); + } + Map> out = new HashMap<>(); + Map 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 level = new HashMap<>(); + List queue = new ArrayList<>(); + for (Map.Entry 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> 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 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 tl = new HashMap<>(); + for (Map.Entry> en : layers.entrySet()) { + List 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> 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 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 wrap(Graphics2D g, String text, int width, int maxLines) { + FontMetrics fm = g.getFontMetrics(); + List 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 + "…"; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/DiagramTokenService.java b/src/main/java/com/yunhai/flowcenter/service/DiagramTokenService.java new file mode 100644 index 0000000..22e176c --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/DiagramTokenService.java @@ -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 验证码式嵌入"): + *

+ * {@code } 发不了 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 加载/刷新/多端嵌入),到期由惰性清扫回收。 + *

+ * 单机内存态即可(图片是无状态渲染,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 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> it = tokens.entrySet().iterator(); it.hasNext(); ) { + if (it.next().getValue().getExpireAt() < now) { + it.remove(); + } + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index ac0a832..d151609 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -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 @@ -116,6 +119,193 @@ public class OapiFlowService { return instance == null ? null : toInstanceVo(instance); } + /** + * 撤回(托管接入件,2026-09-16 补:原先只有 CAS 用户面 {@code /api/flow/instance/{id}/withdraw}, + * 业务应用只能让浏览器直调,票据与 CORS 一并外溢)。治理口径: + *

+ */ + @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 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 steps = new ArrayList<>(); + steps.add(submit); + + // 步骤聚合:nodeId 保序;同节点多任务行(会签/退回重到/失效清理)并入同一多人步骤 + Map 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 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, diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java index 9c26876..6622b1a 100644 --- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java @@ -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 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 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 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 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"); } diff --git a/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java new file mode 100644 index 0000000..ba75a7f --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/DiagramImageServiceTest.java @@ -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 nodes, List 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 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 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 nodes = new ArrayList<>(Arrays.asList( + node("n1", "START", "开始", null, null), + node("n2", "APPROVAL", "审核节点", null, "done"), + node("n3", "APPROVAL", "孤儿节点", null, null))); + List edges = new ArrayList<>(Arrays.asList( + edge("n1", "n2", null), edge("n2", "n1", null), edge("n2", "ghost", null))); + assertDoesNotThrow(() -> service.renderPng(vo(nodes, edges))); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java index cebeea2..cff6009 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java @@ -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) {