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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回审核(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;
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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() {
|
||||
// base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定)
|
||||
return TargetSpec.of(properties.getBaseUrl(), "");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 <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) {
|
||||
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