release: FL 2.0 候选(C01 记账边界+P2-2 认领协议+D10 变量契约+D11 资格终审+D09 激活代次+T01/T03/T04/T05)

按《FL最终代码审计与上线阻断清单》194 项已审计工作树形成候选,基座 = origin/main 11e0778。

- 源码/测试/资源:沿用已审计工作树内容(96 项相对远端为新增或更新),含未跟踪实现
  CallbackFactStore、CallbackSourcePolicy、VariableContract、FormalApplyCommandService、
  FlowActorContextGate 等;C01 续验夹具修正一并入内。
- CI:保留远端 11e0778 的门禁强化(CI_SOURCE_SHA 核对、容器回收验证、空 schema 反向门禁、
  main ref 才推镜像);ci/flow-test-schema.sql 按当前实体重新生成(14→20 表,补 6 新表与
  6 新列,回调日志复合唯一键 uk_flw_log_event_reg),workflow 表数断言同步 14→20。
- 依据:docs/tasks/evidence/FL-final-audit-20261027(194 项哈希/版本核对)。
This commit is contained in:
chenxing
2026-10-27 12:05:08 +08:00
parent 11e077867b
commit 500a9a2fcb
99 changed files with 7973 additions and 633 deletions
@@ -15,7 +15,8 @@ import java.util.List;
/**
* 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源,
* 零跨服务依赖);USERS 定义直配,姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。
* 零跨服务依赖);USERS 定义直配。审批责任人统一走 D11 资格终审(存在+启用+校域);
* 仅 NOTIFY 抄送接收人保留宽容解析(姓名补全失败降级空名、不校验资格)。
*/
@Slf4j
@Service
@@ -32,13 +33,31 @@ public class AssigneeResolver {
}
List<AssigneeRef> refs = new ArrayList<>();
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)) {
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
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, fetchName(uid)));
refs.add(new AssigneeRef(uid, requireEligibleAssigneeName(schId, uid)));
}
}
} else {
@@ -51,10 +70,30 @@ public class AssigneeResolver {
return refs;
}
/**
* 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 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义),
* 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
* 绝不能因抄送对象缺失卡住流程推进。与 {@link #resolve} 的强口径相反。
* 绝不能因抄送对象缺失卡住流程推进。D11 的资格终审只约束审批责任人,本方法刻意不校验
* 存在/启用/校域,直接复用既有姓名补全容错。
*/
public List<AssigneeRef> resolveTolerant(Long schId, FlowDefModel.Node node) {
FlowDefModel.Assignee a = node.receivers;
@@ -89,24 +128,52 @@ public class AssigneeResolver {
}
/**
* 移交目标校验(严格口径,与 {@link #fetchName} 的降级容错相反):
* 查不到就必须拒绝——否则会建出一条谁在待办里都看不到的僵尸任务。
* UserBase 不可达与"人不存在"分别报错,便于排查。
* 审批责任人资格终审(D11 唯一权威入口):存在 + 启用(status=1,null fail-closed)
* + 属于当前学校。UserBase 不可达与“人不合格”分别报错,前者不可降级为过滤。
*/
public String requireUserName(Integer userId) {
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, "缺少移交对象");
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.PARAM_ERROR, "移交对象校验失败,请稍后重试:" + userId);
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);
throw new BizException(ErrorCode.PARAM_ERROR, "办理人不存在:" + userId);
}
return user.getName() == null ? "" : user.getName();
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;
}
}