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

1581 lines
86 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.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.callback.CallbackAppSigner;
import com.yunhai.flowcenter.entity.FlwCallbackReg;
import com.yunhai.flowcenter.entity.FlwCc;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwTask;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.engine.AssigneeRef;
import com.yunhai.flowcenter.engine.AssigneeResolver;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwCcRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BasePageResult;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* 托管接入件(/oapi/flow/**,V2.0 Part D #5 前置到 M1):
* list/{flowGuid}/apply/instance/callback/reg——出入参与 svc-hall FlowCenterClient 逐字对齐。
* 鉴权靠 appKey 签名(RequireAppSignFilter 强制 401);submitterId 按概览 §5.5 做本校校验
* (经 UserBase /oapi/user/byId,UserBase 不可达时 fail-closed 拒绝发起)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OapiFlowService {
/**
* 提交人本校校验开关(概览 §5.5 fail-closed 默认开)。本地开发无 UserBase 可达时置 false
* (application.yml flow-center.submitter-school-check.enabled),生产必须保持 true。
*/
@Value("${flow-center.submitter-school-check.enabled:true}")
private boolean submitterCheckEnabled;
/**
* 办件进度嵌入页前端基址(flow-center 自身的对外前端地址,本方案新增的第一个前端地址配置:
* 此前 UserMsg jumpPath 均为裸相对路径由接收方解析)。未配置即调签发 → fail-fast 业务异常,
* 与 callback-url 同纪律——宁可不发也不发一个打不开的地址。
*/
@Value("${flow-center.embed.front-url:}")
private String embedFrontUrl;
private final FlwDefRepository defRepository;
private final FlwInstanceRepository instanceRepository;
private final FlwTaskRepository taskRepository;
private final FlwCcRepository ccRepository;
private final FlwCallbackRegRepository regRepository;
private final FlowInstanceService instanceService;
private final FlowTaskService taskService;
private final FlowEngine flowEngine;
private final AssigneeResolver assigneeResolver;
private final ObjectMapper objectMapper;
private final OpenApiUserClient userClient;
private final com.yunhai.platform.openapi.base.OpenApiAppClient appClient;
private final DiagramTokenService tokenService;
/** 本应用身份(回调探活补应用签名头;未装配时裸发,接收方 /oapi/* 会 401) */
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
appIdentityProvider;
/**
* v2 发起的事务模板。<b>用 setter/字段注入而不是加进构造器</b>:本类构造被
* {@code OapiFlowApplyDefVersionTest}/{@code OapiFlowEmbedTaskUrlTest} 逐位钉住,
* 多一个参数就打不进来(同 svc-hall ServiceItemService 的 setter 先例)。
* 未装配(老单测直接 new)时 {@link #applyV2} 回落到"直接调用",事务语义由调用方的
* {@code @Transactional} 承担——那条路径只用于测试,不影响生产装配。
*/
private org.springframework.transaction.support.TransactionTemplate transactionTemplate;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setTransactionTemplate(org.springframework.transaction.support.TransactionTemplate template) {
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;
}
/** 流程详情回显(未发布/不存在返回 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);
}
/** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份,
* 与 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());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
// D02/D09/D11:来源主体只从验签上下文解析(缺失即拒);T01 使用许可(v1 路径)——
// 回执幂等(既有在途/退回待修改/通过件)不拦;无实例或终态件 R4 重启=新申报,必须持有效许可。
FlowSourcePrincipalResolver.Principal source = requireApplySource(cleanAppKey);
requireUsageGrantLegacyApply(schId, body.getFlowGuid(), source, 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
&& !body.getExpectedDefVersion().equals(def.getVersion())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
+ ",当前已发布版本 " + def.getVersion() + ")");
}
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
cmd.sourcePrincipalId = source.id;
FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd);
OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo();
vo.setInstanceId(outcome.getInstanceId());
vo.setDuplicated(outcome.isDuplicated());
return vo;
}
/**
* 带请求标识/轮次守卫的代发起({@code POST /oapi/flow/apply/v2},v2 §8,TASK_2 C.3)。
*
* <p>与 {@link #apply} 的三点不同,都是"幂等修复"要求而不是新功能:</p>
* <ol>
* <li><b>事务边界收窄到一次发起</b>:用 {@code TransactionTemplate} 而不是方法级
* {@code @Transactional}——唯一键冲突必须在<b>事务外</b>重新读既有实例
* (撞键后当前事务已被标记回滚,事务内再读不可靠),这与 svc-hall
* {@code AnswerService#executeSubmit} 同一形态;</li>
* <li><b>并发撞键后先核归属再返回</b>:读到的实例若属于另一应用/另一提交者/另一流程线,
* 一律拒绝,<b>不把别人的 bizType+bizId 当自己的结果</b>;</li>
* <li><b>回带稳定分支码</b>(CREATED/IDEMPOTENT/SAME_REQUEST/STALE_ROUND/REPLAY_ONLY/
* RESTARTED/CONCURRENT_CREATED),消费方按码处置,不再猜"duplicated 是哪一种重复"。</li>
* </ol>
*
* <p>已发布定义校验、提交人本校校验与旧路径同一条,不放宽;流程节点算法一行未改。</p>
*/
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());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
// D02/D09/D11:可信来源只从验签上下文推导。T01 使用许可(v2 路径)——恢复感知:
// SAME_REQUEST/REPLAY_ONLY/IDEMPOTENT/STALE_ROUND 四类回执分支不拦(已受理命令的回执不因许可
// 撤销丢失);CREATED/RESTARTED(新申报/新轮)必须持有效使用许可。分支判定与
// FlowInstanceService.applyGuarded 同键同序;完整命令协议(requestId/指纹/服务端轮次)归 T04。
FlowSourcePrincipalResolver.Principal source = requireApplySource(cleanAppKey);
requireUsageGrantGuardedApply(schId, body.getFlowGuid(), source, body.getBizType(),
body.getBizId(), body.getRequestToken(), body.getAttempt(),
Boolean.TRUE.equals(body.getRetryOnly()));
FlwInstance existing = sourceScopedInstance(schId, source, body.getBizType(), body.getBizId());
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());
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
// T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝
cmd.payloadHash = payloadHash(cmd);
cmd.sourcePrincipalId = source.id;
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()),
body.getExpectedPrevRound());
FlowInstanceService.GuardedOutcome outcome;
try {
outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy));
} catch (DataIntegrityViolationException concurrent) {
// 事务边界外重读:另一个并发请求已用同一业务键建好办件
return fromConcurrent(schId, cmd, policy);
}
requireNotConflict(outcome);
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(outcome.instanceId);
vo.setDuplicated(outcome.duplicated);
vo.setBranch(outcome.branch);
vo.setApplyRound(outcome.applyRound);
instanceRepository.findById(outcome.instanceId).ifPresent(current -> {
int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound();
int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound;
if (currentRound == outcomeRound) {
vo.setStatus(current.getStatus());
vo.setVersion(current.getVersion());
} // 已被另一请求推进到新轮时,让消费方按实例查询恢复,不混装两轮证据。
});
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/50)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
* 重启即新一轮申报。
*
* <p><b>F01 整改(2026-10-29):本方法不再有直通分支。</b>旧实现
* {@code if (guard == null || !guard.isEnforce() || appKey == null) return;} 让"闸门未装配"、
* "灰度开关关闭"、"缺验签主体"三种情况都变成放行。现三者全部 fail-closed 拒绝。</p>
*/
private void requireUsageGrantLegacyApply(Long schId, String flowGuid,
FlowSourcePrincipalResolver.Principal source,
String bizType, String bizId) {
if (!legacyApplyIsReceiptOnly(sourceScopedInstance(schId, source, bizType, bizId))) {
requireGrantGuard().requireNewApplyGrant(schId, flowGuid, source);
}
}
/**
* T01 使用许可闸·v2 路径:与 {@code FlowInstanceService.applyGuarded/settleExisting}
* 同键同序的分支预判(判定逻辑见 {@link #guardedApplyIsReceiptOnly});
* 新申报(CREATED/RESTARTED)必须过 {@link FlowUsageGrantGuard#requireNewApplyGrant}。
* 完整命令协议统一归 T04。
*/
private void requireUsageGrantGuardedApply(Long schId, String flowGuid,
FlowSourcePrincipalResolver.Principal source,
String bizType, String bizId,
String requestToken, Integer attempt, boolean retryOnly) {
if (!guardedApplyIsReceiptOnly(sourceScopedInstance(schId, source, bizType, bizId),
requestToken, attempt, retryOnly)) {
requireGrantGuard().requireNewApplyGrant(schId, flowGuid, source);
}
}
/** 闸门缺失=拒绝而不是放行(生产装配必有该 bean;手工构造服务时必须显式注入测试替身)。 */
private FlowUsageGrantGuard requireGrantGuard() {
if (usageGrantGuard == null) {
throw new BizException(ErrorCode.FORBIDDEN, "应用使用许可闸门未装配,拒绝发起");
}
return usageGrantGuard;
}
/**
* 验签上下文 → 可信来源主体(F02)。appKey 缺失、UserBase 不可达或注册表查无一律拒绝,
* 不回退成"无来源"的宽松路径;通道未装配同样拒绝。
*/
private FlowSourcePrincipalResolver.Principal requireApplySource(String appKey) {
if (sourcePrincipalResolver == null) {
throw new BizException(ErrorCode.FORBIDDEN, "调用应用主体解析通道未装配,拒绝发起");
}
return sourcePrincipalResolver.resolve(appKey);
}
/** 来源分区的业务坐标回读(与 {@code uk_flw_instance_legacy} 同列同序)。 */
private FlwInstance sourceScopedInstance(Long schId, FlowSourcePrincipalResolver.Principal source,
String bizType, String bizId) {
if (source == null) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源应用,不能按业务坐标定位办件");
}
return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
schId, source.id, bizType, bizId, LEGACY_SLOT).orElse(null);
}
/** 旧 OAPI 入口的坐标槽位(正式命令走 NULL 槽位,两条分区互不复用)。 */
private static final Integer LEGACY_SLOT = 1;
/**
* v1 回执判定:true=该请求只会命中幂等回读(不建件不重启),false=会触发新申报或 R4 重启。
* 与 {@code FlowInstanceService.apply(ApplyCommand)} 的分支一一对应(包私有便于规则单测钉住)。
*/
static boolean legacyApplyIsReceiptOnly(FlwInstance existing) {
return existing != null
&& (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus())
|| 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 (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus())
|| 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。
*/
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
FlowInstanceService.ApplyCommand cmd,
FlowInstanceService.ApplyRetryPolicy policy) {
FlwInstance existing = instanceRepository
.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, LEGACY_SLOT)
.orElse(null);
if (existing == null) {
throw new BizException(ErrorCode.CUSTOM, "办件创建冲突且读不到既有实例,请稍后重试");
}
if (!cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) {
throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
}
if (existing.getSubmitterId() != null && cmd.submitterId != null
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符");
}
String token = policy.requestToken == null ? null : policy.requestToken.trim();
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(existing.getId());
vo.setDuplicated(true);
vo.setApplyRound(existing.getApplyRound());
vo.setStatus(existing.getStatus());
vo.setVersion(existing.getVersion());
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;
}
/**
* 调用方 app_key → app_code 一次转换(域间边界:验签域→路由域,只在 apply 做这一次)。
* 查无/不可达降级 null——消息 jumpApp 空、归属回落发送方(现状行为),不阻断 apply;
* jump_app 值域契约(=app_code)由本方法单点保证,替代历史上用 bizType 冒充的写法。
*/
private String resolveApplyAppCode(String appKey) {
if (appKey == null) {
return null;
}
try {
com.fasterxml.jackson.databind.JsonNode app = appClient.getAppByKey(appKey);
String code = app == null ? null : app.path("appCode").asText(null);
if (code == null || code.trim().isEmpty()) {
log.warn("[flow-center] apply 调用方 app_code 解析为空(by-key 查无):appKey={},消息归属将回落发送方", appKey);
return null;
}
return code.trim();
} catch (Exception e) {
log.warn("[flow-center] apply 调用方 app_code 解析失败(UserBase 不可达?):appKey={}:{}",
appKey, e.toString());
return null;
}
}
/**
* 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;
}
Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>();
Set<String> seenNodeIds = new LinkedHashSet<>();
for (OapiModels.AssignItem item : assigns) {
if (item.getNodeId() == null || seenNodeIds.contains(item.getNodeId())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"指派 nodeId 缺失或重复:" + item.getNodeId());
}
seenNodeIds.add(item.getNodeId());
FlowDefModel.Node node = model.node(item.getNodeId());
if (node == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "指派节点不存在:" + item.getNodeId());
}
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"节点未开启发起时指派:" + item.getNodeId());
}
if (item.getUserIds() == null || item.getUserIds().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"指派人员为空:" + item.getNodeId());
}
Set<Integer> uids = new LinkedHashSet<>(item.getUserIds());
if (uids.contains(submitterId)) {
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<>();
for (AssigneeRef ref : candidates) {
candidateIds.add(ref.getUserId());
}
for (Integer uid : uids) {
if (!candidateIds.contains(uid)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"指派人员不在节点候选集内:node=" + item.getNodeId() + " userId=" + uid);
}
}
boolean single = FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode);
int n = uids.size();
if (single && n != 1) {
throw new BizException(ErrorCode.PARAM_ERROR,
"单选节点须恰指派 1 人:" + item.getNodeId());
}
if (!single) {
int min = node.assignPolicy.min == null ? 1 : node.assignPolicy.min;
int max = node.assignPolicy.max == null ? candidates.size() : node.assignPolicy.max;
if (n < min || n > max) {
throw new BizException(ErrorCode.PARAM_ERROR,
"指派人数越界[" + min + "~" + max + "]:" + item.getNodeId());
}
}
snapshot.put(item.getNodeId(), rows);
}
try {
return objectMapper.writeValueAsString(snapshot);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "指派快照序列化失败:" + e.getMessage());
}
}
/**
* 发起预览(指派候选,方案 §5.1):只返回开启指派节点;candidates 剔除 submitter;
* 节点级解析异常降级空列表(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 = requirePublishedDefForApply(schId, flowGuid);
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo();
vo.setFlowGuid(def.getFlowGuid());
vo.setDefVersion(def.getVersion());
List<OapiModels.PreviewNodeVo> nodes = new ArrayList<>();
for (FlowDefModel.Node node : model.nodes) {
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
continue;
}
List<AssigneeRef> candidates;
try {
candidates = assigneeResolver.resolve(schId, node);
} catch (BizException e) {
log.warn("[flow-center] applyPreview 节点候选解析失败,降级空候选:flowGuid={} node={}:{}",
flowGuid, node.id, e.getMessage());
candidates = new ArrayList<>();
}
List<OapiModels.PreviewUserVo> users = new ArrayList<>();
for (AssigneeRef ref : candidates) {
if (submitterId.equals(ref.getUserId())) {
continue;
}
OapiModels.PreviewUserVo u = new OapiModels.PreviewUserVo();
u.setUserId(ref.getUserId());
u.setUserName(ref.getUserName());
users.add(u);
}
OapiModels.PreviewNodeVo nv = new OapiModels.PreviewNodeVo();
nv.setNodeId(node.id);
nv.setNodeName(node.name);
nv.setCountersign(node.countersign == null ? "ALL" : node.countersign);
nv.setCandidates(users);
OapiModels.PreviewSelectionVo selection = new OapiModels.PreviewSelectionVo();
selection.setMode(FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode)
? FlowDefModel.ASSIGN_MODE_SINGLE : FlowDefModel.ASSIGN_MODE_MULTI);
selection.setMin(node.assignPolicy.min == null ? 1 : node.assignPolicy.min);
selection.setMax(node.assignPolicy.max == null ? users.size() : node.assignPolicy.max);
selection.setRequired(node.assignPolicy.required == null
? Boolean.FALSE : node.assignPolicy.required);
nv.setSelection(selection);
nodes.add(nv);
}
vo.setNodes(nodes);
return vo;
}
/** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */
public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) {
FlwInstance instance = findByBizForOapi(schId, bizType, bizId)
.orElse(null);
return instance == null ? null : toInstanceVo(instance);
}
// ===== 应用内审批(2026-09 第三步):查询 + 办理 =====
/**
* 我的待办(oapi,应用内审批列表页数据源):assigneeId 维度分页,bizType 可选过滤。
* 办理人判定以 taskId 为准——每条待办任务行恰好一个 assignee,不再做集合判断。
*/
public BasePageResult<OapiModels.TodoTaskVo> pageTodoTasks(Long schId, Integer assigneeId,
String bizType, Integer page, Integer size) {
if (schId == null || assigneeId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId");
}
// D11/F02:OAPI 列表一律按可信来源分区,A 应用不会看到 B 应用的待办。
// 本次改造之前落库、来源列为空的实例不在本口径内(不回填、不猜测归属,见任务书 §2.3)。
Page<FlwTask> p = taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(),
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()
? java.util.Collections.emptyMap()
: instanceRepository.findAllById(instanceIds).stream()
.collect(java.util.stream.Collectors.toMap(FlwInstance::getId, i -> i));
List<OapiModels.TodoTaskVo> items = p.getContent().stream()
.map(t -> toTodoTaskVo(t, instances.get(t.getInstanceId())))
.collect(java.util.stream.Collectors.toList());
return toPageResult(p.getTotalElements(), items, page, size);
}
/** 办件分页(oapi,应用侧列表/对账):bizType/status 可选过滤 */
public BasePageResult<OapiModels.InstanceItemVo> pageInstances(Long schId, String bizType, Integer status,
Integer page, Integer size) {
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
}
Page<FlwInstance> p = instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(),
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);
}
/**
* 按业务单据查当前人待办(v2.1 应用内审批的业务详情卡片数据源):
* 补齐 tasks/todo 缺的那一侧——todo 是"人 → 单据列表",本方法是"单据 → 我的待办",
* 子应用据此判断"当前人是否该办件的待办人",不必再拉全量待办在前端过滤。
* <p>治理口径:
* <ul>
* <li>只返回 {@code assigneeId=operatorId} 且 status=PENDING 的任务;他人待办与"办件不存在"
* 一律出空数组(HTTP 200),出参不区分二者,避免用本端点探测别人的办件;</li>
* <li>租户收敛在定位查询本身({@code findBySchIdAndBizTypeAndBizId}),bizType 不匹配即空;</li>
* <li>{@code outlets} 只为当前人的任务计算,且出边 &gt;1 才透出(与
* {@code FlowQueryService.detail} 的办理面板口径一致——单出口无需选择,不给前端多余分支);</li>
* <li>不透出他人办理人、意见或定义内部 id;taskId 是办理必需的运行时身份,保留。</li>
* </ul>
*/
public OapiModels.ByBizTasksVo tasksByBiz(Long schId, String bizType, String bizId, Integer operatorId) {
if (schId == null || schId <= 0 || operatorId == null || operatorId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/operatorId(均须为正)");
}
String cleanBizType = emptyToNull(bizType);
String cleanBizId = emptyToNull(bizId);
if (cleanBizType == null || cleanBizId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:bizType/bizId");
}
OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo();
vo.setBizType(cleanBizType);
vo.setBizId(cleanBizId);
FlwInstance instance = findByBizForOapi(schId, cleanBizType, cleanBizId).orElse(null);
if (instance == null) {
return vo;
}
vo.setInstanceId(instance.getId());
vo.setStatus(instance.getStatus());
vo.setCurrentNodes(instance.getCurrentNodes());
List<FlwTask> mine = taskRepository
.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode()).stream()
.filter(t -> operatorId.equals(t.getAssigneeId()))
.sorted(java.util.Comparator.comparing(FlwTask::getId))
.collect(java.util.stream.Collectors.toList());
if (mine.isEmpty()) {
return vo;
}
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
FlowDefModel model = def == null ? null : flowEngine.parseModel(def);
for (FlwTask t : mine) {
vo.getTasks().add(toByBizTaskVo(t, model));
}
return vo;
}
private OapiModels.ByBizTaskVo toByBizTaskVo(FlwTask t, FlowDefModel model) {
OapiModels.ByBizTaskVo vo = new OapiModels.ByBizTaskVo();
vo.setTaskId(t.getId());
vo.setNodeId(t.getNodeId());
vo.setNodeName(t.getNodeName());
vo.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString());
if (model == null) {
return vo;
}
List<FlowDefModel.Edge> outs = model.outEdges(t.getNodeId());
if (outs.size() > 1) {
for (FlowDefModel.Edge e : outs) {
FlowDefModel.Node to = model.node(e.to);
OapiModels.ByBizOutletVo outlet = new OapiModels.ByBizOutletVo();
outlet.setName(e.name);
outlet.setToNodeId(e.to);
outlet.setToNodeName(to == null || to.name == null || to.name.isEmpty() ? e.to : to.name);
vo.getOutlets().add(outlet);
}
}
return vo;
}
/**
* 同意(oapi,应用内审批):operatorId 由调用方应用按其登录态传入,本端按任务办理人
* 复核({@code task.assigneeId == operatorId},不满足→业务码拒绝,应用签名不替代"谁在办")。
* operatorName 供应用侧审计透传,事件流仍以任务行 assigneeName 为准。多出口节点必带
* edgeName({@code FlowTaskService.handle} 内校验)。审批后的回调/待办减一等事件链自动复用。
*/
@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);
}
/** 拒绝(oapi):opinion 必填由 {@code FlowTaskService.handle} 强制;其余口径同 {@link #approveTask} */
@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");
}
if (operatorName == null || operatorName.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:operatorName(操作人姓名,审计用)");
}
}
private OapiModels.TodoTaskVo toTodoTaskVo(FlwTask t, FlwInstance instance) {
OapiModels.TodoTaskVo vo = new OapiModels.TodoTaskVo();
vo.setTaskId(t.getId());
vo.setInstanceId(t.getInstanceId());
vo.setInstanceVersion(instance == null ? null : instance.getVersion());
vo.setBizType(instance == null ? null : instance.getBizType());
vo.setBizId(instance == null ? null : instance.getBizId());
vo.setBizTitle(instance == null ? null : instance.getBizTitle());
vo.setBizSnapshot(instance == null ? null : instance.getBizSnapshot());
vo.setNodeId(t.getNodeId());
vo.setNodeName(t.getNodeName());
vo.setAssigneeId(t.getAssigneeId());
vo.setAssigneeName(t.getAssigneeName());
vo.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString());
return vo;
}
private OapiModels.InstanceItemVo toInstanceItemVo(FlwInstance instance) {
OapiModels.InstanceItemVo vo = new OapiModels.InstanceItemVo();
vo.setInstanceId(instance.getId());
vo.setStatus(instance.getStatus());
vo.setVersion(instance.getVersion());
vo.setBizType(instance.getBizType());
vo.setBizId(instance.getBizId());
vo.setBizTitle(instance.getBizTitle());
vo.setBizUrl(instance.getBizUrl());
vo.setSubmitterName(instance.getSubmitterName());
vo.setSubmitTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
vo.setFinishTime(instance.getFinishTime() == null ? null : instance.getFinishTime().toString());
vo.setCurrentNodes(instance.getCurrentNodes());
return vo;
}
/** 与 FlowQueryService 同口径(page<1→0;size 空/越界→10) */
private static int normPage(Integer page) {
return page == null || page < 1 ? 0 : page - 1;
}
private static int normSize(Integer size) {
return size == null || size < 1 || size > 100 ? 10 : size;
}
private static <T> BasePageResult<T> toPageResult(long total, List<T> content, Integer page, Integer size) {
BasePageResult<T> result = new BasePageResult<>();
result.setTotal((int) total);
result.setContent(content);
int pageIndex = page == null || page < 1 ? 1 : page;
int pageSize = size == null || size < 1 ? content.size() : size;
result.setPageIndex(pageIndex);
result.setPageSize(pageSize);
result.setTotalPages(pageSize == 0 ? 0 : (int) Math.ceil((double) total / pageSize));
result.setHasNext((long) pageIndex * pageSize < total);
return result;
}
private static String emptyToNull(String s) {
return s == null || s.trim().isEmpty() ? null : s.trim();
}
/**
* 撤回(托管接入件,2026-09-16 补:原先只有 CAS 用户面 {@code /api/flow/instance/{id}/withdraw},
* 业务应用只能让浏览器直调,票据与 CORS 一并外溢)。治理口径:
* <ul>
* <li>身份:{@code submitterId} 必传——应用间签名只证明"哪个应用在调",不证明"谁在撤",
* 对端按 submitterId 与办件提交人**逐字复核**,杜绝业务应用越权代撤他人办件;</li>
* <li>租户:schId 收敛 + requireInstance 校验办件归属;</li>
* <li>状态:仅审批中(10) 且尚无任务被办理(引擎既有纪律,见 FlowInstanceService.withdraw);</li>
* <li>不校验 submitterId 的本校成员资格——提交人身份在发起时已按 §5.5 校验过,撤回不改变归属。</li>
* </ul>
*/
@Transactional
public void withdraw(Long schId, Long instanceId, Integer submitterId) {
if (schId == null || instanceId == null || submitterId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/instanceId/submitterId");
}
FlwInstance instance = instanceService.requireInstance(schId, instanceId);
if (!submitterId.equals(instance.getSubmitterId())) {
throw new BizException(ErrorCode.FORBIDDEN, "仅提交人本人可撤回");
}
instanceService.withdraw(schId, instanceId);
}
/**
* D14 发起人重提(托管接入件,2026-10-29)。治理口径与 {@link #withdraw} 一致:
* <ul>
* <li>来源:{@code FlowOapiSourceScope} 已按实例 id 核验归属,非本应用发起的办件到不了这里;</li>
* <li>身份:{@code submitterId} 与办件提交人逐字复核——签名只证明"哪个应用在调";</li>
* <li>状态:仅 50 退回待修改(幂等:已是 10 视为已重提,回原实例不重开一代任务);</li>
* <li>轮次/版本:不改 applyRound、不改 defId/defVersion(D04/D05 在途固定版本);</li>
* <li>业务字段:FL 不做 Diff,{@code formData} 为空即沿用原快照。</li>
* </ul>
*/
@Transactional
public void resubmit(Long schId, Long instanceId, Integer submitterId, String formData) {
throw new BizException(ErrorCode.FORBIDDEN,
"OAPI 重提缺少可信用户身份证明,请由原提交人通过 CAS 办理页重提");
}
/**
* 办件进度(结构化轨迹):steps=发起申请 + 节点步骤(按任务 id 序;同节点同人多任务行合并为多人步骤)。
* 状态标签口径与前端流程轨迹一致(0 待审核 / 1 已同意 / 2 已拒绝 / 3 失效 / 4 已退回 / 5 已移交);
* 失效行保留(消费方自行决定是否隐藏——重启重提后的旧节点轨迹即 3/失效)。
*/
public OapiModels.ProgressVo progress(Long schId, Long instanceId, String bizType, String bizId) {
FlwInstance instance;
if (instanceId != null) {
instance = requireInstance(schId, instanceId);
} else {
if (bizType == null || bizId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId");
}
instance = findByBizForOapi(schId, bizType, bizId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见"));
}
return toProgressVo(instance);
}
/**
* 流程图换图(token 模式):app-sign 校验在本次请求完成(/oapi 强制验签),token 只向浏览器
* 传递"出图资格"。二选一:instanceId(办件口径,带运行时状态+可见性收紧)/ flowGuid(定义
* 口径,最高已发布版)。userId 无需传——appKey 调用方已视为可信(两级模型,可见性由发起方
* 业务边界保证),浏览器侧只认 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())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + flowGuid));
return issueUrl(schId, null, def.getId(), mode);
}
if (instanceId != null) {
requireInstance(schId, instanceId);
return issueUrl(schId, instanceId, null, mode);
}
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 flowGuid");
}
private OapiModels.DiagramUrlVo issueUrl(Long schId, Long instanceId, Long defId, String mode) {
String token = tokenService.issue(schId, instanceId, defId, mode);
OapiModels.DiagramUrlVo vo = new OapiModels.DiagramUrlVo();
vo.setImgUrl("/open/flow/diagram/img?token=" + token);
vo.setExpiresIn(DiagramTokenService.TTL_MS / 1000);
return vo;
}
/**
* 办件进度嵌入页换址(token 模式,与换图同构):同一 token 两处消费——嵌入页数据
* /open/flow/embed/data 与流程图 /open/flow/diagram/img 均免签认领该 token。定位二选一:
* instanceId(与办件直接复核)/ bizType+bizId(对账口径,办件不存在与 progress 同文案)。
* 用户级可见性(本人/办理人/管理员/该办件抄送接收人)由子应用后端在调本端点前完成——oapi 层只认 schId。
* 2026-10-17 F7 前置补丁后,中心用户面口径({@link FlowAccessGuard#requireInstanceVisible})已含抄送
* 接收人;签发侧必须与之一致,否则"抄送我的"点开得、进度嵌入页换址却拒发(2026-10-17 记)。
*/
public OapiModels.EmbedUrlVo embedUrl(Long schId, Long instanceId, String bizType, String bizId, String mode) {
return embedUrl(schId, instanceId, bizType, bizId, mode, null);
}
/**
* mode=task(F9)直跳中心任务办理页:pageUrl = front-url + {@code #/task/{taskId}},
* 无 token(CAS 会话鉴权页,办理权由该页加载时的用户面接口按 assigneeId 复核)——
* 子应用"在自家页放办理入口"场景用;taskId 必填,缺则 PARAM_ERROR。
*/
public OapiModels.EmbedUrlVo embedUrl(Long schId, Long instanceId, String bizType, String bizId,
String mode, Long taskId) {
// 配置缺失优先于办件定位:fail-fast 纪律要的是"无论如何都不发打不开的地址"
if (embedFrontUrl == null || embedFrontUrl.trim().isEmpty()) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "嵌入页地址未配置:flow-center.embed.front-url");
}
String base = embedFrontUrl.trim();
if (!base.endsWith("/")) {
base = base + "/";
}
String normMode = mode == null || mode.trim().isEmpty() ? "auto" : mode.trim();
if ("task".equals(normMode)) {
if (taskId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "mode=task 需要 taskId(任务办理页定位)");
}
OapiModels.EmbedUrlVo out = new OapiModels.EmbedUrlVo();
out.setPageUrl(base + "#/task/" + taskId);
out.setExpiresIn(0);
return out;
}
Long resolvedInstanceId;
if (instanceId != null) {
resolvedInstanceId = requireInstance(schId, instanceId).getId();
} else {
if (bizType == null || bizId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId");
}
OapiModels.InstanceVo vo = instanceByBiz(schId, bizType, bizId);
if (vo == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见");
}
resolvedInstanceId = vo.getInstanceId();
}
// mode 与 DiagramTokenService 内部缺省同值("auto"),URL 里显式带上仅为前端展示冗余
String token = tokenService.issue(schId, resolvedInstanceId, null, normMode);
OapiModels.EmbedUrlVo out = new OapiModels.EmbedUrlVo();
out.setPageUrl(base + "#/embed/instance?token=" + token + "&mode=" + normMode);
out.setExpiresIn(DiagramTokenService.TTL_MS / 1000);
return out;
}
/** 进度/换图共用的办件定位(schId 收敛在次;来源分区必填,缺来源即拒) */
private java.util.Optional<FlwInstance> findByBizForOapi(Long school, String type, String biz) {
String source = FlowOapiSourceScope.current();
return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByIdDesc(
school, source, type, biz);
}
private FlwInstance requireInstance(Long schId, Long instanceId) {
FlwInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
if (!schId.equals(instance.getSchId())) {
throw new BizException(ErrorCode.FORBIDDEN, "办件不属于该学校");
}
return instance;
}
private OapiModels.ProgressVo toProgressVo(FlwInstance instance) {
OapiModels.ProgressVo vo = new OapiModels.ProgressVo();
vo.setInstanceId(instance.getId());
vo.setBizType(instance.getBizType());
vo.setBizId(instance.getBizId());
vo.setBizTitle(instance.getBizTitle());
vo.setStatus(instance.getStatus());
vo.setCurrentNodes(instance.getCurrentNodes());
vo.setSubmitterId(instance.getSubmitterId());
vo.setSubmitterName(instance.getSubmitterName());
vo.setSubmitTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
vo.setFinishTime(instance.getFinishTime() == null ? null : instance.getFinishTime().toString());
Set<String> activeNodes = new LinkedHashSet<>();
if (instance.getCurrentNodes() != null && !instance.getCurrentNodes().isEmpty()) {
activeNodes.addAll(Arrays.asList(instance.getCurrentNodes().split(",")));
}
List<FlwTask> rows = taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
boolean hasSubmitRow = rows.stream()
.anyMatch(t -> FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId()));
List<OapiModels.ProgressStepVo> steps = new ArrayList<>();
// 步骤发生时间(排序键):任务步=办理人行最晚时间(handleTime?:arriveTime),submit/事件步=动作时间,
// 抄送步=最晚 sentTime——抄送步与任务步按真实时间交织,人读时间线不出现"抄送悬在最后"。
Map<OapiModels.ProgressStepVo, java.time.LocalDateTime> stepTimes =
new java.util.IdentityHashMap<>();
// 第 0 步"发起申请"(对外时间线图3/图4口径):2026-09-18 起发起会留痕成真实任务行,
// 有真实行时由下面的循环出这一步(可见内容与这里的合成步一致);老办件没有真实行,
// 仍按办件快照合成,存量数据对外表现不变。
if (!hasSubmitRow) {
OapiModels.ProgressStepVo submit = submitStep(instance);
steps.add(submit);
stepTimes.put(submit, instance.getSubmitTime());
}
// 轨迹留痕事件行(6 发起/重新提交、7 撤回)单独成步;步骤 label 直接用事件语义,
// 不参与下面的"任一拒绝>任一同意>全失效"汇总(否则会被判成"失效")。
Set<OapiModels.ProgressStepVo> eventSteps =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
// 步骤聚合:nodeId 保序;同节点多任务行(会签/退回重到/失效清理)并入同一多人步骤
Map<String, OapiModels.ProgressStepVo> stepsByNode = new HashMap<>();
for (FlwTask t : rows) {
if (FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) {
OapiModels.ProgressStepVo es = eventStep(t);
eventSteps.add(es);
steps.add(es);
stepTimes.put(es, t.getHandleTime() != null ? t.getHandleTime() : t.getArriveTime());
continue;
}
OapiModels.ProgressStepVo step = stepsByNode.computeIfAbsent(t.getNodeId(), k -> {
OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo();
s.setNodeId(t.getNodeId());
s.setNodeName(t.getNodeName());
s.setAssignees(new ArrayList<>());
steps.add(s);
return s;
});
step.getAssignees().add(taskVo(t));
}
// 步骤 label:在办节点=待审核,其余取办理人行的汇总(任一拒绝=已拒绝 > 任一同意=已同意 > 全失效=失效)
for (OapiModels.ProgressStepVo s : steps) {
if (eventSteps.contains(s)) {
continue;
}
s.setActive(activeNodes.contains(s.getNodeId()));
s.setLabel(s.isActive() ? "待审核" : stepLabel(s.getAssignees()));
java.time.LocalDateTime latest = null;
for (OapiModels.ProgressTaskVo r : s.getAssignees()) {
java.time.LocalDateTime t = r.getHandleTime() != null
? java.time.LocalDateTime.parse(r.getHandleTime())
: (r.getArriveTime() == null ? null : java.time.LocalDateTime.parse(r.getArriveTime()));
if (t != null && (latest == null || t.isAfter(latest))) {
latest = t;
}
}
stepTimes.put(s, latest);
}
// 抄送步(有才交织排序):无抄送时保持既有拼装顺序原样输出(存量办件出参零变化);
// 有抄送时按 (发生时间, 秩) 排——既有步=0、抄送步=1(同秒 DATETIME 分不出先后,任务步
// 恒在前;不给既有步分秩是因为驳回/重提步同秒靠 id 定序即真实先后,秩会颠倒)。
List<OapiModels.ProgressStepVo> ccStepList = ccSteps(instance.getId(), stepTimes);
if (!ccStepList.isEmpty()) {
Map<OapiModels.ProgressStepVo, Integer> ranks = new java.util.IdentityHashMap<>();
for (OapiModels.ProgressStepVo s : steps) {
ranks.put(s, 0);
}
for (OapiModels.ProgressStepVo s : ccStepList) {
ranks.put(s, 1);
}
steps.addAll(ccStepList);
steps.sort(java.util.Comparator
.comparing((OapiModels.ProgressStepVo s) -> stepTimes.get(s),
java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
.thenComparing(s -> ranks.get(s)));
}
vo.setSteps(steps);
return vo;
}
/**
* 抄送步(2026-09-23 NOTIFY 节点):按 nodeId 聚合(多人并排),label="抄送"、
* 参与行 status=1/已抄送(复用"已办过"取值,消费方无需适配新状态);时间取 sentTime,
* 与任务步按真实发生时间交织排序。无抄送返回空列表(存量办件出参零变化)。
*/
private List<OapiModels.ProgressStepVo> ccSteps(Long instanceId,
Map<OapiModels.ProgressStepVo, java.time.LocalDateTime> stepTimes) {
List<FlwCc> ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId);
if (ccs.isEmpty()) {
return new ArrayList<>();
}
Map<String, OapiModels.ProgressStepVo> byNode = new java.util.LinkedHashMap<>();
for (FlwCc c : ccs) {
OapiModels.ProgressStepVo step = byNode.computeIfAbsent(c.getNodeId(), k -> {
OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo();
s.setNodeId(c.getNodeId());
s.setNodeName(c.getNodeName() == null || c.getNodeName().isEmpty() ? "抄送" : c.getNodeName());
s.setActive(false);
s.setLabel("抄送");
s.setAssignees(new ArrayList<>());
stepTimes.put(s, c.getSentTime());
return s;
});
OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo();
row.setUserId(c.getUserId());
row.setUserName(c.getUserName());
row.setStatus(1);
row.setStatusLabel("已抄送");
row.setArriveTime(c.getSentTime() == null ? null : c.getSentTime().toString());
step.getAssignees().add(row);
java.time.LocalDateTime t = stepTimes.get(step);
if (c.getSentTime() != null && (t == null || c.getSentTime().isAfter(t))) {
stepTimes.put(step, c.getSentTime());
}
}
return new ArrayList<>(byNode.values());
}
/** 合成"发起申请"步(老办件:无真实留痕行,取办件快照的提交人/提交时间) */
private OapiModels.ProgressStepVo submitStep(FlwInstance instance) {
OapiModels.ProgressStepVo submit = new OapiModels.ProgressStepVo();
submit.setNodeId("submit");
submit.setNodeName("发起申请");
submit.setActive(false);
submit.setLabel("发起申请");
OapiModels.ProgressTaskVo submitter = new OapiModels.ProgressTaskVo();
submitter.setUserId(instance.getSubmitterId());
submitter.setUserName(instance.getSubmitterName());
submitter.setStatus(1);
submitter.setStatusLabel("已提交");
submitter.setArriveTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
submit.setAssignees(new ArrayList<>(java.util.Collections.singletonList(submitter)));
return submit;
}
/**
* 留痕事件步(发起/重新提交/撤回):nodeId 用保留值 submit/resubmit/withdraw。
* 发起步保留原合成步的口径(nodeName/label=发起申请、参与行 status=1/已提交),
* 使新老办件对外输出一致;撤回与重新提交是本轮新增的对外可见动作。
*/
private OapiModels.ProgressStepVo eventStep(FlwTask t) {
boolean submitted = FlwEnums.TaskStatus.SUBMITTED.getCode() == t.getStatus();
OapiModels.ProgressStepVo step = new OapiModels.ProgressStepVo();
step.setNodeId(t.getNodeId());
step.setNodeName(t.getNodeName());
step.setActive(false);
step.setLabel(!submitted ? "已撤回"
: (FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId()) ? "发起申请" : "已提交"));
OapiModels.ProgressTaskVo vo = taskVo(t);
if (submitted) {
// 发起/重新提交沿用"已提交"文案(status=1 与老合成步一致,消费方无需适配)
vo.setStatus(1);
vo.setStatusLabel("已提交");
} else {
vo.setStatusLabel("已撤回");
}
step.setAssignees(new ArrayList<>(java.util.Collections.singletonList(vo)));
return step;
}
/** 任务行 → 对外步骤里的办理人行(展示时间截断到秒:列已升毫秒精度,对外口径不变) */
private OapiModels.ProgressTaskVo taskVo(FlwTask t) {
OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo();
row.setTaskId(t.getId());
row.setUserId(t.getAssigneeId());
row.setUserName(t.getAssigneeName());
row.setStatus(t.getStatus());
row.setStatusLabel(taskLabel(t.getStatus()));
row.setOpinion(t.getOpinion());
row.setArriveTime(t.getArriveTime() == null ? null
: t.getArriveTime().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
row.setHandleTime(t.getHandleTime() == null ? null
: t.getHandleTime().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
row.setEdgeName(t.getEdgeName());
return row;
}
private String taskLabel(Integer status) {
if (status == null) {
return "";
}
switch (status) {
case 0: return "待审核";
case 1: return "已同意";
case 2: return "已拒绝";
case 3: return "失效";
case 4: return "已退回";
case 5: return "已移交";
case 6: return "已提交";
case 7: return "已撤回";
default: return String.valueOf(status);
}
}
private String stepLabel(List<OapiModels.ProgressTaskVo> rows) {
boolean denied = false, approved = false, invalid = true;
for (OapiModels.ProgressTaskVo r : rows) {
int s = r.getStatus() == null ? -1 : r.getStatus();
if (s == 2) {
denied = true;
}
if (s == 1) {
approved = true;
}
if (s == 0 || s == 1 || s == 2 || s == 4 || s == 5) {
invalid = false;
}
}
if (denied) {
return "已拒绝";
}
if (approved) {
return "已同意";
}
return invalid ? "失效" : "待审核";
}
/** 回调注册(uk: sch+appKey+flowGuid;重复注册=更新配置并重置 secret;注册时探活) */
@Transactional
public OapiModels.CallbackRegResultVo registerCallback(Long schId, String appKey,
OapiModels.CallbackRegBody body) {
if (schId == null || appKey == null || body.getFlowGuid() == null || body.getCallbackUrl() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/callbackUrl");
}
// 绑定的流程须存在(草稿也允许先注册,发布后事件即按订阅分发)
defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, body.getFlowGuid())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在:" + body.getFlowGuid()));
String secret = uuid32();
FlwCallbackReg reg = regRepository.findBySchIdAndAppKeyAndFlowGuid(schId, appKey, body.getFlowGuid())
.orElse(null);
if (reg == null) {
reg = new FlwCallbackReg();
reg.setSchId(schId);
reg.setAppKey(appKey);
reg.setFlowGuid(body.getFlowGuid());
}
{
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());
reg.setSecret(secret);
reg.setStatus(0);
regRepository.saveAndFlush(reg);
probeReachable(body.getCallbackUrl(), body.getBasePath(), body.getFlowGuid(), secret);
OapiModels.CallbackRegResultVo vo = new OapiModels.CallbackRegResultVo();
vo.setSecret(secret);
return vo;
}
/**
* 可达性探活(概览 §十:拦住配错域名的低级故障)。只做连接级判定:
* 连不上(未知主机/拒绝/超时)视为不可达并拒绝注册;任何 HTTP 状态码(含 401/404)
* 都算可达——接收方验签过滤器在拿到 secret 前必然拒绝 ping,鉴权语义不在此验证。
* <p>
* 但探活请求必须带应用签名:回调端点挂在接收方 /oapi/* 下(强制应用验签)时,
* 裸 ping 会被 401 拦下,而 RestTemplate 对 4xx 抛异常 → 误判"不可达"拒绝注册。
*/
private void probeReachable(String callbackUrl, String basePath, String flowGuid, String secret) {
try {
RestTemplate template = CallbackAppSigner.signedTemplate(
appIdentityProvider.getIfAvailable(), basePath, 3000, 3000);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Flw-EventId", uuid32());
headers.set("X-Flw-Timestamp", String.valueOf(System.currentTimeMillis()));
headers.set("X-Flw-Nonce", uuid32().substring(0, 16));
headers.set("X-Flw-Signature", "ping");
Map<String, Object> ping = new HashMap<>();
ping.put("eventId", uuid32());
ping.put("event", "ping");
ping.put("flowGuid", flowGuid);
ping.put("occurredTime", System.currentTimeMillis());
template.exchange(callbackUrl, HttpMethod.POST, new HttpEntity<>(ping, headers), String.class);
} catch (Exception e) {
Throwable root = rootCause(e);
log.warn("[flow-center] 回调地址探活失败:url={} cause={}", callbackUrl, root.toString());
throw new BizException(ErrorCode.PARAM_ERROR, "回调地址不可达:" + root.getMessage());
}
}
private static Throwable rootCause(Throwable e) {
Throwable cur = e;
while (cur.getCause() != null && cur.getCause() != cur) {
cur = cur.getCause();
}
return cur;
}
/** submitterId 本校校验(防跨租户脏数据,概览 §5.5;UserBase 不可达 fail-closed) */
private void requireSchoolMember(Long schId, Integer submitterId) {
if (!submitterCheckEnabled) {
log.warn("[flow-center] 提交人本校校验已关闭(仅限本地开发): schId={} submitterId={}", schId, submitterId);
return;
}
BoaUser user;
try {
user = userClient.getUserById(String.valueOf(submitterId));
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "提交人身份核验失败(UserBase 不可达)");
}
if (user == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "提交人不存在:" + submitterId);
}
Long userSchId = user.getSchool() == null || user.getSchool().getId() == null
? null : Long.valueOf(user.getSchool().getId());
if (!schId.equals(userSchId)) {
throw new BizException(ErrorCode.FORBIDDEN, "提交人不属于该学校(防跨租户脏数据)");
}
}
private OapiModels.FlowSummaryVo toSummary(FlwDef def) {
OapiModels.FlowSummaryVo vo = new OapiModels.FlowSummaryVo();
vo.setFlowGuid(def.getFlowGuid());
vo.setName(def.getName());
vo.setVersion(def.getVersion());
vo.setNodeSummary(FlowEngine.nodeSummary(flowEngine.parseModel(def)));
return vo;
}
/**
* 办件出参:轨迹(含 assigneeId,拍板项#3 办理人放行判定依赖)。
*
* <p>2026-09-18:留痕事件行(6 发起/重新提交、7 撤回)<b>不进</b>本出参——它是"办理人与进度"
* 的对账口径(assigneeIds 明确是"办理人集合",提交人另有 submitterId 字段,混进来会让
* 消费方把提交人当办理人)。撤回/重提的对外可见性走 /oapi/flow/progress 的 steps(人读轨迹)。
*/
public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) {
OapiModels.InstanceVo vo = new OapiModels.InstanceVo();
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());
vo.setCurrentNodes(instance.getCurrentNodes());
vo.setSubmitterId(instance.getSubmitterId());
vo.setSubmitterName(instance.getSubmitterName());
vo.setSubmitTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
vo.setFinishTime(instance.getFinishTime() == null ? null : instance.getFinishTime().toString());
List<com.yunhai.flowcenter.entity.FlwTask> tasks =
taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
List<Map<String, Object>> trace = new ArrayList<>();
Set<Integer> assigneeIds = new LinkedHashSet<>();
for (com.yunhai.flowcenter.entity.FlwTask t : tasks) {
if (FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) {
continue;
}
assigneeIds.add(t.getAssigneeId());
Map<String, Object> row = new HashMap<>();
row.put("taskId", t.getId());
row.put("nodeId", t.getNodeId());
row.put("nodeName", t.getNodeName());
row.put("assigneeId", t.getAssigneeId());
row.put("assigneeName", t.getAssigneeName());
row.put("status", t.getStatus());
row.put("opinion", t.getOpinion());
row.put("arriveTime", t.getArriveTime() == null ? null : t.getArriveTime().toString());
row.put("handleTime", t.getHandleTime() == null ? null : t.getHandleTime().toString());
trace.add(row);
}
try {
vo.setTraceJson(objectMapper.writeValueAsString(trace));
} catch (Exception e) {
vo.setTraceJson("[]");
}
vo.setAssigneeIds(new ArrayList<>(assigneeIds));
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
vo.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(flowEngine.parseModel(def)));
return vo;
}
private String asSnapshotString(Object bizSnapshot) {
if (bizSnapshot == null) {
return null;
}
if (bizSnapshot instanceof String) {
return (String) bizSnapshot;
}
try {
return objectMapper.writeValueAsString(bizSnapshot);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "bizSnapshot 不是可序列化的摘要内容");
}
}
private String asFormDataString(Map<String, Object> formData) {
if (formData == null || formData.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(formData);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "formData 不是合法的 JSON 对象");
}
}
private static String uuid32() {
return UUID.randomUUID().toString().replace("-", "").toLowerCase();
}
}