[audit] P6.1 F11-4 台账路由挂起可观测:pageAdmin 增 hung 过滤 + 行级 routeHung 红标(口径=max attempt 路由行 outlet 仍 NULL,防误报)

This commit is contained in:
chenxing
2026-10-18 08:05:32 +08:00
parent f4121b2c1e
commit 448c8d36f4
6 changed files with 159 additions and 5 deletions
@@ -54,6 +54,7 @@ public class FlowAdminController {
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
private final com.yunhai.flowcenter.service.FlowAppAdminService appAdminService;
private final com.yunhai.flowcenter.service.FlowDeptAdminService deptAdminService;
private final com.yunhai.flowcenter.repository.FlwRouteLogRepository routeLogRepository;
// ==================== 流程定义 ====================
@@ -462,18 +463,34 @@ public class FlowAdminController {
// ==================== 办件查询 ====================
/**
* 管理端办件分页(F11-4 增 hung 筛选与行级红标):hung=true 只看路由挂起办件
* (口径见 {@link com.yunhai.flowcenter.repository.FlwInstanceRepository#pageAdmin});
* 每行回填 {@code routeHung}(按页一次 {@code findHungInstanceIds} 查询,非逐行 N+1)。
*/
@GetMapping("/instance/page")
public BasePageResult<FlwInstance> instancePage(
BoaUser user,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "hung", required = false) Boolean hung,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
Long schId = adminGuard.requireAdmin(user, null);
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword,
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword, hung,
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
size == null || size < 1 || size > 100 ? 10 : size,
Sort.by(Sort.Order.desc("id"))));
List<Long> pageIds = new java.util.ArrayList<>();
for (FlwInstance i : p.getContent()) {
pageIds.add(i.getId());
}
// 行级红标:不在挂起集合内的行显式置 false(前端只判 truthy,null 也行,但显式更可读)
java.util.Set<Long> hungIds = pageIds.isEmpty() ? java.util.Collections.emptySet()
: new java.util.HashSet<>(routeLogRepository.findHungInstanceIds(pageIds));
for (FlwInstance i : p.getContent()) {
i.setRouteHung(hungIds.contains(i.getId()));
}
int pi = page == null || page < 1 ? 1 : page;
int ps = size == null || size < 1 || size > 100 ? 10 : size;
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(), pi, ps,
@@ -128,4 +128,12 @@ public class FlwInstance extends BaseEntity {
@Version
@Column(name = "version", nullable = false)
private Integer version;
/**
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
* 随实体序列化给前端画红标;业务代码不得读写该字段。
*/
@javax.persistence.Transient
private Boolean routeHung;
}
@@ -27,12 +27,23 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
@Param("status") Integer status, @Param("appKey") String appKey,
Pageable pageable);
/** 管理端办件分页(状态/关键字可空) */
/**
* 管理端办件分页(状态/关键字可空)。
* hung=true 只看「路由挂起」:审批中(status=10)且存在某 (instance,node) 的<b>最大 attempt</b>
* flw_route_log 行 outlet 仍为 NULL(与 {@link com.yunhai.flowcenter.repository.FlwRouteLogRepository#findHungInstanceIds}
* 同口径——历史低 attempt 失败行不参与判定,防「重跑已成功仍报挂起」误报);hung=null/false 不过滤。
*/
@Query("select i from FlwInstance i where i.schId = :schId "
+ "and (:status is null or i.status = :status) "
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) order by i.id desc")
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) "
+ "and (:hung is null or :hung = false or (i.status = 10 and exists ("
+ "select r.id from FlwRouteLog r where r.instanceId = i.id and r.outlet is null "
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)))) "
+ "order by i.id desc")
Page<FlwInstance> pageAdmin(@Param("schId") Long schId, @Param("status") Integer status,
@Param("keyword") String keyword, Pageable pageable);
@Param("keyword") String keyword, @Param("hung") Boolean hung,
Pageable pageable);
/** 按业务类型/状态分页(oapi instances 查询;bizType/status 均可空=不过滤) */
@Query("select i from FlwInstance i where i.schId = :schId "
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long> {
@@ -16,4 +17,16 @@ public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long>
@Query("select coalesce(max(l.attempt), 0) from FlwRouteLog l "
+ "where l.instanceId = :instanceId and l.nodeId = :nodeId")
int maxAttempt(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
/**
* 台账「路由挂起」红标数据源(F11-4):页内办件中仍挂起的 instanceId 集合。
* 判定口径:某 (instance, node) 的<b>最大 attempt</b> 行 outlet 仍为 NULL 才算挂着——
* 退回重走/管理端重跑会写更高 attempt 的新行,历史低 attempt 的失败行(outlet NULL)不参与判定,
* 防「已重跑成功仍报挂起」的误报。
*/
@Query("select distinct r.instanceId from FlwRouteLog r where r.instanceId in :instanceIds "
+ "and r.outlet is null "
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)")
List<Long> findHungInstanceIds(@Param("instanceIds") Collection<Long> instanceIds);
}
@@ -0,0 +1,104 @@
package com.yunhai.flowcenter.controller.api;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwRouteLogRepository;
import com.yunhai.flowcenter.service.FlowAdminGuard;
import com.yunhai.platform.utils.model.BasePageResult;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
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;
/**
* F11-4 台账「路由挂起」可观测单测:hung 筛选透传 + 行级 routeHung 红标三态
* (在途+挂起 / 在途未挂 / 已终态)。挂起判定 JPQL 口径(最大 attempt 行 outlet 仍 NULL)
* 在 {@link FlwRouteLogRepository#findHungInstanceIds} 与 pageAdmin 的 exists 子查询里,
* 语义断言由 160 人工验收(V 过滤)与真库回归兜住;本测锁定控制器编排与红标填充。
*/
class FlowAdminInstanceHungTest {
private final FlowAdminGuard adminGuard = mock(FlowAdminGuard.class);
private final FlwInstanceRepository instanceRepository = mock(FlwInstanceRepository.class);
private final FlwRouteLogRepository routeLogRepository = mock(FlwRouteLogRepository.class);
private final FlowAdminController controller = new FlowAdminController(
adminGuard,
mock(com.yunhai.flowcenter.service.FlowDefAdminService.class),
mock(com.yunhai.flowcenter.service.FlowMapAdminService.class),
mock(com.yunhai.flowcenter.service.FlowCallbackAdminService.class),
mock(com.yunhai.flowcenter.service.FlowRouterAdminService.class),
instanceRepository,
mock(com.yunhai.flowcenter.service.FlowDiagramService.class),
mock(com.yunhai.flowcenter.service.DiagramImageService.class),
mock(com.fasterxml.jackson.databind.ObjectMapper.class),
mock(com.yunhai.flowcenter.service.FlowAppAdminService.class),
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
routeLogRepository);
private static FlwInstance instance(long id, int status) {
FlwInstance i = new FlwInstance();
i.setId(id);
i.setStatus(status);
return i;
}
@Test
void hungFilterPassedThroughToRepository() {
when(adminGuard.requireAdmin(any(), any())).thenReturn(990001L);
when(instanceRepository.pageAdmin(eq(990001L), any(), any(), eq(Boolean.TRUE), any()))
.thenReturn(new PageImpl<>(Arrays.asList(instance(1, 10))));
when(routeLogRepository.findHungInstanceIds(any())).thenReturn(Arrays.asList());
controller.instancePage(new BoaUser(), null, null, Boolean.TRUE, 1, 10);
ArgumentCaptor<Boolean> hung = ArgumentCaptor.forClass(Boolean.class);
verify(instanceRepository).pageAdmin(eq(990001L), isNull(), isNull(), hung.capture(), any());
assertEquals(Boolean.TRUE, hung.getValue(), "hung=true 必须原样透传到分页查询");
}
@Test
void routeHungBadgeThreeStates() {
when(adminGuard.requireAdmin(any(), any())).thenReturn(990001L);
// 三行:1=在途且在挂起集合;2=在途但不在集合;3=已终态(集合必然不含)
when(instanceRepository.pageAdmin(eq(990001L), any(), any(), any(), any()))
.thenReturn(new PageImpl<>(Arrays.asList(instance(1, 10), instance(2, 10), instance(3, 20))));
when(routeLogRepository.findHungInstanceIds(any()))
.thenReturn(Arrays.asList(1L));
BasePageResult<FlwInstance> result = controller.instancePage(new BoaUser(), null, null, null, 1, 10);
List<FlwInstance> rows = result.getContent();
assertEquals(3, rows.size());
assertTrue(rows.get(0).getRouteHung(), "在途且最新 attempt 路由行无出口 → 红标 true");
assertFalse(rows.get(1).getRouteHung(), "在途但无挂起路由行 → false");
assertFalse(rows.get(2).getRouteHung(), "已终态办件不参与挂起判定 → false");
}
@Test
void emptyPageSkipsHungLookup() {
when(adminGuard.requireAdmin(any(), any())).thenReturn(990001L);
when(instanceRepository.pageAdmin(eq(990001L), any(), any(), any(), any()))
.thenReturn(new PageImpl<>(Arrays.asList()));
controller.instancePage(new BoaUser(), 10, null, null, 1, 10);
verify(routeLogRepository, never()).findHungInstanceIds(any());
}
}
@@ -493,7 +493,8 @@ class FlowDiagramServiceTest {
mock(com.yunhai.flowcenter.service.DiagramImageService.class),
new ObjectMapper(),
mock(com.yunhai.flowcenter.service.FlowAppAdminService.class),
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class));
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
mock(com.yunhai.flowcenter.repository.FlwRouteLogRepository.class));
}
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {