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 appIdentityProvider; /** * v2 发起的事务模板。用 setter/字段注入而不是加进构造器:本类构造被 * {@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 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 listFlows(Long schId) { List 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)。 * *

与 {@link #apply} 的三点不同,都是"幂等修复"要求而不是新功能:

*
    *
  1. 事务边界收窄到一次发起:用 {@code TransactionTemplate} 而不是方法级 * {@code @Transactional}——唯一键冲突必须在事务外重新读既有实例 * (撞键后当前事务已被标记回滚,事务内再读不可靠),这与 svc-hall * {@code AnswerService#executeSubmit} 同一形态;
  2. *
  3. 并发撞键后先核归属再返回:读到的实例若属于另一应用/另一提交者/另一流程线, * 一律拒绝,不把别人的 bizType+bizId 当自己的结果;
  4. *
  5. 回带稳定分支码(CREATED/IDEMPOTENT/SAME_REQUEST/STALE_ROUND/REPLAY_ONLY/ * RESTARTED/CONCURRENT_CREATED),消费方按码处置,不再猜"duplicated 是哪一种重复"。
  6. *
* *

已发布定义校验、提交人本校校验与旧路径同一条,不放宽;流程节点算法一行未改。

*/ 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 * 重启即新一轮申报。 * *

F01 整改(2026-10-29):本方法不再有直通分支。旧实现 * {@code if (guard == null || !guard.isEnforce() || appKey == null) return;} 让"闸门未装配"、 * "灰度开关关闭"、"缺验签主体"三种情况都变成放行。现三者全部 fail-closed 拒绝。

*/ 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; } /** * 并发撞键后的收口:重读既有实例 → 先核归属 → 同 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 missingRequiredAssignNodes(FlowDefModel model, List assigns) { java.util.Set submitted = new java.util.HashSet<>(); if (assigns != null) { for (OapiModels.AssignItem item : assigns) { if (item.getNodeId() != null) { submitted.add(item.getNodeId()); } } } List 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 assigns, Integer submitterId) { FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); // T06(2026-10-25)required 覆盖终审:遍历定义里全部开启且必选的指派节点—— // 不能只遍历请求里出现的项,更不能让空 assigns 绕过 required 检查(审计 §7.4 点名)。 List 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>> snapshot = new java.util.LinkedHashMap<>(); Set 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 uids = new LinkedHashSet<>(item.getUserIds()); if (uids.contains(submitterId)) { throw new BizException(ErrorCode.PARAM_ERROR, "不允许指派发起人本人:" + item.getNodeId()); } // D11:手选人先逐人资格终审(存在/启用/校域),再核候选集——任一人不合格整体拒绝, // 快照与实例均不落;不能只信前端候选列表,也不能以“在候选 ID 中”替代资格。 List> rows = new ArrayList<>(); for (Integer uid : uids) { String userName = assigneeResolver.requireEligibleAssigneeName(schId, uid); Map row = new HashMap<>(); row.put("userId", uid); row.put("userName", userName); rows.add(row); } // 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝 List candidates = assigneeResolver.resolve(schId, node); Set 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 nodes = new ArrayList<>(); for (FlowDefModel.Node node : model.nodes) { if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) { continue; } List 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 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 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 p = taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size))); List instanceIds = p.getContent().stream() .map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList()); Map instances = instanceIds.isEmpty() ? java.util.Collections.emptyMap() : instanceRepository.findAllById(instanceIds).stream() .collect(java.util.stream.Collectors.toMap(FlwInstance::getId, i -> i)); List 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 pageInstances(Long schId, String bizType, Integer status, Integer page, Integer size) { if (schId == null) { throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId"); } Page p = instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size))); List 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 是"人 → 单据列表",本方法是"单据 → 我的待办", * 子应用据此判断"当前人是否该办件的待办人",不必再拉全量待办在前端过滤。 *

治理口径: *

    *
  • 只返回 {@code assigneeId=operatorId} 且 status=PENDING 的任务;他人待办与"办件不存在" * 一律出空数组(HTTP 200),出参不区分二者,避免用本端点探测别人的办件;
  • *
  • 租户收敛在定位查询本身({@code findBySchIdAndBizTypeAndBizId}),bizType 不匹配即空;
  • *
  • {@code outlets} 只为当前人的任务计算,且出边 >1 才透出(与 * {@code FlowQueryService.detail} 的办理面板口径一致——单出口无需选择,不给前端多余分支);
  • *
  • 不透出他人办理人、意见或定义内部 id;taskId 是办理必需的运行时身份,保留。
  • *
*/ 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 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 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 BasePageResult toPageResult(long total, List content, Integer page, Integer size) { BasePageResult 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 一并外溢)。治理口径: *
    *
  • 身份:{@code submitterId} 必传——应用间签名只证明"哪个应用在调",不证明"谁在撤", * 对端按 submitterId 与办件提交人**逐字复核**,杜绝业务应用越权代撤他人办件;
  • *
  • 租户:schId 收敛 + requireInstance 校验办件归属;
  • *
  • 状态:仅审批中(10) 且尚无任务被办理(引擎既有纪律,见 FlowInstanceService.withdraw);
  • *
  • 不校验 submitterId 的本校成员资格——提交人身份在发起时已按 §5.5 校验过,撤回不改变归属。
  • *
