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.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.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; /** 已发布流程列表(每条流程线取最高已发布版本) */ public List listFlows(Long schId) { List out = new ArrayList<>(); for (FlwDef def : defRepository.findPublishedLatestPerGuid(schId)) { out.add(toSummary(def)); } return out; } /** 流程详情回显(未发布/不存在返回 null,消费方按"未绑定流程"降级) */ public OapiModels.FlowSummaryVo getFlow(Long schId, String flowGuid) { 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) { if (schId == null || body.getFlowGuid() == null || body.getBizType() == null || body.getBizId() == null || body.getSubmitterId() == null) { throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/bizType/bizId/submitterId"); } requireSchoolMember(schId, body.getSubmitterId()); FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布:" + body.getFlowGuid())); // 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()); String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); 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); FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd); OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo(); vo.setInstanceId(outcome.getInstanceId()); vo.setDuplicated(outcome.isDuplicated()); 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) 拒绝发起。 */ private String renderAssignJson(Long schId, FlwDef def, List assigns, Integer submitterId) { if (assigns == null || assigns.isEmpty()) { return null; } FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); 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()); } // 候选集取 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()); } } List> rows = new ArrayList<>(); for (Integer uid : uids) { String userName = assigneeResolver.requireUserName(uid); Map row = new HashMap<>(); row.put("userId", uid); row.put("userName", userName); rows.add(row); } 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) { if (schId == null || flowGuid == null || submitterId == null) { throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId"); } FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode()) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布:" + flowGuid)); 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 = instanceRepository.findBySchIdAndBizTypeAndBizId(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"); } Page p = taskRepository.pageTodoBiz(schId, 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.pageByBiz(schId, 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); } /** * 同意(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) { 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) { requireOperator(schId, taskId, operatorId, operatorName); return taskService.handle(schId, taskId, false, opinion, operatorId); } 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); } /** * 办件进度(结构化轨迹):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 = instanceRepository.findBySchIdAndBizTypeAndBizId(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 (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 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()); } 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.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(); } }