feat(apply): 用户面撤回/进度/流程图后端中转 + 大厅HallController + flowEnabled 三态(SDK withdraw/progress/diagramUrl)
build / build-push (push) Successful in 1m11s
build / build-push (push) Successful in 1m11s
This commit is contained in:
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -149,6 +150,100 @@ class FlowCenterClientFacadeTest {
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(client().getFlow(3L, "guid-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤回:走门面 → /oapi/flow/instance/{id}/withdraw + schId/submitterId(对端复核身份)")
|
||||
void withdraw_goesThroughFacade() {
|
||||
client().withdraw(3L, 77L, 201);
|
||||
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).post(eq(TARGET), eq("/oapi/flow/instance/77/withdraw"), query.capture(),
|
||||
isNull(), eq(Void.class));
|
||||
assertEquals("3", query.getValue().get("schId"));
|
||||
assertEquals("201", query.getValue().get("submitterId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("进度:走门面 → /oapi/flow/progress?instanceId=&schId=")
|
||||
void progress_goesThroughFacade() {
|
||||
SvcModels.FlowProgressVo stub = new SvcModels.FlowProgressVo();
|
||||
stub.setInstanceId(77L);
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/progress"), anyMap(),
|
||||
eq(SvcModels.FlowProgressVo.class))).thenReturn(stub);
|
||||
|
||||
assertEquals(Long.valueOf(77L), client().progress(3L, 77L, null, null).getInstanceId());
|
||||
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).get(eq(TARGET), eq("/oapi/flow/progress"), query.capture(),
|
||||
eq(SvcModels.FlowProgressVo.class));
|
||||
assertEquals("77", query.getValue().get("instanceId"));
|
||||
assertEquals("3", query.getValue().get("schId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("流程图:换图 + 取图两步都在后端;token 不出后端(前端拿不到,也不需要 flow-center 地址)")
|
||||
void diagramImage_tokenStaysInBackend() {
|
||||
SvcModels.FlowDiagramVo url = new SvcModels.FlowDiagramVo();
|
||||
url.setImgUrl("/open/flow/diagram/img?token=tok-abc");
|
||||
url.setExpiresIn(300);
|
||||
when(openCall.get(eq(TARGET), eq("/oapi/flow/diagram/url"), anyMap(),
|
||||
eq(SvcModels.FlowDiagramVo.class))).thenReturn(url);
|
||||
byte[] png = new byte[]{(byte) 0x89, 'P', 'N', 'G'};
|
||||
when(openCall.download(eq(TARGET), eq("/open/flow/diagram/img"),
|
||||
eq(java.util.Collections.singletonMap("token", "tok-abc")))).thenReturn(png);
|
||||
|
||||
assertEquals(png, client().diagramImage(3L, 77L, null, "dept"));
|
||||
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
verify(openCall).get(eq(TARGET), eq("/oapi/flow/diagram/url"), query.capture(),
|
||||
eq(SvcModels.FlowDiagramVo.class));
|
||||
assertEquals("77", query.getValue().get("instanceId"));
|
||||
assertEquals("dept", query.getValue().get("mode"));
|
||||
verify(openCall).download(eq(TARGET), eq("/open/flow/diagram/img"),
|
||||
eq(java.util.Collections.singletonMap("token", "tok-abc")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tokenOf:只认 ?token= 段,容忍尾部其它参数;无 token 返回 null(不拼出脏地址)")
|
||||
void tokenOf_parsesTokenOnly() {
|
||||
assertEquals("abc", FlowCenterClient.tokenOf("/open/flow/diagram/img?token=abc"));
|
||||
assertEquals("abc", FlowCenterClient.tokenOf("/open/flow/diagram/img?token=abc&x=1"));
|
||||
assertEquals("abc", FlowCenterClient.tokenOf("http://h/flow-center/open/flow/diagram/img?token=abc"));
|
||||
org.junit.jupiter.api.Assertions.assertNull(FlowCenterClient.tokenOf("/open/flow/diagram/img"));
|
||||
org.junit.jupiter.api.Assertions.assertNull(FlowCenterClient.tokenOf(null));
|
||||
org.junit.jupiter.api.Assertions.assertNull(FlowCenterClient.tokenOf("/x?token="));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("目录未配:撤回落 legacy base-url(ad-hoc TargetSpec POST,行为不变)")
|
||||
void withdraw_fallsBackToLegacy() {
|
||||
YschOpenCall legacyCall = mock(YschOpenCall.class);
|
||||
when(legacyCall.hasTarget(TARGET)).thenReturn(false);
|
||||
|
||||
new FlowCenterClient(legacyCall, objectMapper, true, LEGACY_BASE).withdraw(3L, 77L, 201);
|
||||
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
verify(legacyCall).post(any(com.yunhai.platform.openapi.core.TargetSpec.class),
|
||||
eq("/oapi/flow/instance/77/withdraw"), query.capture(), isNull(), eq(Void.class));
|
||||
assertEquals("201", query.getValue().get("submitterId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enabled=false:三个新方法一律抛「未接入」(不静默成功让前端误判)")
|
||||
void disabledClient_rejectsNewActions() {
|
||||
YschOpenCall noDirectory = mock(YschOpenCall.class);
|
||||
when(noDirectory.hasTarget(TARGET)).thenReturn(false);
|
||||
FlowCenterClient c = new FlowCenterClient(noDirectory, objectMapper, false, "");
|
||||
|
||||
com.yunhai.platform.apistd.BizException withdrawEx =
|
||||
org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class,
|
||||
() -> c.withdraw(3L, 77L, 201));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(withdrawEx.getMessage().contains("流程中心未接入"));
|
||||
org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class,
|
||||
() -> c.progress(3L, 77L, null, null));
|
||||
org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class,
|
||||
() -> c.diagramImage(3L, 77L, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("目录未配:回落 legacy svchall.flow.base-url(迁移期兼容,行为不变)")
|
||||
void fallsBackToLegacyWhenDirectoryMissing() {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.yunhai.svchall.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.svchall.entity.ServiceApply;
|
||||
import com.yunhai.svchall.enums.ApplyFlowStatusEnum;
|
||||
import com.yunhai.svchall.model.svc.SvcModels;
|
||||
import com.yunhai.svchall.repository.ServiceApplyRepository;
|
||||
import com.yunhai.svchall.repository.ServiceItemRepository;
|
||||
import com.yunhai.svchall.service.ApplyViewGuard;
|
||||
import com.yunhai.svchall.service.FlowClient;
|
||||
import com.yunhai.svchall.service.SiteDirectory;
|
||||
import com.yunhai.svchall.service.SiteGuard;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 用户面流程动作单测(BP-R4-1/BP-R4-2,2026-09-16):
|
||||
* ①撤回改由后端经 SDK 中转(前端不再直调 flow-center),授权点在本地——提交人本人 + 审批中 + 有实例;
|
||||
* ②进度/流程图与详情同守卫(三分支),未进流程不报错;
|
||||
* ③提交类响应带 flowEnabled 标志,让前端能区分"排队中"与"流程中心没接入"。
|
||||
*/
|
||||
class ServiceApplyUserActionTest {
|
||||
|
||||
private ServiceApplyRepository applyRepository;
|
||||
private FlowClient flowClient;
|
||||
private ApplyViewGuard applyViewGuard;
|
||||
private ServiceApplyService service;
|
||||
private ServiceApply apply;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applyRepository = mock(ServiceApplyRepository.class);
|
||||
ServiceItemRepository itemRepository = mock(ServiceItemRepository.class);
|
||||
ServiceFormService formService = mock(ServiceFormService.class);
|
||||
AttachmentService attachmentService = mock(AttachmentService.class);
|
||||
flowClient = mock(FlowClient.class);
|
||||
SiteGuard siteGuard = mock(SiteGuard.class);
|
||||
SiteDirectory siteDirectory = mock(SiteDirectory.class);
|
||||
applyViewGuard = mock(ApplyViewGuard.class);
|
||||
service = new ServiceApplyService(applyRepository, itemRepository, formService,
|
||||
attachmentService, flowClient, siteGuard, siteDirectory, applyViewGuard,
|
||||
new ObjectMapper(),
|
||||
new org.springframework.transaction.support.TransactionTemplate(
|
||||
mock(org.springframework.transaction.PlatformTransactionManager.class)));
|
||||
|
||||
apply = new ServiceApply();
|
||||
ReflectionTestUtils.setField(apply, "id", 27);
|
||||
apply.setSchId(1L);
|
||||
apply.setSiteCode("S1");
|
||||
apply.setItemTitle("教师入职");
|
||||
apply.setSubmitterId(9);
|
||||
apply.setSubmitterName("刘文娟");
|
||||
apply.setFlowGuid("fg-1");
|
||||
apply.setFlowInstanceId(9011L);
|
||||
apply.setFlowStatus(ApplyFlowStatusEnum.APPROVING.getCode());
|
||||
when(applyRepository.findByIdAndSchId(27, 1L)).thenReturn(Optional.of(apply));
|
||||
when(applyRepository.findById(27)).thenReturn(Optional.of(apply));
|
||||
when(flowClient.enabled()).thenReturn(true);
|
||||
}
|
||||
|
||||
private BoaUser user(int id, int schId) {
|
||||
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(user.getId()).thenReturn(id);
|
||||
when(user.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
return user;
|
||||
}
|
||||
|
||||
// ===== 撤回 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("撤回:提交人本人 + 审批中 → 经 FlowClient 出站(schId/instanceId/submitterId 全带)")
|
||||
void withdraw_byOwner_goesThroughFlowClient() {
|
||||
service.withdraw(user(9, 1), 27);
|
||||
verify(flowClient).withdraw(eq(1L), eq(9011L), eq(9));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤回:非提交人 → FORBIDDEN,且不出站(授权点在后端,前端拿不到别人的办件 id 也没用)")
|
||||
void withdraw_nonOwner_rejectedWithoutOutbound() {
|
||||
BizException e = assertThrows(BizException.class, () -> service.withdraw(user(8, 1), 27));
|
||||
assertEquals("仅可撤回自己的办件", e.getMessage());
|
||||
verify(flowClient, never()).withdraw(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤回:非审批中(已撤回/已完成)→ 拒绝,不出站")
|
||||
void withdraw_nonApproving_rejected() {
|
||||
apply.setFlowStatus(ApplyFlowStatusEnum.WITHDRAWN.getCode());
|
||||
assertThrows(BizException.class, () -> service.withdraw(user(9, 1), 27));
|
||||
verify(flowClient, never()).withdraw(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤回:待提交(无实例)→ 拒绝并说明原因,不出站")
|
||||
void withdraw_pendingWithoutInstance_rejected() {
|
||||
apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode());
|
||||
apply.setFlowInstanceId(null);
|
||||
BizException e = assertThrows(BizException.class, () -> service.withdraw(user(9, 1), 27));
|
||||
assertTrue(e.getMessage().contains("可撤回"), e.getMessage());
|
||||
verify(flowClient, never()).withdraw(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤回:流程中心拒绝(已有任务被办理)→ 业务异常原样透传给前端")
|
||||
void withdraw_flowCenterRejection_propagates() {
|
||||
// void 方法用 doThrow 桩(when(...) 对 void 不合法)
|
||||
org.mockito.Mockito.doThrow(new BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "已有任务被办理,不能撤回"))
|
||||
.when(flowClient).withdraw(anyLong(), anyLong(), any());
|
||||
BizException e = assertThrows(BizException.class, () -> service.withdraw(user(9, 1), 27));
|
||||
assertEquals("已有任务被办理,不能撤回", e.getMessage());
|
||||
}
|
||||
|
||||
// ===== 进度 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("进度:先过可见性守卫,再按 instanceId 取(对端只校验 schId,守卫必须在本地)")
|
||||
void progress_guardsThenDelegates() {
|
||||
SvcModels.FlowProgressVo vo = new SvcModels.FlowProgressVo();
|
||||
vo.setInstanceId(9011L);
|
||||
when(flowClient.progress(eq(1L), eq(9011L), isNull(), isNull())).thenReturn(vo);
|
||||
|
||||
assertEquals(vo, service.progress(user(9, 1), 27));
|
||||
verify(applyViewGuard).requireVisible(eq(apply), any(BoaUser.class));
|
||||
verify(flowClient).progress(eq(1L), eq(9011L), isNull(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("进度:守卫拒绝时不出站(他人办件查不到轨迹)")
|
||||
void progress_guardRejection_blocksOutbound() {
|
||||
org.mockito.Mockito.doThrow(new BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "办件不存在或无权查看"))
|
||||
.when(applyViewGuard).requireVisible(eq(apply), any(BoaUser.class));
|
||||
assertThrows(BizException.class, () -> service.progress(user(8, 1), 27));
|
||||
verify(flowClient, never()).progress(anyLong(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("进度:未进流程(待提交)→ 返回空 steps 不报错(前端渲染待提交)")
|
||||
void progress_notSubmitted_returnsEmptySteps() {
|
||||
apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode());
|
||||
apply.setFlowInstanceId(null);
|
||||
SvcModels.FlowProgressVo vo = service.progress(user(9, 1), 27);
|
||||
assertTrue(vo.getSteps().isEmpty());
|
||||
assertEquals(ApplyFlowStatusEnum.PENDING.getCode(), vo.getStatus());
|
||||
assertEquals("27", vo.getBizId(), "bizId 口径 = 办件 id(与代发起同键)");
|
||||
assertNull(vo.getInstanceId());
|
||||
verify(flowClient, never()).progress(anyLong(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ===== 流程图 =====
|
||||
|
||||
@Test
|
||||
@DisplayName("流程图:可见性通过后返回 PNG 字节(token 换图在 FlowClient 内部完成)")
|
||||
void diagram_delegatesToFlowClient() {
|
||||
byte[] png = new byte[]{(byte) 0x89, 'P', 'N', 'G'};
|
||||
when(flowClient.diagramImage(eq(1L), eq(9011L), isNull(), eq("dept"))).thenReturn(png);
|
||||
assertEquals(png, service.diagramImage(user(9, 1), 27, "dept"));
|
||||
verify(applyViewGuard).requireVisible(eq(apply), any(BoaUser.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("流程图:未进流程 → 明确报错(不返回空图骗前端)")
|
||||
void diagram_notSubmitted_rejected() {
|
||||
apply.setFlowInstanceId(null);
|
||||
assertThrows(BizException.class, () -> service.diagramImage(user(9, 1), 27, null));
|
||||
verify(flowClient, never()).diagramImage(anyLong(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ===== flowEnabled 契约(BP-R4-1 误报根因) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("retry:响应带 flowEnabled=true(已接入则 flowStatus=0 只是排队,不是失败)")
|
||||
void retry_stampsFlowEnabledTrue() {
|
||||
apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode());
|
||||
when(flowClient.apply(anyLong(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(new FlowClient.ApplyResult(9011L, false));
|
||||
|
||||
SvcModels.ApplyVo vo = service.retry(user(9, 1), 27);
|
||||
|
||||
assertTrue(vo.getFlowEnabled(), "前端据此判断'排队中'而非'流程中心不可用'");
|
||||
verify(applyRepository).markApproving(eq(27), eq(9011L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("retry:enabled=false 时响应 flowEnabled=false(真的不会翻转,前端提示未接入)")
|
||||
void retry_stampsFlowEnabledFalseWhenDisabled() {
|
||||
apply.setFlowStatus(ApplyFlowStatusEnum.PENDING.getCode());
|
||||
when(flowClient.enabled()).thenReturn(false);
|
||||
|
||||
SvcModels.ApplyVo vo = service.retry(user(9, 1), 27);
|
||||
|
||||
assertFalse(vo.getFlowEnabled());
|
||||
verify(flowClient, never()).apply(anyLong(), any(), any(), any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user