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)。
*
*
三种主体口径:
*
* - {@code DEPT}(新配置唯一口径):UserBase 新部门目录(SYSTEM/CUSTOM 类型化引用)
* 解析出候选人员,再由节点策略决定一人办理/会签/或签;
* - {@code USERS}:定义直配固定人员;同样支持会签/或签;
* - {@code DEPT_KEY}(历史只读):FL 本地 {@code flw_dept}/{@code flw_dept_member}。
* 仅为不破坏既有在途定义而保留读取,新配置不再写入、也不是静默兜底:
* {@code DEPT} 解析失败照抛,不回退本地科室表。
*
*
* 审批责任人统一走 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;
}
}