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)。 * *

三种主体口径:

* * *

审批责任人统一走 D11 资格终审(存在+启用+校域);仅 NOTIFY 抄送接收人保留宽容口径 * (解析不出人只记日志继续走——抄送是知会不是审批)。

*/ @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 resolve(Long schId, FlowDefModel.Node node) { FlowDefModel.Assignee a = node.assignee; if (a == null) { throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少办理人配置:" + node.id); } List refs = new ArrayList<>(); if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) { // 部门是授权主体,候选人仍逐人过 D11 资格终审:不合格者剔除并留痕(部门里有停用账号 // 不该卡死整条流程),但权威源不可达(UNAUTHORIZED)照抛,不伪装成"无人可用"。 List 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 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:部门是审批授权主体,不是"前端筛选人员的临时集合")。 * *

一个用户可属多个部门,多部门/多引用解析后按用户去重,避免同一人收到两条待办。 * 目录不可用照抛(空候选不等于成功)。{@code tolerant=true}(抄送)时把"某部门查不到人" * 记日志跳过,但通道整体不可用仍然拒绝——不能把故障伪装成"没人需要知会"。

*/ private List 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 out = new ArrayList<>(); Set 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 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 resolveDepartmentReceivers(Long schId, FlowDefModel.Node node, FlowDefModel.Assignee a) { List 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 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 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; } }