Files
flow-center-service/src/test/java/com/yunhai/flowcenter/service/FlDepartmentSubjectTest.java
T
chenxing d72128672f
build / build-push (push) Failing after 55s
feat(fl2): 落地部门目录 / 退回重提(D14) / 授权管理后端(基线 origin/main)
在 b369527 之上增量移植 FL2.0 后端能力,不改既有线级停发/指派预览/流程图入口:

- 部门目录:FlowDepartmentController(/admin/department/{groups,options,resolve,members})
  + UserBaseDepartmentDirectory(先按会话校域解析引用,再读成员;目录不可用 fail-closed)
- 退回重提 D14:POST /oapi/flow/instance/{instanceId}/resubmit,仅原提交人 CAS 路径放行,
  OAPI 侧 submitterId 路径继续 fail-closed
- 授权管理:/api/flow/admin/usage-grant/{schoolList,summary}(校域取管理员会话,无 schId 入参)
- 来源主体:解析器派生 ub-app:{applicationInfo.id},不再读取 UserBase 的 sourcePrincipalId 字段
- 回调来源策略:SELF 身份强校验;NULL 来源不广播;复合幂等键 (event_id,reg_id)
- 测试夹具:许可/主体/开通桩按新契约修正,FlowEngineIT/FormalApplyCommandIT/
  CallbackDispatchIT/OapiFlowContractIT 共 117 例在真实 MySQL 8 通过

不含:FlSourceAuthorizationTest(其断言的两个 Repository 派生方法属另一实现线)
2026-10-30 10:15:12 +08:00

