feat(fl2): 落地部门目录 / 退回重提(D14) / 授权管理后端(基线 origin/main)
build / build-push (push) Failing after 55s

在 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 派生方法属另一实现线)
This commit is contained in:
chenxing
2026-10-30 10:15:12 +08:00
parent b369527fe1
commit d72128672f
52 changed files with 3208 additions and 859 deletions
@@ -0,0 +1,423 @@
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;
}
}