diff --git a/src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java b/src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java index bb7e509..f4205b9 100644 --- a/src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java +++ b/src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java @@ -66,6 +66,21 @@ public class NoticeFlowController { return noticeFlowService.buildTrace(serid, user); } + /** + * 撤回审核(2026-09-17 治理:原先前端直调 flow-center 用户面,现后端经 SDK 托管件中转; + * 仅创建人本人 + 审批中,撤回后状态由 flow.withdrawn 回调推进)。 + */ + @PostMapping("Withdraw") + public void withdraw(@RequestParam("serid") Integer serid, BoaUser user) { + noticeFlowService.withdrawNotice(serid, user); + } + + /** 流程示意图(PNG 流,同源直嵌 img;可见性与 Trace 同口径,token 不落前端) */ + @GetMapping(value = "Diagram", produces = org.springframework.http.MediaType.IMAGE_PNG_VALUE) + public byte[] diagram(@RequestParam("serid") Integer serid, BoaUser user) { + return noticeFlowService.diagramImage(serid, user); + } + private Integer currentSchId(BoaUser user) { if (user == null || user.getCurrentRole() == null) { return null; diff --git a/src/main/java/com/yunhai/notice/model/flow/FlowModels.java b/src/main/java/com/yunhai/notice/model/flow/FlowModels.java index e45bc90..164b5f0 100644 --- a/src/main/java/com/yunhai/notice/model/flow/FlowModels.java +++ b/src/main/java/com/yunhai/notice/model/flow/FlowModels.java @@ -7,6 +7,8 @@ import com.yunhai.platform.openapi.flow.model.FlowApplyResult; import com.yunhai.platform.openapi.flow.model.FlowInstance; import com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody; import com.yunhai.platform.openapi.flow.model.FlowCallbackRegResult; +import com.yunhai.platform.openapi.flow.model.FlowDiagramUrl; +import com.yunhai.platform.openapi.flow.model.FlowProgress; import lombok.Data; import lombok.EqualsAndHashCode; @@ -61,4 +63,18 @@ public final class FlowModels { @JsonIgnoreProperties(ignoreUnknown = true) public static class CallbackRegResultVo extends FlowCallbackRegResult { } + + /** 办件进度(GET /oapi/flow/progress;steps[0] 固定"发起申请",2026-09-17 接入) */ + @Data + @EqualsAndHashCode(callSuper = true) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ProgressVo extends FlowProgress { + } + + /** 流程示意图换图地址(GET /oapi/flow/diagram/url;imgUrl 为 flow-center 免签相对路径) */ + @Data + @EqualsAndHashCode(callSuper = true) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DiagramUrlVo extends FlowDiagramUrl { + } } diff --git a/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java b/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java index 705c99f..9e47673 100644 --- a/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java +++ b/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java @@ -116,6 +116,87 @@ public class FlowCenterClient implements FlowClient { FlowModels.CallbackRegResultVo.class); } + @Override + public void withdraw(Integer schId, Long instanceId, Integer submitterId) { + requireEnabled(); + if (directoryResolved) { + com.yunhai.platform.openapi.flow.YschFlowOpenApi.withdraw(schId, instanceId, submitterId); + return; + } + Map query = new HashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("submitterId", String.valueOf(submitterId)); + // 无请求体:对端 POST 无 @RequestBody,query 传参即可(body 传 null 即零长度请求体) + openCall.post(target(), "/oapi/flow/instance/" + instanceId + "/withdraw", query, null, Void.class); + } + + @Override + public FlowModels.ProgressVo progress(Integer schId, Long instanceId) { + requireEnabled(); + if (directoryResolved) { + return com.yunhai.platform.openapi.flow.YschFlowOpenApi.progress( + instanceId, BIZ_TYPE, null, schId, FlowModels.ProgressVo.class); + } + Map query = new HashMap<>(); + query.put("instanceId", String.valueOf(instanceId)); + query.put("schId", String.valueOf(schId)); + return openCall.get(target(), "/oapi/flow/progress", query, FlowModels.ProgressVo.class); + } + + /** 免签出图端点(flow-center OpenDiagramImageController,相对其 context-path) */ + private static final String DIAGRAM_IMG_PATH = "/open/flow/diagram/img"; + + @Override + public byte[] diagramImage(Integer schId, Long instanceId) { + requireEnabled(); + String imgUrl; + if (directoryResolved) { + FlowModels.DiagramUrlVo dto = com.yunhai.platform.openapi.flow.YschFlowOpenApi.diagramUrl( + instanceId, null, null, schId, FlowModels.DiagramUrlVo.class); + imgUrl = dto == null ? null : dto.getImgUrl(); + } else { + Map query = new HashMap<>(); + query.put("instanceId", String.valueOf(instanceId)); + query.put("schId", String.valueOf(schId)); + FlowModels.DiagramUrlVo dto = + openCall.get(target(), "/oapi/flow/diagram/url", query, FlowModels.DiagramUrlVo.class); + imgUrl = dto == null ? null : dto.getImgUrl(); + } + String token = tokenOf(imgUrl); + if (token == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程中心未返回有效的示意图地址"); + } + Map imgQuery = new HashMap<>(); + imgQuery.put("token", token); + byte[] bytes = directoryResolved + ? com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw().download(DIAGRAM_IMG_PATH, imgQuery) + : openCall.download(target(), DIAGRAM_IMG_PATH, imgQuery); + if (bytes == null || bytes.length == 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程示意图为空(流程可能未发布节点)"); + } + return bytes; + } + + /** + * 从换图地址中取 token(形如 {@code /open/flow/diagram/img?token=xxx})。 + * 包可见:契约测试冻结"token 只在后端流转、前端拿不到"的取用形态。 + */ + static String tokenOf(String imgUrl) { + if (imgUrl == null || imgUrl.isEmpty()) { + return null; + } + int q = imgUrl.indexOf("?token="); + if (q < 0) { + return null; + } + String token = imgUrl.substring(q + "?token=".length()); + int amp = token.indexOf('&'); + if (amp >= 0) { + token = token.substring(0, amp); + } + return token.isEmpty() ? null : token; + } + private TargetSpec target() { // base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定) return TargetSpec.of(properties.getBaseUrl(), ""); diff --git a/src/main/java/com/yunhai/notice/service/flow/FlowClient.java b/src/main/java/com/yunhai/notice/service/flow/FlowClient.java index 8049254..a75f7c2 100644 --- a/src/main/java/com/yunhai/notice/service/flow/FlowClient.java +++ b/src/main/java/com/yunhai/notice/service/flow/FlowClient.java @@ -21,4 +21,16 @@ public interface FlowClient { /** 回调注册(下发 secret;重复注册会轮换 secret——仅在 reg 缺失时调用) */ FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body); + + /** + * 撤回(托管件,2026-09-17 接入):对端按 submitterId 与办件提交人逐字复核, + * 应用签名只证明"哪个应用在调"不证明"谁在撤"——防业务应用越权代撤他人办件。 + */ + void withdraw(Integer schId, Long instanceId, Integer submitterId); + + /** 办件进度(结构化 steps,instanceId 定位;oapi 只校验 schId,调用方先做可见性守卫) */ + FlowModels.ProgressVo progress(Integer schId, Long instanceId); + + /** 流程示意图字节(token 换图两步都在本方法内,浏览器只拿同源 PNG,token 不落前端) */ + byte[] diagramImage(Integer schId, Long instanceId); } diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java index 0397d70..74d070d 100644 --- a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java @@ -235,6 +235,15 @@ public class NoticeFlowService { result.put("finishTime", instance.getFinishTime()); result.put("trace", parseJson(instance.getTraceJson())); } + if (notice.getFlowInstanceId() != null) { + // 结构化步骤(steps[0] 固定"发起申请",同节点多任务每人一行)—— + // 2026-09-17 接入 SDK progress,详情页时间线数据源;失败降级不阻断 + FlowModels.ProgressVo progress = + flowClient.progress(notice.getSchId(), notice.getFlowInstanceId()); + if (progress != null) { + result.put("steps", progress.getSteps()); + } + } } catch (Exception e) { // 进度查询失败不阻断详情:展示本地镜像态即可(流程中心停机时详情仍可用) log.warn("[notice-flow] 进度查询失败:notice={} {}", noticeId, e.toString()); @@ -243,6 +252,48 @@ public class NoticeFlowService { return result; } + /** + * 撤回审核(2026-09-17 治理:原先前端直调 flow-center 用户面——跨应用 CAS 票据 + CORS + * 双重外溢,且"谁能撤"落在前端)。现经 SDK 托管件后端中转:①本人(publisherAccount) + * = 权威授权;②审批中(10) 且已有实例;③对端再按 submitterId 复核一次。 + * 撤回后镜像状态由 flow.withdrawn 回调推进(40),本方法不改本地状态——单一写入口。 + */ + public void withdrawNotice(Integer noticeId, BoaUser user) { + Notice notice = requireNotice(noticeId); + if (user == null || !StringUtils.hasText(notice.getPublisherAccount()) + || !notice.getPublisherAccount().equals(user.getAccount())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅通知创建人可撤回审核"); + } + Integer audit = notice.getAuditStatus(); + if (audit == null || audit != FlowAuditStatusEnum.APPROVING.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的通知可撤回"); + } + if (notice.getFlowInstanceId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程,无需撤回"); + } + flowClient.withdraw(notice.getSchId(), notice.getFlowInstanceId(), user.getId()); + log.info("[notice-flow] 撤回审核:notice={} instance={} user={}", + noticeId, notice.getFlowInstanceId(), user.getId()); + } + + /** + * 流程示意图(PNG 流,同源直嵌 {@code }):可见性与 Trace 同口径(未发布仅三类人)。 + * token 与字节都在服务端流转,前端不再需要知道 flow-center 地址。 + */ + public byte[] diagramImage(Integer noticeId, BoaUser user) { + Notice notice = requireNotice(noticeId); + if (notice.getStatus() == null || notice.getStatus() != 1) { + noticeFlowGuard.requireUnpublishedVisible(notice, user); + } + if (!StringUtils.hasText(notice.getFlowGuid()) || notice.getAuditStatus() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "该通知未接入审核流程"); + } + if (notice.getFlowInstanceId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "该通知尚未进入流程,无流程图"); + } + return flowClient.diagramImage(notice.getSchId(), notice.getFlowInstanceId()); + } + /** 编辑页"审核流程"选择器数据源(未启用返回空列表由前端隐藏) */ public List options(Integer schId) { if (!properties.isEnabled()) { diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowWithdrawTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowWithdrawTest.java new file mode 100644 index 0000000..b269c7e --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowWithdrawTest.java @@ -0,0 +1,124 @@ +package com.yunhai.notice.service.flow; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.repository.NoticeFlowBindingRepository; +import com.yunhai.notice.repository.NoticeFlowRegRepository; +import com.yunhai.notice.repository.NoticeRepository; +import com.yunhai.notice.repository.UpFileRepository; +import com.yunhai.platform.appauth.AppAuthProperties; +import com.yunhai.platform.auth.spring.CurrentBoaUser; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 撤回中转单测(2026-09-17 接入 SDK 托管件): + * 非创建人 403 / 非审批中拒绝 / 未进入流程拒绝,且三者都不出站; + * 创建人对审批中通知出站撤回(本地不改状态,由 flow.withdrawn 回调推进)。 + */ +class NoticeFlowWithdrawTest { + + private NoticeRepository noticeRepository; + private FlowClient flowClient; + private NoticeFlowService service; + private Notice approving; + + @BeforeEach + void setUp() { + noticeRepository = mock(NoticeRepository.class); + flowClient = mock(FlowClient.class); + NoticeFlowProperties props = new NoticeFlowProperties(); + props.setEnabled(true); + service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class), + mock(NoticeFlowBindingRepository.class), mock(UpFileRepository.class), flowClient, props, + new ObjectMapper(), new AppAuthProperties(), mock(CurrentBoaUser.class), + new NoticeFlowGuard(flowClient, props)); + + approving = new Notice(); + approving.setId(42); + approving.setSchId(27); + approving.setStatus(0); + approving.setFlowGuid("fg-1"); + approving.setAuditStatus(10); + approving.setFlowInstanceId(9527L); + approving.setPublisherAccount("zhangsan"); + when(noticeRepository.findById(42)).thenReturn(Optional.of(approving)); + } + + private static BoaUser owner() { + BoaUser u = new BoaUser(); + org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 201); + u.setAccount("zhangsan"); + return u; + } + + private static BoaUser stranger() { + BoaUser u = new BoaUser(); + org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 999); + u.setAccount("stranger"); + return u; + } + + @Test + void withdraw_byStranger_forbiddenAndNoOutbound() { + com.yunhai.platform.apistd.BizException e = assertThrows( + com.yunhai.platform.apistd.BizException.class, + () -> service.withdrawNotice(42, stranger())); + assertTrue(e.getMessage().contains("仅通知创建人")); + verify(flowClient, never()).withdraw(Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test + void withdraw_whenNotApproving_rejected() { + approving.setAuditStatus(20); + com.yunhai.platform.apistd.BizException e = assertThrows( + com.yunhai.platform.apistd.BizException.class, + () -> service.withdrawNotice(42, owner())); + assertTrue(e.getMessage().contains("仅审批中")); + verify(flowClient, never()).withdraw(Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test + void withdraw_withoutInstance_rejected() { + approving.setFlowInstanceId(null); + com.yunhai.platform.apistd.BizException e = assertThrows( + com.yunhai.platform.apistd.BizException.class, + () -> service.withdrawNotice(42, owner())); + assertTrue(e.getMessage().contains("尚未进入流程")); + verify(flowClient, never()).withdraw(Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test + void withdraw_byOwner_outboundWithSubmitterId() { + service.withdrawNotice(42, owner()); + verify(flowClient).withdraw(27, 9527L, 201); + } + + @Test + void diagram_byStranger_unpublished_forbidden() { + com.yunhai.platform.apistd.BizException e = assertThrows( + com.yunhai.platform.apistd.BizException.class, + () -> service.diagramImage(42, stranger())); + // 未发布通知的流程图与 Trace 同口径(三类人),stranger 404 语义 + assertTrue(e.getMessage().contains("通知不存在或未发布")); + verify(flowClient, Mockito.never()).diagramImage(Mockito.any(), Mockito.any()); + } + + @Test + void diagram_byOwner_returnsBytes() { + when(flowClient.diagramImage(27, 9527L)).thenReturn(new byte[]{1, 2, 3}); + byte[] png = service.diagramImage(42, owner()); + assertTrue(png.length == 3); + } +}