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
@@ -2,6 +2,7 @@ package com.yunhai.flowcenter.engine;
import com.yunhai.flowcenter.entity.FlwDeptMember;
import com.yunhai.flowcenter.service.FlowDeptAdminService;
import com.yunhai.flowcenter.service.UserBaseDepartmentDirectory;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.exception.ErrorCode;
@@ -11,12 +12,25 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源,
* 零跨服务依赖);USERS 定义直配。审批责任人统一走 D11 资格终审(存在+启用+校域);
* 仅 NOTIFY 抄送接收人保留宽容解析(姓名补全失败降级空名、不校验资格)。
* 办理人解析(概览 §六 + C1/C2 收口,2026-10-29)。
*
* <p>三种主体口径:</p>
* <ul>
* <li>{@code DEPT}(<b>新配置唯一口径</b>):UserBase 新部门目录(SYSTEM/CUSTOM 类型化引用)
* 解析出候选人员,再由节点策略决定一人办理/会签/或签;</li>
* <li>{@code USERS}:定义直配固定人员;同样支持会签/或签;</li>
* <li>{@code DEPT_KEY}(历史只读):FL 本地 {@code flw_dept}/{@code flw_dept_member}。
* 仅为不破坏既有在途定义而保留读取,<b>新配置不再写入、也不是静默兜底</b>:
* {@code DEPT} 解析失败照抛,不回退本地科室表。</li>
* </ul>
*
* <p>审批责任人统一走 D11 资格终审(存在+启用+校域);仅 NOTIFY 抄送接收人保留宽容口径
* (解析不出人只记日志继续走——抄送是知会不是审批)。</p>
*/
@Slf4j
@Service
@@ -26,13 +40,48 @@ public class AssigneeResolver {
private final FlowDeptAdminService deptAdminService;
private final OpenApiUserClient userClient;
/**
* UserBase 部门目录(字段注入:构造被 {@code AssigneeResolverEligibilityTest} 逐位钉住,
* 加构造参数会让整棵测试树编译失败——同 {@code OapiFlowService#usageGrantGuard} 先例)。
* 未装配时 {@code DEPT} 主体解析按 fail-closed 拒绝,不降级成本地科室。
*/
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setDepartmentDirectory(UserBaseDepartmentDirectory directory) {
this.departmentDirectory = directory;
}
private UserBaseDepartmentDirectory departmentDirectory;
public List<AssigneeRef> resolve(Long schId, FlowDefModel.Node node) {
FlowDefModel.Assignee a = node.assignee;
if (a == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少办理人配置:" + node.id);
}
List<AssigneeRef> refs = new ArrayList<>();
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) {
// 部门是授权主体,候选人仍逐人过 D11 资格终审:不合格者剔除并留痕(部门里有停用账号
// 不该卡死整条流程),但权威源不可达(UNAUTHORIZED)照抛,不伪装成"无人可用"。
List<String> ineligible = new ArrayList<>();
for (AssigneeRef ref : resolveDepartmentCandidates(schId, node, a, false)) {
try {
BoaUser user = requireEligibleAssignee(schId, ref.getUserId());
refs.add(new AssigneeRef(ref.getUserId(),
user.getName() == null ? ref.getUserName() : user.getName()));
} catch (BizException e) {
if (e.getErrorCode() == ErrorCode.UNAUTHORIZED) {
throw e;
}
ineligible.add(ref.getUserId() + "=" + e.getMessage());
log.warn("[flow-center] 部门成员不具备办理资格,跳过:schId={} node={} userId={} reason={}",
schId, node.id, ref.getUserId(), e.getMessage());
}
}
if (refs.isEmpty() && !ineligible.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"节点[" + node.id + "]无可用办理人:部门成员均不具备办理资格("
+ String.join(";", ineligible) + ")");
}
} else if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
// D11:科室启用 ≠ 成员都可办理——逐人走同一资格终审;不合格成员按既有
// “只取启用成员”口径剔除(local status 已在 resolveEnabled 过滤),并留日志。
List<String> rejected = new ArrayList<>();
@@ -65,11 +114,71 @@ public class AssigneeResolver {
}
if (refs.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"节点[" + node.id + "]无可用办理人(科室可能已被停用或成员为空)");
"节点[" + node.id + "]无可用办理人(部门/科室可能已被停用或成员为空)");
}
return refs;
}
/**
* UserBase 部门候选解析(C2:部门是审批授权主体,不是"前端筛选人员的临时集合")。
*
* <p>一个用户可属多个部门,多部门/多引用解析后<b>按用户去重</b>,避免同一人收到两条待办。
* 目录不可用照抛(空候选不等于成功)。{@code tolerant=true}(抄送)时把"某部门查不到人"
* 记日志跳过,但通道整体不可用仍然拒绝——不能把故障伪装成"没人需要知会"。</p>
*/
private List<AssigneeRef> resolveDepartmentCandidates(Long schId, FlowDefModel.Node node,
FlowDefModel.Assignee a, boolean tolerant) {
if (departmentDirectory == null) {
throw new BizException(ErrorCode.UNAUTHORIZED,
"UserBase 部门目录通道未装配,节点[" + node.id + "]不能解析部门办理人");
}
if (a.deptRefs == null || a.deptRefs.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "节点[" + node.id + "] DEPT 办理人缺少 deptRefs");
}
List<AssigneeRef> out = new ArrayList<>();
Set<Integer> seen = new LinkedHashSet<>();
for (FlowDefModel.DeptRef ref : a.deptRefs) {
if (ref == null || ref.departmentId == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"节点[" + node.id + "] 部门引用缺少 departmentType/departmentId");
}
List<BoaUser> members;
try {
members = departmentDirectory.membersAtSchool(schId, ref.departmentType, ref.departmentId,
UserBaseDepartmentDirectory.SCOPE_SELECT);
} catch (BizException e) {
if (tolerant && e.getErrorCode() != ErrorCode.UNAUTHORIZED) {
log.warn("[flow-center] 抄送部门解析失败(不阻塞推进):node={} ref={}:{}",
node.id, ref.directoryKey(), e.getMessage());
continue;
}
throw new BizException(e.getErrorCode(),
"节点[" + node.id + "] 部门 " + ref.directoryKey() + " 解析失败:" + e.getMessage());
}
if (members == null) {
continue;
}
for (BoaUser user : members) {
if (user == null || user.getId() == null || !seen.add(user.getId())) {
continue; // 多部门归属同一人:只保留一条
}
out.add(new AssigneeRef(user.getId(), user.getName() == null ? "" : user.getName()));
}
}
return out;
}
/** 抄送接收人用的部门解析(宽容口径,见 {@link #resolveDepartmentCandidates}) */
private List<AssigneeRef> resolveDepartmentReceivers(Long schId, FlowDefModel.Node node,
FlowDefModel.Assignee a) {
List<AssigneeRef> out = new ArrayList<>();
for (AssigneeRef ref : resolveDepartmentCandidates(schId, node, a, true)) {
out.add(new AssigneeRef(ref.getUserId(), ref.getUserName().isEmpty()
? fetchName(ref.getUserId()) : ref.getUserName()));
}
return out;
}
/**
* D11 固定 USERS 整体预检:流程启动前逐节点核验全部固定审批人,任何一人不存在/停用/跨校
* 即整体拒绝——不允许“过滤掉非法人后继续”。运行时建任务仍复用 {@link #resolve} 兜底。
@@ -101,7 +210,9 @@ public class AssigneeResolver {
throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少抄送对象配置:" + node.id);
}
List<AssigneeRef> refs = new ArrayList<>();
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) {
refs.addAll(resolveDepartmentReceivers(schId, node, a));
} else if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
}