feat(apply): 用户面撤回/进度/流程图后端中转 + 大厅HallController + flowEnabled 三态(SDK withdraw/progress/diagramUrl)
build / build-push (push) Successful in 1m11s

This commit is contained in:
chenxing
2026-09-17 01:05:35 +08:00
parent e462b71bef
commit f0279ae12d
9 changed files with 675 additions and 8 deletions
@@ -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());
}
}