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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user