424 lines
19 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.AssigneeRef;
import com.yunhai.flowcenter.engine.AssigneeResolver;
import com.yunhai.flowcenter.engine.DefValidator;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiGroupClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.openapi.base.model.DepartmentEntry;
import com.yunhai.platform.openapi.base.model.DepartmentRef;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* UserBase 部门权威化与审批主体规则(工作包 C,2026-10-29)。
*
* <p>测试替身边界:{@link OpenApiGroupClient}/{@link OpenApiUserClient} 按 UserBase
* {@code /oapi/departments/**} 的真实契约打桩(响应形状=SDK 的 DepartmentEntry/BoaUser),
* <b>这不是跨服务联调</b>——真实 UserBase 可达性、SYSTEM/CUSTOM 数据齐备性由后续 WorkBuddy 验证。</p>
*
* <p>钉住的红线:</p>
* <ul>
* <li>部门是<b>审批授权主体</b>,不是"前端筛出来的一堆人";解析出的候选仍逐人过 D11 资格终审;</li>
* <li>一个用户可属多个部门 → 多部门引用按用户去重,只发一条待办;</li>
* <li>目录不可用一律抛错,<b>不静默退回 flw_dept</b>,也不把空候选当成功;</li>
* <li>展示口径(显示人员/显示部门)与审批主体分开配置并独立生效。</li>
* </ul>
*/
@DisplayName("UserBase 部门主体解析 + 展示规则(C1/C2/C3)")
class FlDepartmentSubjectTest {
@Test void foreignSchoolReferenceCannotExposeMembers() {
assertThatThrownBy(() -> directory.resolve(SCH,
Arrays.asList(new DepartmentRef((int) SCH + 1, SYSTEM, 42))))
.isInstanceOf(BizException.class).hasMessageContaining("学校");
DepartmentEntry foreign = entry(SYSTEM, 42, true, false);
foreign.setSchoolId((int) SCH + 1); foreign.setName("foreign");
when(groupClient.resolveDepartments(any())).thenReturn(Arrays.asList(foreign));
assertThatThrownBy(() -> directory.membersAtSchool(SCH, SYSTEM, 42, "select"))
.isInstanceOf(BizException.class).hasMessageContaining("学校");
org.mockito.Mockito.verify(groupClient, org.mockito.Mockito.never()).getDepartmentMembers(any(), any(), any());
}
private static final long SCH = 5L;
private static final String SYSTEM = "SYSTEM";
private static final String CUSTOM = "CUSTOM";
private OpenApiGroupClient groupClient;
private OpenApiUserClient userClient;
private UserBaseDepartmentDirectory directory;
private AssigneeResolver resolver;
private final ObjectMapper mapper = new ObjectMapper();
@BeforeEach
void setUp() {
groupClient = mock(OpenApiGroupClient.class);
userClient = mock(OpenApiUserClient.class);
directory = new UserBaseDepartmentDirectory(groupClient);
when(groupClient.resolveDepartments(any())).thenAnswer(call -> {
java.util.List<DepartmentRef> refs = call.getArgument(0);
if (refs == null) return java.util.Collections.emptyList();
java.util.List<DepartmentEntry> rows = new java.util.ArrayList<>();
for (DepartmentRef ref : refs) {
DepartmentEntry e = entry(ref.getDepartmentType(), ref.getDepartmentId(), true, false);
e.setSchoolId(ref.getSchoolId()); e.setName("department"); rows.add(e);
}
return rows;
});
resolver = new AssigneeResolver(mock(FlowDeptAdminService.class), userClient);
resolver.setDepartmentDirectory(directory);
givenEligibleUsers(101, 102, 103, 104);
}
// ==================== C1 权威目录接入 ====================
@Test
@DisplayName("DEPT 主体从 UserBase 目录解析候选人(scope=select 严档,不读 flw_dept_member)")
void departmentSubjectResolvesFromUserBase() {
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select"))
.thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师")));
List<AssigneeRef> refs = resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42)));
assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101, 102);
verify(groupClient).getDepartmentMembers(SYSTEM, 42, "select");
}
@Test
@DisplayName("同一用户归属多个被选部门:只产出一条待办(多组归属不是模型禁忌)")
void overlappingMembershipDeduplicatesUser() {
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select"))
.thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师")));
when(groupClient.getDepartmentMembers(CUSTOM, 7, "select"))
.thenReturn(Arrays.asList(user(102, "李老师"), user(103, "王老师")));
FlowDefModel.Assignee assignee = new FlowDefModel.Assignee();
assignee.type = FlowDefModel.ASSIGNEE_DEPT;
assignee.deptRefs = new ArrayList<>(Arrays.asList(deptRef(SYSTEM, 42), deptRef(CUSTOM, 7)));
List<AssigneeRef> refs = resolver.resolve(SCH, approvalNode(assignee));
assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101, 102, 103);
}
@Test
@DisplayName("目录不可用:拒绝并说明原因,绝不退回 FL 本地科室(双权威 fallback 已禁)")
void directoryOutageFailsClosed() {
when(groupClient.getDepartmentMembers(anyString(), anyInt(), anyString()))
.thenThrow(new RuntimeException("userbase unreachable"));
FlowDefModel.Node node = approvalNode(deptAssignee(SYSTEM, 42));
assertThatThrownBy(() -> resolver.resolve(SCH, node))
.isInstanceOf(BizException.class)
.hasMessageContaining("不可用");
// 未装配目录通道同样拒绝,而不是回读本地表
AssigneeResolver unwired = new AssigneeResolver(mock(FlowDeptAdminService.class), userClient);
assertThatThrownBy(() -> unwired.resolve(SCH, node))
.isInstanceOf(BizException.class)
.hasMessageContaining("未装配");
}
@Test
@DisplayName("部门解析出 0 人:按「无可用办理人」拒绝,不把空候选当成功")
void emptyDepartmentIsRejected() {
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")).thenReturn(new ArrayList<>());
assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42))))
.isInstanceOf(BizException.class)
.hasMessageContaining("无可用办理人");
}
@Test
@DisplayName("部门里全是禁用/跨校账号:整体拒绝并点名原因(存在≠有资格,D11)")
void ineligibleMembersAreFilteredAndEmptyIsRejected() {
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select"))
.thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师")));
when(userClient.getUserById("101")).thenReturn(disabled(101));
when(userClient.getUserById("102")).thenReturn(otherSchool(102));
assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42))))
.isInstanceOf(BizException.class)
.hasMessageContaining("不具备办理资格");
}
@Test
@DisplayName("部分成员不合格:剔除不合格者继续,不因个别人卡死整条流程")
void partiallyIneligibleKeepsEligibleOnes() {
when(groupClient.getDepartmentMembers(SYSTEM, 42, "select"))
.thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师")));
when(userClient.getUserById("102")).thenReturn(disabled(102));
List<AssigneeRef> refs = resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42)));
assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101);
}
@Test
@DisplayName("部门引用缺类型消歧位:拒绝(同值 id 在 SYSTEM/CUSTOM 不等价)")
void untypedReferenceRejected() {
FlowDefModel.Assignee assignee = new FlowDefModel.Assignee();
assignee.type = FlowDefModel.ASSIGNEE_DEPT;
FlowDefModel.DeptRef bad = new FlowDefModel.DeptRef();
bad.departmentId = 42;
assignee.deptRefs = new ArrayList<>(Arrays.asList(bad));
assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(assignee)))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> directory.members("OTHER", 42, "select"))
.isInstanceOf(BizException.class)
.hasMessageContaining("SYSTEM/CUSTOM");
}
@Test
@DisplayName("历史口径与选择口径分开:resolve-batch 取停用部门名称,select 不返回停用项")
void historyAndSelectionScopesAreSeparate() {
when(groupClient.getDepartmentMembers(CUSTOM, 9, "history"))
.thenReturn(Arrays.asList(user(103, "王老师")));
assertThat(directory.members(CUSTOM, 9, "history"))
.extracting(BoaUser::getId).containsExactly(103);
DepartmentEntry deleted = new DepartmentEntry();
deleted.setDepartmentType(CUSTOM);
deleted.setDepartmentId(9);
deleted.setName("已撤销的课题组");
deleted.setSelectable(false);
deleted.setDeleted(true);
deleted.setSchoolId((int) SCH);
when(groupClient.resolveDepartments(any())).thenReturn(Arrays.asList(deleted));
List<DepartmentEntry> resolved = directory.resolve(SCH,
Arrays.asList(new DepartmentRef(null, CUSTOM, 9)));
assertThat(resolved).hasSize(1);
assertThat(resolved.get(0).getName()).isEqualTo("已撤销的课题组");
assertThat(resolved.get(0).getSelectable()).isFalse();
// 学校范围由服务端会话派生并补进引用,不接受前端传别校 id
assertThat(resolved.get(0).getSchoolId()).isEqualTo((int) SCH);
ArgumentCaptor<List<DepartmentRef>> sent = ArgumentCaptor.forClass(List.class);
verify(groupClient).resolveDepartments(sent.capture());
assertThat(sent.getValue().get(0).getSchoolId()).as("校域由服务端会话派生并补进引用").isEqualTo((int) SCH);
}
@Test
@DisplayName("发布校验白名单来自目录 selectable 项,停用部门不可被新选中")
void selectableKeysOnlyCatalogSelectable() {
DepartmentEntry live = entry(SYSTEM, 42, true, false);
DepartmentEntry stopped = entry(SYSTEM, 43, false, false);
DepartmentEntry softDeleted = entry(CUSTOM, 9, true, true);
when(groupClient.getDepartments(eq((int) SCH), any(), any(), any()))
.thenReturn(Arrays.asList(live, stopped, softDeleted));
assertThat(directory.selectableKeys(SCH)).containsExactly("SYSTEM:42");
}
@Test
@DisplayName("空引用批量解析本地短路,不出站也不报错")
void emptyResolveBatchShortCircuits() {
assertThat(directory.resolve(SCH, new ArrayList<>())).isEmpty();
verify(groupClient, never()).resolveDepartments(any());
}
// ==================== C3 展示规则 ====================
@Test
@DisplayName("显示部门:任务节点名取独立展示名;未配展示字段=沿用节点名(历史定义零变化)")
void displayLabelDrivesTaskNodeName() throws Exception {
FlowDefModel model = FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}");
FlowDefModel.Node legacy = approvalNode(deptAssignee(SYSTEM, 42));
assertThat(legacy.displayName()).isEqualTo("二级审批");
legacy.displayMode = FlowDefModel.DISPLAY_MODE_DEPT;
legacy.displayLabel = "校长室";
assertThat(legacy.displayName()).isEqualTo("校长室");
// 按人员展示时不受 displayLabel 影响(只展示本节点自己配置/解析到的人)
legacy.displayMode = FlowDefModel.DISPLAY_MODE_USER;
assertThat(legacy.displayName()).isEqualTo("二级审批");
assertThat(model).isNotNull();
}
@Test
@DisplayName("发布校验:显示部门必须填展示名;未知 displayMode 取值拒绝")
void publishRejectsMissingDisplayLabel() {
FlowDefModel model = twoLevelModel(deptAssignee(SYSTEM, 42));
model.nodes.get(1).displayMode = FlowDefModel.DISPLAY_MODE_DEPT;
assertThatThrownBy(() -> DefValidator.validate(model, null, null,
new java.util.HashSet<>(Arrays.asList("SYSTEM:42"))))
.isInstanceOf(BizException.class)
.hasMessageContaining("展示名称");
model.nodes.get(1).displayMode = "BOTH";
model.nodes.get(1).displayLabel = "校长室";
assertThatThrownBy(() -> DefValidator.validate(model, null, null,
new java.util.HashSet<>(Arrays.asList("SYSTEM:42"))))
.isInstanceOf(BizException.class)
.hasMessageContaining("displayMode");
}
@Test
@DisplayName("发布校验:部门不在本校可选目录内(跨校/停用/已删)即拦;引用齐备则放行")
void publishChecksDepartmentAgainstDirectoryWhitelist() {
FlowDefModel model = twoLevelModel(deptAssignee(SYSTEM, 42));
DefValidator.validate(model, null, null, new java.util.HashSet<>(Arrays.asList("SYSTEM:42")));
assertThatThrownBy(() -> DefValidator.validate(model, null, null,
new java.util.HashSet<>(Arrays.asList("SYSTEM:99"))))
.isInstanceOf(BizException.class)
.hasMessageContaining("不在本校可选部门目录内");
}
@Test
@DisplayName("会签/或签对部门主体与人员主体同样可配(D07/D08 主体定候选,策略定完成条件)")
void countersignConfigurableForBothSubjects() {
Set<String> whitelist = new java.util.HashSet<>(Arrays.asList("SYSTEM:42"));
// 部门主体 + 或签:合法(会签/或签不是只对部门生效)
FlowDefModel deptModel = twoLevelModel(deptAssignee(SYSTEM, 42));
deptModel.nodes.get(2).countersign = FlowDefModel.COUNTERSIGN_ANY;
DefValidator.validate(deptModel, null, null, whitelist);
assertThat(deptModel.nodes.get(2).countersign).isEqualTo(FlowDefModel.COUNTERSIGN_ANY);
// 人员主体 + 或签:同样合法
FlowDefModel usersModel = twoLevelModel(usersAssignee(101, 102));
usersModel.nodes.get(2).countersign = FlowDefModel.COUNTERSIGN_ANY;
DefValidator.validate(usersModel, null, null, whitelist);
// 非法策略值:两类主体都拦
deptModel.nodes.get(2).countersign = "MOST";
assertThatThrownBy(() -> DefValidator.validate(deptModel, null, null, whitelist))
.isInstanceOf(BizException.class)
.hasMessageContaining("countersign");
}
// ==================== fixtures ====================
private void givenEligibleUsers(int... ids) {
for (int id : ids) {
when(userClient.getUserById(String.valueOf(id))).thenReturn(sameSchool(id, "教师" + id));
}
}
private FlowDefModel.Node approvalNode(FlowDefModel.Assignee assignee) {
FlowDefModel.Node node = new FlowDefModel.Node();
node.id = "n2";
node.type = FlowDefModel.TYPE_APPROVAL;
node.name = "二级审批";
node.assignee = assignee;
return node;
}
private FlowDefModel twoLevelModel(FlowDefModel.Assignee assignee) {
FlowDefModel model = FlowDefModel.parse(mapper, "{\"start\":\"start\",\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"一级\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[101]}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],\"edges\":["
+ "{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}");
FlowDefModel.Node deptNode = new FlowDefModel.Node();
deptNode.id = "n2";
deptNode.type = FlowDefModel.TYPE_APPROVAL;
deptNode.name = "二级审批";
deptNode.assignee = assignee;
model.nodes.add(2, deptNode);
model.edges.add(edge("n1", "n2"));
model.edges.add(edge("n2", "end"));
// 一级→end 的原边由 n1→n2 取代
model.edges.removeIf(e -> "n1".equals(e.from) && "end".equals(e.to));
return model;
}
private FlowDefModel.Edge edge(String from, String to) {
FlowDefModel.Edge e = new FlowDefModel.Edge();
e.from = from;
e.to = to;
return e;
}
private FlowDefModel.Assignee deptAssignee(String type, int id) {
FlowDefModel.Assignee assignee = new FlowDefModel.Assignee();
assignee.type = FlowDefModel.ASSIGNEE_DEPT;
assignee.deptRefs = new ArrayList<>(Arrays.asList(deptRef(type, id)));
return assignee;
}
private FlowDefModel.Assignee usersAssignee(int... ids) {
FlowDefModel.Assignee assignee = new FlowDefModel.Assignee();
assignee.type = FlowDefModel.ASSIGNEE_USERS;
List<Integer> list = new ArrayList<>();
for (int id : ids) {
list.add(id);
}
assignee.userIds = list;
return assignee;
}
private FlowDefModel.DeptRef deptRef(String type, int id) {
FlowDefModel.DeptRef ref = new FlowDefModel.DeptRef();
ref.departmentType = type;
ref.departmentId = id;
return ref;
}
private DepartmentEntry entry(String type, int id, boolean enabled, boolean deleted) {
DepartmentEntry e = new DepartmentEntry();
e.setSchoolId((int) SCH);
e.setDepartmentType(type);
e.setDepartmentId(id);
e.setName(type + "-" + id);
e.setEnabled(enabled);
e.setDeleted(deleted);
e.setSelectable(enabled && !deleted);
return e;
}
private BoaUser user(int id, String name) {
return sameSchool(id, name);
}
private BoaUser sameSchool(int id, String name) {
BoaUser u = new BoaUser();
u.setId(id);
u.setName(name);
u.setStatus(1);
com.yunhai.platform.utils.model.BoaGroup school = new com.yunhai.platform.utils.model.BoaGroup();
school.setId((int) SCH);
u.setSchool(school);
return u;
}
private BoaUser disabled(int id) {
BoaUser u = sameSchool(id, "停用账号");
u.setStatus(0);
return u;
}
private BoaUser otherSchool(int id) {
BoaUser u = sameSchool(id, "他校账号");
u.getSchool().setId((int) SCH + 1);
return u;
}
}