Files
flow-center-service/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.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

291 lines
15 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.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;
}
}