feat(flow): 接入新流程体系——SDK withdraw/progress/diagramUrl:撤回后端中转+同源流程图端点+Trace 结构化 steps(治前端直调外溢)
build / build-push (push) Failing after 33s
build / build-push (push) Failing after 33s
This commit is contained in:
@@ -66,6 +66,21 @@ public class NoticeFlowController {
|
|||||||
return noticeFlowService.buildTrace(serid, user);
|
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) {
|
private Integer currentSchId(BoaUser user) {
|
||||||
if (user == null || user.getCurrentRole() == null) {
|
if (user == null || user.getCurrentRole() == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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.FlowInstance;
|
||||||
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody;
|
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody;
|
||||||
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegResult;
|
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.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
@@ -61,4 +63,18 @@ public final class FlowModels {
|
|||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public static class CallbackRegResultVo extends FlowCallbackRegResult {
|
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 {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,87 @@ public class FlowCenterClient implements FlowClient {
|
|||||||
FlowModels.CallbackRegResultVo.class);
|
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<String, String> 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<String, String> 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<String, String> 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<String, String> 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() {
|
private TargetSpec target() {
|
||||||
// base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定)
|
// base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定)
|
||||||
return TargetSpec.of(properties.getBaseUrl(), "");
|
return TargetSpec.of(properties.getBaseUrl(), "");
|
||||||
|
|||||||
@@ -21,4 +21,16 @@ public interface FlowClient {
|
|||||||
|
|
||||||
/** 回调注册(下发 secret;重复注册会轮换 secret——仅在 reg 缺失时调用) */
|
/** 回调注册(下发 secret;重复注册会轮换 secret——仅在 reg 缺失时调用) */
|
||||||
FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,15 @@ public class NoticeFlowService {
|
|||||||
result.put("finishTime", instance.getFinishTime());
|
result.put("finishTime", instance.getFinishTime());
|
||||||
result.put("trace", parseJson(instance.getTraceJson()));
|
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) {
|
} catch (Exception e) {
|
||||||
// 进度查询失败不阻断详情:展示本地镜像态即可(流程中心停机时详情仍可用)
|
// 进度查询失败不阻断详情:展示本地镜像态即可(流程中心停机时详情仍可用)
|
||||||
log.warn("[notice-flow] 进度查询失败:notice={} {}", noticeId, e.toString());
|
log.warn("[notice-flow] 进度查询失败:notice={} {}", noticeId, e.toString());
|
||||||
@@ -243,6 +252,48 @@ public class NoticeFlowService {
|
|||||||
return result;
|
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 <img>}):可见性与 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<FlowModels.FlowSummaryVo> options(Integer schId) {
|
public List<FlowModels.FlowSummaryVo> options(Integer schId) {
|
||||||
if (!properties.isEnabled()) {
|
if (!properties.isEnabled()) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user