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 派生方法属另一实现线)
291 lines
15 KiB
Java
291 lines
15 KiB
Java
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;
|
||
import com.yunhai.platform.utils.model.BoaUser;
|
||
import lombok.RequiredArgsConstructor;
|
||
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;
|
||
|
||
/**
|
||
* 办理人解析(概览 §六 + 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
|
||
@RequiredArgsConstructor
|
||
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.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<>();
|
||
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
|
||
try {
|
||
requireEligibleAssignee(schId, m.getUserId());
|
||
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
|
||
} catch (BizException e) {
|
||
if (e.getErrorCode() == ErrorCode.INTERNAL_SERVER_ERROR) {
|
||
throw e; // 权威源不可达≠成员不合格,不能降级成“无人可用”
|
||
}
|
||
rejected.add(m.getUserId() + "=" + e.getMessage());
|
||
log.warn("[flow-center] 科室成员资格校验不通过,跳过:schId={} deptKey={} userId={} reason={}",
|
||
schId, a.deptKey, m.getUserId(), e.getMessage());
|
||
}
|
||
}
|
||
if (refs.isEmpty() && !rejected.isEmpty()) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"节点[" + node.id + "]无可用办理人:科室成员均不具备办理资格("
|
||
+ String.join(";", rejected) + ")");
|
||
}
|
||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||
if (a.userIds != null) {
|
||
for (Integer uid : a.userIds) {
|
||
refs.add(new AssigneeRef(uid, requireEligibleAssigneeName(schId, uid)));
|
||
}
|
||
}
|
||
} else {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "一期不支持办理人类型:" + a.type);
|
||
}
|
||
if (refs.isEmpty()) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"节点[" + 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} 兜底。
|
||
*/
|
||
public void requireEligibleFixedAssignees(Long schId, FlowDefModel model) {
|
||
for (FlowDefModel.Node node : model.nodes) {
|
||
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignee == null
|
||
|| !FlowDefModel.ASSIGNEE_USERS.equals(node.assignee.type)) {
|
||
continue;
|
||
}
|
||
try {
|
||
resolve(schId, node);
|
||
} catch (BizException e) {
|
||
throw new BizException(e.getErrorCode(),
|
||
"节点[" + node.id + "]固定审批人配置非法:" + e.getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义),
|
||
* 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
|
||
* 绝不能因抄送对象缺失卡住流程推进。D11 的资格终审只约束审批责任人,本方法刻意不校验
|
||
* 存在/启用/校域,直接复用既有姓名补全容错。
|
||
*/
|
||
public List<AssigneeRef> resolveTolerant(Long schId, FlowDefModel.Node node) {
|
||
FlowDefModel.Assignee a = node.receivers;
|
||
if (a == null || a.type == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少抄送对象配置:" + node.id);
|
||
}
|
||
List<AssigneeRef> refs = new ArrayList<>();
|
||
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()));
|
||
}
|
||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||
if (a.userIds != null) {
|
||
for (Integer uid : a.userIds) {
|
||
refs.add(new AssigneeRef(uid, fetchName(uid)));
|
||
}
|
||
}
|
||
} else {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "不支持抄送对象类型:" + a.type);
|
||
}
|
||
return refs;
|
||
}
|
||
|
||
private String fetchName(Integer userId) {
|
||
try {
|
||
BoaUser user = userClient.getUserById(String.valueOf(userId));
|
||
return user == null ? "" : (user.getName() == null ? "" : user.getName());
|
||
} catch (Exception e) {
|
||
log.warn("[flow-center] UserBase 用户名补全失败 userId={}:{}", userId, e.getMessage());
|
||
return "";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 审批责任人资格终审(D11 唯一权威入口):存在 + 启用(status=1,null fail-closed)
|
||
* + 属于当前学校。UserBase 不可达与“人不合格”分别报错,前者不可降级为过滤。
|
||
*/
|
||
public BoaUser requireEligibleAssignee(Long schId, Integer userId) {
|
||
BoaUser user = requireValidUser(userId);
|
||
Long userSchId = user.getSchool() == null || user.getSchool().getId() == null
|
||
? null : Long.valueOf(user.getSchool().getId());
|
||
if (schId == null || !schId.equals(userSchId)) {
|
||
log.warn("[flow-center] 办理人不属于本校,拒绝:userId={} userSchool={} schId={}",
|
||
userId, userSchId, schId);
|
||
throw new BizException(ErrorCode.FORBIDDEN,
|
||
"办理人不属于该学校,不能办理任务:" + userId
|
||
+ "(所属学校 " + (userSchId == null ? "未知" : userSchId)
|
||
+ ",要求 " + schId + ")");
|
||
}
|
||
return user;
|
||
}
|
||
|
||
/** 资格终审通过后返回姓名快照;姓名缺失保留空串(既有口径)。 */
|
||
public String requireEligibleAssigneeName(Long schId, Integer userId) {
|
||
BoaUser user = requireEligibleAssignee(schId, userId);
|
||
return user.getName() == null ? "" : user.getName();
|
||
}
|
||
|
||
/**
|
||
* 存在 + 启用子检查。只供本类资格终审复用;外部审批责任人必须走
|
||
* {@link #requireEligibleAssignee}(含校域),避免漏掉学校范围。
|
||
*/
|
||
private BoaUser requireValidUser(Integer userId) {
|
||
if (userId == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少办理人");
|
||
}
|
||
BoaUser user;
|
||
try {
|
||
user = userClient.getUserById(String.valueOf(userId));
|
||
} catch (Exception e) {
|
||
log.warn("[flow-center] 办理人资格校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
|
||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "办理人资格校验失败,请稍后重试:" + userId);
|
||
}
|
||
if (user == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "办理人不存在:" + userId);
|
||
}
|
||
if (!Integer.valueOf(1).equals(user.getStatus())) {
|
||
log.warn("[flow-center] 办理人为禁用/未知状态账号,拒绝:userId={} status={}", userId, user.getStatus());
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "办理人为禁用账号,不能办理任务:" + userId);
|
||
}
|
||
return user;
|
||
}
|
||
}
|