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
@@ -106,15 +106,114 @@ public class OapiFlowService {
this.transactionTemplate = template;
}
/**
* T01 应用使用许可闸(2026-10-25):新发起必须持有 (school, flowGuid, appId) 生效许可。
* setter 注入(构造被单测逐位钉住,同 transactionTemplate 先例);未装配(老单测直接 new)
* 视为关闭,生产装配后由 {@code yh.flow.school-activation-enforce} 控制生效。
* enforce 开启时 UserBase 不可达 fail-closed(见 {@link FlowUsageGrantGuard})。
*/
private FlowUsageGrantGuard usageGrantGuard;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setUsageGrantGuard(FlowUsageGrantGuard guard) {
this.usageGrantGuard = guard;
}
/**
* 发起期变量终审(字段注入:保持既有构造签名)。手工构造服务时也使用同一校验器,
* 所有拒绝均发生在实例创建之前。
*/
private ApplyVariableValidator applyVariableValidator;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setApplyVariableValidator(ApplyVariableValidator validator) {
this.applyVariableValidator = validator;
}
/** R1 SDK 快捷审批门禁(字段注入同上):门禁无条件 fail-closed,装配与否均不放行 */
private FlowActorContextGate actorContextGate;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setActorContextGate(FlowActorContextGate gate) {
this.actorContextGate = gate;
}
private void requireExpressionVariablesPresent(FlwDef def, String formDataJson) {
ApplyVariableValidator validator = applyVariableValidator == null
? new ApplyVariableValidator(objectMapper) : applyVariableValidator;
validator.requireVariablesPresent(
FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
}
private FlowInstanceService.GuardedOutcome inTransaction(
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
}
@Value("${flow-center.formal-command.enabled:false}")
private boolean formalCommandEnabled;
@org.springframework.beans.factory.annotation.Autowired
private FlowSourcePrincipalResolver sourcePrincipalResolver;
@org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwApplyCommandRepository formalCommands;
@org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwUsageGrantRepository formalGrants;
private void requireLegacyWriter() {
if (formalCommandEnabled) throw new BizException(ErrorCode.FORBIDDEN,
"正式命令模式已启用,请迁移到 /oapi/flow/commands;旧发起接口已关闭");
}
/** New-command validation only. Never call this while recovering a settled receipt. */
FlowInstanceService.ApplyCommand prepareFormal(Long schId, FlowSourcePrincipalResolver.Principal source,
com.yunhai.flowcenter.model.FormalApplyModels.Request body) {
BoaUser user;
try { user = userClient.getUserById(String.valueOf(body.getSubmitterId())); }
catch (RuntimeException unavailable) { throw new IllegalStateException("提交人权威查询不可用", unavailable); }
if (user == null || !Integer.valueOf(1).equals(user.getStatus()) || user.getSchool() == null || user.getSchool().getId() == null
|| !schId.equals(Long.valueOf(user.getSchool().getId()))) {
throw new BizException(ErrorCode.FORBIDDEN, "提交人不存在或不属于该学校");
}
if (body.getAssigns() != null && !body.getAssigns().isEmpty() && body.getExpectedDefVersion() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "正式指派须携带预览定义版本锚");
}
FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程版本已改变,请刷新后使用新的请求 ID");
}
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
// D11:固定 USERS 属审批责任人,正式命令在受理阶段整体终审——任一非法即 VALIDATION_REJECTED,
// 不落实例、不做“过滤后继续”。运行期建任务仍由 FlowEngine/AssigneeResolver 再兜底。
assigneeResolver.requireEligibleFixedAssignees(schId, FlowDefModel.parse(objectMapper, def.getContentJson()));
String assigns = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
return new FlowInstanceService.ApplyCommand(schId, def, body.getBizType(), body.getBizId(),
body.getBizTitle(), body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()),
source.appKey, source.appCode, assigns);
}
private boolean canReadFormalDefinition(Long school, String flow) {
String source = FlowOapiSourceScope.current();
return formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, source, 1)
|| formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(school, source, flow, "ACCEPTED");
}
private void requireFormalPreviewGrant(Long school, String flow) {
if (!formalCommandEnabled) return;
FlowSourcePrincipalResolver.Principal principal = sourcePrincipalResolver.resolve(
(String)((org.springframework.web.context.request.ServletRequestAttributes)org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes()).getRequest().getAttribute("yh.app.auth"));
if (!formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, principal.id, 1)
|| !formalActivation.isAppActivatedStrict(principal.appId, school)) {
throw new BizException(ErrorCode.FORBIDDEN, "没有此流程线的发起预览权限");
}
}
@org.springframework.beans.factory.annotation.Autowired private UserBaseActivationClient formalActivation;
/** 已发布流程列表(每条流程线取最高已发布版本) */
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
for (FlwDef def : defRepository.findPublishedLatestPerGuid(schId)) {
if (formalCommandEnabled && !canReadFormalDefinition(schId, def.getFlowGuid())) continue;
out.add(toSummary(def));
}
return out;
@@ -122,6 +221,7 @@ public class OapiFlowService {
/** 流程详情回显(未发布/不存在返回 null,消费方按"未绑定流程"降级) */
public OapiModels.FlowSummaryVo getFlow(Long schId, String flowGuid) {
if (formalCommandEnabled && !canReadFormalDefinition(schId, flowGuid)) return null;
return defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
.map(this::toSummary).orElse(null);
@@ -131,16 +231,20 @@ public class OapiFlowService {
* 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */
@Transactional
public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) {
requireLegacyWriter();
if (schId == null || body.getFlowGuid() == null || body.getBizType() == null
|| body.getBizId() == null || body.getSubmitterId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
}
requireSchoolMember(schId, body.getSubmitterId());
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + body.getFlowGuid()));
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
// T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报,
// 必须持有效使用许可。恢复感知的分支判定与 FlowInstanceService.apply 保持同键同序。
requireUsageGrantLegacyApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(), body.getBizId());
FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
// T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
// S2.7 版本竞态(可选、opt-in):调用方回带预览时的定义版本,改版即人类可读拒绝,
// 免得选好的 nodeId/候选人在新版本里对不上而报「指派节点不存在」。不传=现状逐字不变。
if (body.getExpectedDefVersion() != null
@@ -150,7 +254,6 @@ public class OapiFlowService {
+ ",当前已发布版本 " + def.getVersion() + ")");
}
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
@@ -184,23 +287,40 @@ public class OapiFlowService {
*/
public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey,
FlowApplyRetryModels.ApplyRequestV2 body) {
requireLegacyWriter();
if (schId == null || body == null || body.getFlowGuid() == null || body.getBizType() == null
|| body.getBizId() == null || body.getSubmitterId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
}
requireSchoolMember(schId, body.getSubmitterId());
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + body.getFlowGuid()));
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
// T01 使用许可(v2 路径):恢复感知——SAME_REQUEST/REPLAY_ONLY/IDEMPOTENT/STALE_ROUND
// 四类回执分支不拦(已受理命令的回执不因许可撤销丢失);CREATED/RESTARTED(新申报/新轮)
// 必须持有效使用许可。分支判定与 FlowInstanceService.applyGuarded 同键同序;
// 完整命令协议(requestId/指纹/服务端轮次)统一归 T04 重排,此处不重构。
requireUsageGrantGuardedApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(),
body.getBizId(), body.getRequestToken(), body.getAttempt(),
Boolean.TRUE.equals(body.getRetryOnly()));
FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId(
schId, body.getBizType(), body.getBizId()).orElse(null);
boolean replay = existing != null && body.getRequestToken() != null
&& !body.getRequestToken().trim().isEmpty()
&& body.getRequestToken().trim().equals(existing.getApplyRequestToken());
// 同 token 用已受理版本构造原指纹;归属和异载荷冲突仍由 applyGuarded 原守卫裁决。
FlwDef def = replay ? defRepository.findById(existing.getDefId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "原请求的定义版本不存在"))
: requirePublishedDefForApply(schId, body.getFlowGuid());
if (replay && !body.getFlowGuid().equals(def.getFlowGuid())) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件绑定的流程线不同");
}
if (!replay) requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
if (!replay && body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
+ ",当前已发布版本 " + def.getVersion() + ")");
}
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
@@ -208,8 +328,11 @@ public class OapiFlowService {
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
// T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝
cmd.payloadHash = payloadHash(cmd);
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()));
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()),
body.getExpectedPrevRound());
FlowInstanceService.GuardedOutcome outcome;
try {
@@ -218,6 +341,7 @@ public class OapiFlowService {
// 事务边界外重读:另一个并发请求已用同一业务键建好办件
return fromConcurrent(schId, cmd, policy);
}
requireNotConflict(outcome);
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(outcome.instanceId);
vo.setDuplicated(outcome.duplicated);
@@ -235,6 +359,137 @@ public class OapiFlowService {
return vo;
}
/**
* 新发起的当前有效发布版解析(2026-10-25 T03 收口):最高已发布版 + 线级停发守卫——
* 整线停发(line_offline=1)即拒绝新发起,且不回退到更旧的已发布版本(停发不是降级)。
* 在途实例不受影响(推进按实例固定 defId,不经本方法)。
*/
private FlwDef requirePublishedDefForApply(Long schId, String flowGuid) {
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + flowGuid));
if (def.getLineOffline() != null && def.getLineOffline() == 1) {
throw new BizException(ErrorCode.PARAM_ERROR,
"流程线已停发,暂停新发起(在途办件不受影响):" + flowGuid);
}
return def;
}
/**
* T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠
* 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
* 重启即新一轮申报。guard 未装配或未 enforce 时零行为(灰度开关,见 {@link FlowUsageGrantGuard})。
*/
private void requireUsageGrantLegacyApply(Long schId, String flowGuid, String appKey,
String bizType, String bizId) {
if (usageGrantGuard == null || !usageGrantGuard.isEnforce() || appKey == null) {
return;
}
FlwInstance existing = instanceRepository
.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId).orElse(null);
if (!legacyApplyIsReceiptOnly(existing)) {
usageGrantGuard.requireNewApplyGrant(schId, flowGuid, appKey);
}
}
/**
* T01 使用许可闸·v2 路径:与 {@code FlowInstanceService.applyGuarded/settleExisting}
* 同键同序的分支预判(判定逻辑见 {@link #guardedApplyIsReceiptOnly});
* 新申报(CREATED/RESTARTED)必须过 {@link FlowUsageGrantGuard#requireNewApplyGrant}。
* 完整命令协议统一归 T04。
*/
private void requireUsageGrantGuardedApply(Long schId, String flowGuid, String appKey,
String bizType, String bizId,
String requestToken, Integer attempt, boolean retryOnly) {
if (usageGrantGuard == null || !usageGrantGuard.isEnforce() || appKey == null) {
return;
}
FlwInstance existing = instanceRepository
.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId).orElse(null);
if (!guardedApplyIsReceiptOnly(existing, requestToken, attempt, retryOnly)) {
usageGrantGuard.requireNewApplyGrant(schId, flowGuid, appKey);
}
}
/**
* v1 回执判定:true=该请求只会命中幂等回读(不建件不重启),false=会触发新申报或 R4 重启。
* 与 {@code FlowInstanceService.apply(ApplyCommand)} 的分支一一对应(包私有便于规则单测钉住)。
*/
static boolean legacyApplyIsReceiptOnly(FlwInstance existing) {
return existing != null
&& (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode());
}
/**
* v2 回执判定:与 {@code applyGuarded/settleExisting} 同键同序——
* SAME_REQUEST(同 token)→ REPLAY_ONLY(retryOnly)→ IDEMPOTENT(在途/通过)→
* STALE_ROUND(请求轮次≤行轮次,缺省按 1)四类回执返回 true;CREATED/RESTARTED 返回 false。
*/
static boolean guardedApplyIsReceiptOnly(FlwInstance existing, String requestToken,
Integer attempt, boolean retryOnly) {
if (existing == null) {
return false;
}
String token = requestToken == null ? null : requestToken.trim();
if (token != null && !token.isEmpty() && token.equals(existing.getApplyRequestToken())) {
return true;
}
if (retryOnly) {
return true;
}
if (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
return true;
}
int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
int incomingRound = attempt == null ? 1 : attempt;
return incomingRound <= currentRound;
}
/**
* T04:CONFLICT 分支统一翻译成业务异常——冲突不是成功,调用方(SH 恢复链)收到 4xx
* 走 UNKNOWN → 按来源+token 复核,不得把他人/其他轮次的在途件当自己的受理结果。
*/
private static void requireNotConflict(FlowInstanceService.GuardedOutcome outcome) {
if (outcome != null && FlowApplyRetryModels.Branch.CONFLICT.equals(outcome.branch)) {
throw new BizException(ErrorCode.CUSTOM,
"该业务键已有在途/终态申报且请求标识不符(另一命令或另一轮次),"
+ "请按实例查询恢复,禁止跨命令认领;终态后重新申报请走显式重提");
}
}
/**
* 受理载荷指纹(T04):SHA-256(流序规范化) over flowGuid|bizType|bizId|submitterId|bizTitle|
* bizUrl|bizSnapshot|formData|assignJson。重发链载荷逐字节同源 → 同指纹;
* 任何业务字段改动 → 不同指纹 → 冲突。expectedDefVersion 刻意不参与(重试期间发布新版本
* 不应把同一次命令误判成两份)。
*/
private String payloadHash(FlowInstanceService.ApplyCommand cmd) {
String canon = String.join("|",
nullSafe(cmd.def == null ? null : cmd.def.getFlowGuid()),
nullSafe(cmd.bizType), nullSafe(cmd.bizId),
cmd.submitterId == null ? "" : String.valueOf(cmd.submitterId),
nullSafe(cmd.bizTitle), nullSafe(cmd.bizUrl),
nullSafe(cmd.bizSnapshot), nullSafe(cmd.formDataJson), nullSafe(cmd.assignJson));
try {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(canon.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
private static String nullSafe(String v) {
return v == null ? "" : v;
}
/**
* 并发撞键后的收口:重读既有实例 → <b>先核归属</b> → 同 token 记 {@code SAME_REQUEST},
* 否则按幂等命中回读。归属不符就拒绝,绝不返回别人的办件 id。
@@ -263,8 +518,16 @@ public class OapiFlowService {
vo.setApplyRound(existing.getApplyRound());
vo.setStatus(existing.getStatus());
vo.setVersion(existing.getVersion());
vo.setBranch(token != null && token.equals(existing.getApplyRequestToken())
? FlowApplyRetryModels.Branch.SAME_REQUEST : FlowApplyRetryModels.Branch.CONCURRENT_CREATED);
if (token != null && !token.equals(existing.getApplyRequestToken())) {
// T04:并发撞键且 token 不符=两份不同命令抢同一业务键——后到者冲突,不伪装幂等成功
//(旧 CONCURRENT_CREATED 分支只保留给无 token 的存量调用方)。
log.warn("[flow-center] 并发建件 token 不符(冲突拒绝):instance={} biz={}/{}",
existing.getId(), cmd.bizType, cmd.bizId);
throw new BizException(ErrorCode.CUSTOM,
"该业务键已被另一并发命令创建(请求标识不符),请按实例查询恢复,不要换 ID 重发");
}
vo.setBranch(token != null ? FlowApplyRetryModels.Branch.SAME_REQUEST
: FlowApplyRetryModels.Branch.CONCURRENT_CREATED);
log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}",
existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch());
return vo;
@@ -298,12 +561,45 @@ public class OapiFlowService {
* apply 时指派严格校验(方案 §5.2 五条,不变量 I7)+ 快照渲染。
* assigns 空 → null(宽松回退 D3)。任何不过 → BizException(PARAM_ERROR) 拒绝发起。
*/
/**
* T06 required 覆盖判定(包私有纯函数,规则单测钉住):定义里全部开启且必选的指派节点中,
* 提交项未覆盖的 nodeId 列表。空 assigns + 存在必选节点 ⇒ 全部缺失(空 assigns 不绕过)。
*/
static List<String> missingRequiredAssignNodes(FlowDefModel model, List<OapiModels.AssignItem> assigns) {
java.util.Set<String> submitted = new java.util.HashSet<>();
if (assigns != null) {
for (OapiModels.AssignItem item : assigns) {
if (item.getNodeId() != null) {
submitted.add(item.getNodeId());
}
}
}
List<String> missing = new ArrayList<>();
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !FlowDefModel.TYPE_APPROVAL.equals(n.type) || n.assignPolicy == null
|| !Boolean.TRUE.equals(n.assignPolicy.required)) {
continue;
}
if (!submitted.contains(n.id)) {
missing.add(n.id);
}
}
return missing;
}
private String renderAssignJson(Long schId, FlwDef def, List<OapiModels.AssignItem> assigns,
Integer submitterId) {
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
// T06(2026-10-25)required 覆盖终审:遍历<b>定义里全部</b>开启且必选的指派节点——
// 不能只遍历请求里出现的项,更不能让空 assigns 绕过 required 检查(审计 §7.4 点名)。
List<String> missing = missingRequiredAssignNodes(model, assigns);
if (!missing.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"必选指派节点未提交人选(node=" + String.join(", ", missing) + "),请重新选择后提交");
}
if (assigns == null || assigns.isEmpty()) {
return null;
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>();
Set<String> seenNodeIds = new LinkedHashSet<>();
for (OapiModels.AssignItem item : assigns) {
@@ -329,6 +625,16 @@ public class OapiFlowService {
throw new BizException(ErrorCode.PARAM_ERROR,
"不允许指派发起人本人:" + item.getNodeId());
}
// D11:手选人先逐人资格终审(存在/启用/校域),再核候选集——任一人不合格整体拒绝,
// 快照与实例均不落;不能只信前端候选列表,也不能以“在候选 ID 中”替代资格。
List<Map<String, Object>> rows = new ArrayList<>();
for (Integer uid : uids) {
String userName = assigneeResolver.requireEligibleAssigneeName(schId, uid);
Map<String, Object> row = new HashMap<>();
row.put("userId", uid);
row.put("userName", userName);
rows.add(row);
}
// 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝
List<AssigneeRef> candidates = assigneeResolver.resolve(schId, node);
Set<Integer> candidateIds = new LinkedHashSet<>();
@@ -355,14 +661,6 @@ public class OapiFlowService {
"指派人数越界[" + min + "~" + max + "]:" + item.getNodeId());
}
}
List<Map<String, Object>> rows = new ArrayList<>();
for (Integer uid : uids) {
String userName = assigneeResolver.requireUserName(uid);
Map<String, Object> row = new HashMap<>();
row.put("userId", uid);
row.put("userName", userName);
rows.add(row);
}
snapshot.put(item.getNodeId(), rows);
}
try {
@@ -377,13 +675,11 @@ public class OapiFlowService {
* 节点级解析异常降级空列表(I6)。
*/
public OapiModels.ApplyPreviewVo applyPreview(Long schId, String flowGuid, Integer submitterId) {
requireFormalPreviewGrant(schId, flowGuid);
if (schId == null || flowGuid == null || submitterId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId");
}
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + flowGuid));
FlwDef def = requirePublishedDefForApply(schId, flowGuid);
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo();
vo.setFlowGuid(def.getFlowGuid());
@@ -432,7 +728,7 @@ public class OapiFlowService {
/** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */
public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) {
FlwInstance instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId)
FlwInstance instance = findByBizForOapi(schId, bizType, bizId)
.orElse(null);
return instance == null ? null : toInstanceVo(instance);
}
@@ -448,8 +744,9 @@ public class OapiFlowService {
if (schId == null || assigneeId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId");
}
Page<FlwTask> p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType),
PageRequest.of(normPage(page), normSize(size)));
Page<FlwTask> p = formalCommandEnabled
? taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)))
: taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)));
List<Long> instanceIds = p.getContent().stream()
.map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList());
Map<Long, FlwInstance> instances = instanceIds.isEmpty()
@@ -468,8 +765,9 @@ public class OapiFlowService {
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
}
Page<FlwInstance> p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status,
PageRequest.of(normPage(page), normSize(size)));
Page<FlwInstance> p = formalCommandEnabled
? instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)))
: instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)));
List<OapiModels.InstanceItemVo> items = p.getContent().stream()
.map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList());
return toPageResult(p.getTotalElements(), items, page, size);
@@ -501,8 +799,7 @@ public class OapiFlowService {
OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo();
vo.setBizType(cleanBizType);
vo.setBizId(cleanBizId);
FlwInstance instance = instanceRepository
.findBySchIdAndBizTypeAndBizId(schId, cleanBizType, cleanBizId).orElse(null);
FlwInstance instance = findByBizForOapi(schId, cleanBizType, cleanBizId).orElse(null);
if (instance == null) {
return vo;
}
@@ -557,6 +854,7 @@ public class OapiFlowService {
@Transactional
public FlwTask approveTask(Long schId, Long taskId, Integer operatorId, String operatorName,
String opinion, String edgeName) {
requireActorProof(schId, taskId, operatorId);
requireOperator(schId, taskId, operatorId, operatorName);
return taskService.handle(schId, taskId, true, opinion, operatorId, edgeName);
}
@@ -565,10 +863,27 @@ public class OapiFlowService {
@Transactional
public FlwTask rejectTask(Long schId, Long taskId, Integer operatorId, String operatorName,
String opinion) {
requireActorProof(schId, taskId, operatorId);
requireOperator(schId, taskId, operatorId, operatorName);
return taskService.handle(schId, taskId, false, opinion, operatorId);
}
/**
* R1(Codex D03):oapi 审批统一过 ActorContext 门——门禁本身无条件 fail-closed;
* 未装配(手工构造的老单测)时同样拒绝,不存在"未装配=直通"豁免。
*/
private void requireActorProof(Long schId, Long taskId, Integer operatorId) {
if (actorContextGate != null) {
actorContextGate.requireActorProof(schId, taskId, operatorId);
return;
}
log.warn("[actor-gate] oapi 快捷审批被拒(门禁未装配,fail-closed):schId={} taskId={} operatorId={}",
schId, taskId, operatorId);
throw new BizException(ErrorCode.FORBIDDEN,
"SDK 快捷审批未放行:仅凭 appKey/operatorId 不构成用户授权。"
+ "请通过流程中心人工办理页(CAS)办理");
}
private void requireOperator(Long schId, Long taskId, Integer operatorId, String operatorName) {
if (schId == null || taskId == null || operatorId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/taskId/operatorId");
@@ -673,7 +988,7 @@ public class OapiFlowService {
if (bizType == null || bizId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId");
}
instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId)
instance = findByBizForOapi(schId, bizType, bizId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见"));
}
return toProgressVo(instance);
@@ -686,6 +1001,10 @@ public class OapiFlowService {
* 业务边界保证),浏览器侧只认 token。
*/
public OapiModels.DiagramUrlVo diagramUrl(Long schId, Long instanceId, String flowGuid, String mode) {
if (formalCommandEnabled && instanceId == null && !canReadFormalDefinition(schId, flowGuid)) {
throw new BizException(ErrorCode.FORBIDDEN, "没有此流程定义的读取权限");
}
if (flowGuid != null && !flowGuid.trim().isEmpty()) {
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid.trim(), FlwEnums.DefStatus.PUBLISHED.getCode())
@@ -767,6 +1086,12 @@ public class OapiFlowService {
}
/** 进度/换图共用的办件定位(schId 收敛在次) */
private java.util.Optional<FlwInstance> findByBizForOapi(Long school, String type, String biz) {
return formalCommandEnabled
? instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(school, FlowOapiSourceScope.current(), type, biz)
: instanceRepository.findBySchIdAndBizTypeAndBizId(school, type, biz);
}
private FlwInstance requireInstance(Long schId, Long instanceId) {
FlwInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
@@ -1031,6 +1356,15 @@ public class OapiFlowService {
reg.setAppKey(appKey);
reg.setFlowGuid(body.getFlowGuid());
}
if (formalCommandEnabled) {
FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey);
com.yunhai.flowcenter.entity.FlwUsageGrant grant = formalGrants.findBySchIdAndFlowGuidAndAppId(schId, body.getFlowGuid(), source.appId).orElse(null);
boolean permitted = grant != null && Integer.valueOf(1).equals(grant.getEnabled()) && source.id.equals(grant.getSourcePrincipalId());
if (!permitted && !formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(schId, source.id, body.getFlowGuid(), "ACCEPTED")) {
throw new BizException(ErrorCode.FORBIDDEN, "没有 SELF 订阅权限");
}
reg.setSourcePrincipalId(source.id);
}
reg.setCallbackUrl(body.getCallbackUrl());
reg.setEventTypes(body.getEventTypes());
reg.setBasePath(body.getBasePath());
@@ -1125,6 +1459,7 @@ public class OapiFlowService {
vo.setInstanceId(instance.getId());
vo.setApplyRound(instance.getApplyRound());
vo.setRequestToken(instance.getApplyRequestToken());
vo.setApplyAppKey(instance.getApplyAppKey());
vo.setStatus(instance.getStatus());
vo.setVersion(instance.getVersion());
vo.setBizTitle(instance.getBizTitle());