*/ @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} 一致: *
    *
  • 来源:{@code FlowOapiSourceScope} 已按实例 id 核验归属,非本应用发起的办件到不了这里;
  • *
  • 身份:{@code submitterId} 与办件提交人逐字复核——签名只证明"哪个应用在调";
  • *
  • 状态:仅 50 退回待修改(幂等:已是 10 视为已重提,回原实例不重开一代任务);
  • *
  • 轮次/版本:不改 applyRound、不改 defId/defVersion(D04/D05 在途固定版本);
  • *
  • 业务字段:FL 不做 Diff,{@code formData} 为空即沿用原快照。
  • *
*/ @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 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 activeNodes = new LinkedHashSet<>(); if (instance.getCurrentNodes() != null && !instance.getCurrentNodes().isEmpty()) { activeNodes.addAll(Arrays.asList(instance.getCurrentNodes().split(","))); } List rows = taskRepository.findByInstanceIdOrderByIdAsc(instance.getId()); boolean hasSubmitRow = rows.stream() .anyMatch(t -> FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId())); List steps = new ArrayList<>(); // 步骤发生时间(排序键):任务步=办理人行最晚时间(handleTime?:arriveTime),submit/事件步=动作时间, // 抄送步=最晚 sentTime——抄送步与任务步按真实时间交织,人读时间线不出现"抄送悬在最后"。 Map 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 eventSteps = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); // 步骤聚合:nodeId 保序;同节点多任务行(会签/退回重到/失效清理)并入同一多人步骤 Map 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 ccStepList = ccSteps(instance.getId(), stepTimes); if (!ccStepList.isEmpty()) { Map 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 ccSteps(Long instanceId, Map stepTimes) { List ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId); if (ccs.isEmpty()) { return new ArrayList<>(); } Map 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 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,鉴权语义不在此验证。 *

* 但探活请求必须带应用签名:回调端点挂在接收方 /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 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 办理人放行判定依赖)。 * *

2026-09-18:留痕事件行(6 发起/重新提交、7 撤回)不进本出参——它是"办理人与进度" * 的对账口径(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 tasks = taskRepository.findByInstanceIdOrderByIdAsc(instance.getId()); List> trace = new ArrayList<>(); Set assigneeIds = new LinkedHashSet<>(); for (com.yunhai.flowcenter.entity.FlwTask t : tasks) { if (FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) { continue; } assigneeIds.add(t.getAssigneeId()); Map 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 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(); } }