build / build-push (push) Successful in 47s
- NOTIFY:TYPE_NOTIFY 瞬时抄送(flw_cc 逐人留痕 + CC_SENT 事件 + 站内通知不加待办角标)、/api/flow/cc/my、轨迹 kind=CC 与 oapi progress 抄送步、示意图青框卡片
- CONDITION:flw_router 判断服务注册 + flw_route_log 决策留痕(REQUIRES_NEW,reroute 回滚不丢失败行)+ 同步判断调用(服务目录寻址 + 平台签名,3s×3 退避重试)+ 失败挂起/管理端重跑(不配默认出口)+ 可选事件 flow.route.decided
- JOIN scope:branch_key 路径前缀编码 + scope 级到齐放行 + DefValidator SPLIT/JOIN 配对与键长守卫(存量无点号键零迁移,历史直连图回退实例级口径)
- 管理端:/api/flow/admin/router/* CRUD + POST /api/flow/admin/instance/{id}/reroute
- 生产 SQL:sql/20260923_create_flw_cc.sql(含 flw_task 时间列升 DATETIME(3))、20260924_create_flw_router_and_route_log.sql
- 测试:全量 125 绿(FlowEngineIT 24,含条件/嵌套并行/退回重走用例)
753 lines
38 KiB
Java
753 lines
38 KiB
Java
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.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;
|
||
|
||
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 ObjectMapper objectMapper;
|
||
private final OpenApiUserClient userClient;
|
||
private final DiagramTokenService tokenService;
|
||
|
||
/** 本应用身份(回调探活补应用签名头;未装配时裸发,接收方 /oapi/* 会 401) */
|
||
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
|
||
appIdentityProvider;
|
||
|
||
/** 已发布流程列表(每条流程线取最高已发布版本) */
|
||
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
|
||
List<OapiModels.FlowSummaryVo> 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) */
|
||
@Transactional
|
||
public OapiModels.ApplyResultVo apply(Long schId, 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()));
|
||
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()));
|
||
FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd);
|
||
OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo();
|
||
vo.setInstanceId(outcome.getInstanceId());
|
||
vo.setDuplicated(outcome.isDuplicated());
|
||
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<OapiModels.TodoTaskVo> pageTodoTasks(Long schId, Integer assigneeId,
|
||
String bizType, Integer page, Integer size) {
|
||
if (schId == null || assigneeId == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId");
|
||
}
|
||
Page<FlwTask> p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType),
|
||
PageRequest.of(normPage(page), normSize(size)));
|
||
List<Long> instanceIds = p.getContent().stream()
|
||
.map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList());
|
||
Map<Long, FlwInstance> instances = instanceIds.isEmpty()
|
||
? java.util.Collections.emptyMap()
|
||
: instanceRepository.findAllById(instanceIds).stream()
|
||
.collect(java.util.stream.Collectors.toMap(FlwInstance::getId, i -> i));
|
||
List<OapiModels.TodoTaskVo> items = p.getContent().stream()
|
||
.map(t -> toTodoTaskVo(t, instances.get(t.getInstanceId())))
|
||
.collect(java.util.stream.Collectors.toList());
|
||
return toPageResult(p.getTotalElements(), items, page, size);
|
||
}
|
||
|
||
/** 办件分页(oapi,应用侧列表/对账):bizType/status 可选过滤 */
|
||
public BasePageResult<OapiModels.InstanceItemVo> pageInstances(Long schId, String bizType, Integer status,
|
||
Integer page, Integer size) {
|
||
if (schId == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
|
||
}
|
||
Page<FlwInstance> p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status,
|
||
PageRequest.of(normPage(page), normSize(size)));
|
||
List<OapiModels.InstanceItemVo> items = p.getContent().stream()
|
||
.map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList());
|
||
return toPageResult(p.getTotalElements(), items, page, size);
|
||
}
|
||
|
||
/**
|
||
* 同意(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 <T> BasePageResult<T> toPageResult(long total, List<T> content, Integer page, Integer size) {
|
||
BasePageResult<T> result = new BasePageResult<>();
|
||
result.setTotal((int) total);
|
||
result.setContent(content);
|
||
int pageIndex = page == null || page < 1 ? 1 : page;
|
||
int pageSize = size == null || size < 1 ? content.size() : size;
|
||
result.setPageIndex(pageIndex);
|
||
result.setPageSize(pageSize);
|
||
result.setTotalPages(pageSize == 0 ? 0 : (int) Math.ceil((double) total / pageSize));
|
||
result.setHasNext((long) pageIndex * pageSize < total);
|
||
return result;
|
||
}
|
||
|
||
private static String emptyToNull(String s) {
|
||
return s == null || s.trim().isEmpty() ? null : s.trim();
|
||
}
|
||
|
||
/**
|
||
* 撤回(托管接入件,2026-09-16 补:原先只有 CAS 用户面 {@code /api/flow/instance/{id}/withdraw},
|
||
* 业务应用只能让浏览器直调,票据与 CORS 一并外溢)。治理口径:
|
||
* <ul>
|
||
* <li>身份:{@code submitterId} 必传——应用间签名只证明"哪个应用在调",不证明"谁在撤",
|
||
* 对端按 submitterId 与办件提交人**逐字复核**,杜绝业务应用越权代撤他人办件;</li>
|
||
* <li>租户:schId 收敛 + requireInstance 校验办件归属;</li>
|
||
* <li>状态:仅审批中(10) 且尚无任务被办理(引擎既有纪律,见 FlowInstanceService.withdraw);</li>
|
||
* <li>不校验 submitterId 的本校成员资格——提交人身份在发起时已按 §5.5 校验过,撤回不改变归属。</li>
|
||
* </ul>
|
||
*/
|
||
@Transactional
|
||
public void withdraw(Long schId, Long instanceId, Integer submitterId) {
|
||
if (schId == null || instanceId == null || submitterId == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/instanceId/submitterId");
|
||
}
|
||
FlwInstance instance = instanceService.requireInstance(schId, instanceId);
|
||
if (!submitterId.equals(instance.getSubmitterId())) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "仅提交人本人可撤回");
|
||
}
|
||
instanceService.withdraw(schId, instanceId);
|
||
}
|
||
|
||
/**
|
||
* 办件进度(结构化轨迹):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;
|
||
}
|
||
|
||
/** 进度/换图共用的办件定位(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<String> activeNodes = new LinkedHashSet<>();
|
||
if (instance.getCurrentNodes() != null && !instance.getCurrentNodes().isEmpty()) {
|
||
activeNodes.addAll(Arrays.asList(instance.getCurrentNodes().split(",")));
|
||
}
|
||
List<FlwTask> rows = taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
|
||
boolean hasSubmitRow = rows.stream()
|
||
.anyMatch(t -> FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId()));
|
||
List<OapiModels.ProgressStepVo> steps = new ArrayList<>();
|
||
// 步骤发生时间(排序键):任务步=办理人行最晚时间(handleTime?:arriveTime),submit/事件步=动作时间,
|
||
// 抄送步=最晚 sentTime——抄送步与任务步按真实时间交织,人读时间线不出现"抄送悬在最后"。
|
||
Map<OapiModels.ProgressStepVo, java.time.LocalDateTime> stepTimes =
|
||
new java.util.IdentityHashMap<>();
|
||
// 第 0 步"发起申请"(对外时间线图3/图4口径):2026-09-18 起发起会留痕成真实任务行,
|
||
// 有真实行时由下面的循环出这一步(可见内容与这里的合成步一致);老办件没有真实行,
|
||
// 仍按办件快照合成,存量数据对外表现不变。
|
||
if (!hasSubmitRow) {
|
||
OapiModels.ProgressStepVo submit = submitStep(instance);
|
||
steps.add(submit);
|
||
stepTimes.put(submit, instance.getSubmitTime());
|
||
}
|
||
// 轨迹留痕事件行(6 发起/重新提交、7 撤回)单独成步;步骤 label 直接用事件语义,
|
||
// 不参与下面的"任一拒绝>任一同意>全失效"汇总(否则会被判成"失效")。
|
||
Set<OapiModels.ProgressStepVo> eventSteps =
|
||
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||
|
||
// 步骤聚合:nodeId 保序;同节点多任务行(会签/退回重到/失效清理)并入同一多人步骤
|
||
Map<String, OapiModels.ProgressStepVo> stepsByNode = new HashMap<>();
|
||
for (FlwTask t : rows) {
|
||
if (FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) {
|
||
OapiModels.ProgressStepVo es = eventStep(t);
|
||
eventSteps.add(es);
|
||
steps.add(es);
|
||
stepTimes.put(es, t.getHandleTime() != null ? t.getHandleTime() : t.getArriveTime());
|
||
continue;
|
||
}
|
||
OapiModels.ProgressStepVo step = stepsByNode.computeIfAbsent(t.getNodeId(), k -> {
|
||
OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo();
|
||
s.setNodeId(t.getNodeId());
|
||
s.setNodeName(t.getNodeName());
|
||
s.setAssignees(new ArrayList<>());
|
||
steps.add(s);
|
||
return s;
|
||
});
|
||
step.getAssignees().add(taskVo(t));
|
||
}
|
||
// 步骤 label:在办节点=待审核,其余取办理人行的汇总(任一拒绝=已拒绝 > 任一同意=已同意 > 全失效=失效)
|
||
for (OapiModels.ProgressStepVo s : steps) {
|
||
if (eventSteps.contains(s)) {
|
||
continue;
|
||
}
|
||
s.setActive(activeNodes.contains(s.getNodeId()));
|
||
s.setLabel(s.isActive() ? "待审核" : stepLabel(s.getAssignees()));
|
||
java.time.LocalDateTime latest = null;
|
||
for (OapiModels.ProgressTaskVo r : s.getAssignees()) {
|
||
java.time.LocalDateTime t = r.getHandleTime() != null
|
||
? java.time.LocalDateTime.parse(r.getHandleTime())
|
||
: (r.getArriveTime() == null ? null : java.time.LocalDateTime.parse(r.getArriveTime()));
|
||
if (t != null && (latest == null || t.isAfter(latest))) {
|
||
latest = t;
|
||
}
|
||
}
|
||
stepTimes.put(s, latest);
|
||
}
|
||
// 抄送步(有才交织排序):无抄送时保持既有拼装顺序原样输出(存量办件出参零变化);
|
||
// 有抄送时按 (发生时间, 秩) 排——既有步=0、抄送步=1(同秒 DATETIME 分不出先后,任务步
|
||
// 恒在前;不给既有步分秩是因为驳回/重提步同秒靠 id 定序即真实先后,秩会颠倒)。
|
||
List<OapiModels.ProgressStepVo> ccStepList = ccSteps(instance.getId(), stepTimes);
|
||
if (!ccStepList.isEmpty()) {
|
||
Map<OapiModels.ProgressStepVo, Integer> ranks = new java.util.IdentityHashMap<>();
|
||
for (OapiModels.ProgressStepVo s : steps) {
|
||
ranks.put(s, 0);
|
||
}
|
||
for (OapiModels.ProgressStepVo s : ccStepList) {
|
||
ranks.put(s, 1);
|
||
}
|
||
steps.addAll(ccStepList);
|
||
steps.sort(java.util.Comparator
|
||
.comparing((OapiModels.ProgressStepVo s) -> stepTimes.get(s),
|
||
java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
|
||
.thenComparing(s -> ranks.get(s)));
|
||
}
|
||
vo.setSteps(steps);
|
||
return vo;
|
||
}
|
||
|
||
/**
|
||
* 抄送步(2026-09-23 NOTIFY 节点):按 nodeId 聚合(多人并排),label="抄送"、
|
||
* 参与行 status=1/已抄送(复用"已办过"取值,消费方无需适配新状态);时间取 sentTime,
|
||
* 与任务步按真实发生时间交织排序。无抄送返回空列表(存量办件出参零变化)。
|
||
*/
|
||
private List<OapiModels.ProgressStepVo> ccSteps(Long instanceId,
|
||
Map<OapiModels.ProgressStepVo, java.time.LocalDateTime> stepTimes) {
|
||
List<FlwCc> ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||
if (ccs.isEmpty()) {
|
||
return new ArrayList<>();
|
||
}
|
||
Map<String, OapiModels.ProgressStepVo> byNode = new java.util.LinkedHashMap<>();
|
||
for (FlwCc c : ccs) {
|
||
OapiModels.ProgressStepVo step = byNode.computeIfAbsent(c.getNodeId(), k -> {
|
||
OapiModels.ProgressStepVo s = new OapiModels.ProgressStepVo();
|
||
s.setNodeId(c.getNodeId());
|
||
s.setNodeName(c.getNodeName() == null || c.getNodeName().isEmpty() ? "抄送" : c.getNodeName());
|
||
s.setActive(false);
|
||
s.setLabel("抄送");
|
||
s.setAssignees(new ArrayList<>());
|
||
stepTimes.put(s, c.getSentTime());
|
||
return s;
|
||
});
|
||
OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo();
|
||
row.setUserId(c.getUserId());
|
||
row.setUserName(c.getUserName());
|
||
row.setStatus(1);
|
||
row.setStatusLabel("已抄送");
|
||
row.setArriveTime(c.getSentTime() == null ? null : c.getSentTime().toString());
|
||
step.getAssignees().add(row);
|
||
java.time.LocalDateTime t = stepTimes.get(step);
|
||
if (c.getSentTime() != null && (t == null || c.getSentTime().isAfter(t))) {
|
||
stepTimes.put(step, c.getSentTime());
|
||
}
|
||
}
|
||
return new ArrayList<>(byNode.values());
|
||
}
|
||
|
||
/** 合成"发起申请"步(老办件:无真实留痕行,取办件快照的提交人/提交时间) */
|
||
private OapiModels.ProgressStepVo submitStep(FlwInstance instance) {
|
||
OapiModels.ProgressStepVo submit = new OapiModels.ProgressStepVo();
|
||
submit.setNodeId("submit");
|
||
submit.setNodeName("发起申请");
|
||
submit.setActive(false);
|
||
submit.setLabel("发起申请");
|
||
OapiModels.ProgressTaskVo submitter = new OapiModels.ProgressTaskVo();
|
||
submitter.setUserId(instance.getSubmitterId());
|
||
submitter.setUserName(instance.getSubmitterName());
|
||
submitter.setStatus(1);
|
||
submitter.setStatusLabel("已提交");
|
||
submitter.setArriveTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
|
||
submit.setAssignees(new ArrayList<>(java.util.Collections.singletonList(submitter)));
|
||
return submit;
|
||
}
|
||
|
||
/**
|
||
* 留痕事件步(发起/重新提交/撤回):nodeId 用保留值 submit/resubmit/withdraw。
|
||
* 发起步保留原合成步的口径(nodeName/label=发起申请、参与行 status=1/已提交),
|
||
* 使新老办件对外输出一致;撤回与重新提交是本轮新增的对外可见动作。
|
||
*/
|
||
private OapiModels.ProgressStepVo eventStep(FlwTask t) {
|
||
boolean submitted = FlwEnums.TaskStatus.SUBMITTED.getCode() == t.getStatus();
|
||
OapiModels.ProgressStepVo step = new OapiModels.ProgressStepVo();
|
||
step.setNodeId(t.getNodeId());
|
||
step.setNodeName(t.getNodeName());
|
||
step.setActive(false);
|
||
step.setLabel(!submitted ? "已撤回"
|
||
: (FlwEnums.TaskStatus.NODE_SUBMIT.equals(t.getNodeId()) ? "发起申请" : "已提交"));
|
||
OapiModels.ProgressTaskVo vo = taskVo(t);
|
||
if (submitted) {
|
||
// 发起/重新提交沿用"已提交"文案(status=1 与老合成步一致,消费方无需适配)
|
||
vo.setStatus(1);
|
||
vo.setStatusLabel("已提交");
|
||
} else {
|
||
vo.setStatusLabel("已撤回");
|
||
}
|
||
step.setAssignees(new ArrayList<>(java.util.Collections.singletonList(vo)));
|
||
return step;
|
||
}
|
||
|
||
/** 任务行 → 对外步骤里的办理人行(展示时间截断到秒:列已升毫秒精度,对外口径不变) */
|
||
private OapiModels.ProgressTaskVo taskVo(FlwTask t) {
|
||
OapiModels.ProgressTaskVo row = new OapiModels.ProgressTaskVo();
|
||
row.setTaskId(t.getId());
|
||
row.setUserId(t.getAssigneeId());
|
||
row.setUserName(t.getAssigneeName());
|
||
row.setStatus(t.getStatus());
|
||
row.setStatusLabel(taskLabel(t.getStatus()));
|
||
row.setOpinion(t.getOpinion());
|
||
row.setArriveTime(t.getArriveTime() == null ? null
|
||
: t.getArriveTime().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
|
||
row.setHandleTime(t.getHandleTime() == null ? null
|
||
: t.getHandleTime().truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString());
|
||
row.setEdgeName(t.getEdgeName());
|
||
return row;
|
||
}
|
||
|
||
private String taskLabel(Integer status) {
|
||
if (status == null) {
|
||
return "";
|
||
}
|
||
switch (status) {
|
||
case 0: return "待审核";
|
||
case 1: return "已同意";
|
||
case 2: return "已拒绝";
|
||
case 3: return "失效";
|
||
case 4: return "已退回";
|
||
case 5: return "已移交";
|
||
case 6: return "已提交";
|
||
case 7: return "已撤回";
|
||
default: return String.valueOf(status);
|
||
}
|
||
}
|
||
|
||
private String stepLabel(List<OapiModels.ProgressTaskVo> rows) {
|
||
boolean denied = false, approved = false, invalid = true;
|
||
for (OapiModels.ProgressTaskVo r : rows) {
|
||
int s = r.getStatus() == null ? -1 : r.getStatus();
|
||
if (s == 2) {
|
||
denied = true;
|
||
}
|
||
if (s == 1) {
|
||
approved = true;
|
||
}
|
||
if (s == 0 || s == 1 || s == 2 || s == 4 || s == 5) {
|
||
invalid = false;
|
||
}
|
||
}
|
||
if (denied) {
|
||
return "已拒绝";
|
||
}
|
||
if (approved) {
|
||
return "已同意";
|
||
}
|
||
return invalid ? "失效" : "待审核";
|
||
}
|
||
|
||
/** 回调注册(uk: sch+appKey+flowGuid;重复注册=更新配置并重置 secret;注册时探活) */
|
||
@Transactional
|
||
public OapiModels.CallbackRegResultVo registerCallback(Long schId, String appKey,
|
||
OapiModels.CallbackRegBody body) {
|
||
if (schId == null || appKey == null || body.getFlowGuid() == null || body.getCallbackUrl() == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/callbackUrl");
|
||
}
|
||
// 绑定的流程须存在(草稿也允许先注册,发布后事件即按订阅分发)
|
||
defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, body.getFlowGuid())
|
||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||
"流程不存在:" + body.getFlowGuid()));
|
||
String secret = uuid32();
|
||
FlwCallbackReg reg = regRepository.findBySchIdAndAppKeyAndFlowGuid(schId, appKey, body.getFlowGuid())
|
||
.orElse(null);
|
||
if (reg == null) {
|
||
reg = new FlwCallbackReg();
|
||
reg.setSchId(schId);
|
||
reg.setAppKey(appKey);
|
||
reg.setFlowGuid(body.getFlowGuid());
|
||
}
|
||
reg.setCallbackUrl(body.getCallbackUrl());
|
||
reg.setEventTypes(body.getEventTypes());
|
||
reg.setBasePath(body.getBasePath());
|
||
reg.setSecret(secret);
|
||
reg.setStatus(0);
|
||
regRepository.saveAndFlush(reg);
|
||
probeReachable(body.getCallbackUrl(), body.getBasePath(), body.getFlowGuid(), secret);
|
||
OapiModels.CallbackRegResultVo vo = new OapiModels.CallbackRegResultVo();
|
||
vo.setSecret(secret);
|
||
return vo;
|
||
}
|
||
|
||
/**
|
||
* 可达性探活(概览 §十:拦住配错域名的低级故障)。只做连接级判定:
|
||
* 连不上(未知主机/拒绝/超时)视为不可达并拒绝注册;任何 HTTP 状态码(含 401/404)
|
||
* 都算可达——接收方验签过滤器在拿到 secret 前必然拒绝 ping,鉴权语义不在此验证。
|
||
* <p>
|
||
* 但探活请求必须带应用签名:回调端点挂在接收方 /oapi/* 下(强制应用验签)时,
|
||
* 裸 ping 会被 401 拦下,而 RestTemplate 对 4xx 抛异常 → 误判"不可达"拒绝注册。
|
||
*/
|
||
private void probeReachable(String callbackUrl, String basePath, String flowGuid, String secret) {
|
||
try {
|
||
RestTemplate template = CallbackAppSigner.signedTemplate(
|
||
appIdentityProvider.getIfAvailable(), basePath, 3000, 3000);
|
||
HttpHeaders headers = new HttpHeaders();
|
||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||
headers.set("X-Flw-EventId", uuid32());
|
||
headers.set("X-Flw-Timestamp", String.valueOf(System.currentTimeMillis()));
|
||
headers.set("X-Flw-Nonce", uuid32().substring(0, 16));
|
||
headers.set("X-Flw-Signature", "ping");
|
||
Map<String, Object> ping = new HashMap<>();
|
||
ping.put("eventId", uuid32());
|
||
ping.put("event", "ping");
|
||
ping.put("flowGuid", flowGuid);
|
||
ping.put("occurredTime", System.currentTimeMillis());
|
||
template.exchange(callbackUrl, HttpMethod.POST, new HttpEntity<>(ping, headers), String.class);
|
||
} catch (Exception e) {
|
||
Throwable root = rootCause(e);
|
||
log.warn("[flow-center] 回调地址探活失败:url={} cause={}", callbackUrl, root.toString());
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "回调地址不可达:" + root.getMessage());
|
||
}
|
||
}
|
||
|
||
private static Throwable rootCause(Throwable e) {
|
||
Throwable cur = e;
|
||
while (cur.getCause() != null && cur.getCause() != cur) {
|
||
cur = cur.getCause();
|
||
}
|
||
return cur;
|
||
}
|
||
|
||
/** submitterId 本校校验(防跨租户脏数据,概览 §5.5;UserBase 不可达 fail-closed) */
|
||
private void requireSchoolMember(Long schId, Integer submitterId) {
|
||
if (!submitterCheckEnabled) {
|
||
log.warn("[flow-center] 提交人本校校验已关闭(仅限本地开发): schId={} submitterId={}", schId, submitterId);
|
||
return;
|
||
}
|
||
BoaUser user;
|
||
try {
|
||
user = userClient.getUserById(String.valueOf(submitterId));
|
||
} catch (Exception e) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "提交人身份核验失败(UserBase 不可达)");
|
||
}
|
||
if (user == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "提交人不存在:" + submitterId);
|
||
}
|
||
Long userSchId = user.getSchool() == null || user.getSchool().getId() == null
|
||
? null : Long.valueOf(user.getSchool().getId());
|
||
if (!schId.equals(userSchId)) {
|
||
throw new BizException(ErrorCode.FORBIDDEN, "提交人不属于该学校(防跨租户脏数据)");
|
||
}
|
||
}
|
||
|
||
private OapiModels.FlowSummaryVo toSummary(FlwDef def) {
|
||
OapiModels.FlowSummaryVo vo = new OapiModels.FlowSummaryVo();
|
||
vo.setFlowGuid(def.getFlowGuid());
|
||
vo.setName(def.getName());
|
||
vo.setVersion(def.getVersion());
|
||
vo.setNodeSummary(FlowEngine.nodeSummary(flowEngine.parseModel(def)));
|
||
return vo;
|
||
}
|
||
|
||
/**
|
||
* 办件出参:轨迹(含 assigneeId,拍板项#3 办理人放行判定依赖)。
|
||
*
|
||
* <p>2026-09-18:留痕事件行(6 发起/重新提交、7 撤回)<b>不进</b>本出参——它是"办理人与进度"
|
||
* 的对账口径(assigneeIds 明确是"办理人集合",提交人另有 submitterId 字段,混进来会让
|
||
* 消费方把提交人当办理人)。撤回/重提的对外可见性走 /oapi/flow/progress 的 steps(人读轨迹)。
|
||
*/
|
||
public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) {
|
||
OapiModels.InstanceVo vo = new OapiModels.InstanceVo();
|
||
vo.setInstanceId(instance.getId());
|
||
vo.setStatus(instance.getStatus());
|
||
vo.setVersion(instance.getVersion());
|
||
vo.setBizTitle(instance.getBizTitle());
|
||
vo.setCurrentNodes(instance.getCurrentNodes());
|
||
vo.setSubmitterId(instance.getSubmitterId());
|
||
vo.setSubmitterName(instance.getSubmitterName());
|
||
vo.setSubmitTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString());
|
||
vo.setFinishTime(instance.getFinishTime() == null ? null : instance.getFinishTime().toString());
|
||
List<com.yunhai.flowcenter.entity.FlwTask> tasks =
|
||
taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
|
||
List<Map<String, Object>> trace = new ArrayList<>();
|
||
Set<Integer> assigneeIds = new LinkedHashSet<>();
|
||
for (com.yunhai.flowcenter.entity.FlwTask t : tasks) {
|
||
if (FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) {
|
||
continue;
|
||
}
|
||
assigneeIds.add(t.getAssigneeId());
|
||
Map<String, Object> row = new HashMap<>();
|
||
row.put("taskId", t.getId());
|
||
row.put("nodeId", t.getNodeId());
|
||
row.put("nodeName", t.getNodeName());
|
||
row.put("assigneeId", t.getAssigneeId());
|
||
row.put("assigneeName", t.getAssigneeName());
|
||
row.put("status", t.getStatus());
|
||
row.put("opinion", t.getOpinion());
|
||
row.put("arriveTime", t.getArriveTime() == null ? null : t.getArriveTime().toString());
|
||
row.put("handleTime", t.getHandleTime() == null ? null : t.getHandleTime().toString());
|
||
trace.add(row);
|
||
}
|
||
try {
|
||
vo.setTraceJson(objectMapper.writeValueAsString(trace));
|
||
} catch (Exception e) {
|
||
vo.setTraceJson("[]");
|
||
}
|
||
vo.setAssigneeIds(new ArrayList<>(assigneeIds));
|
||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||
vo.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(flowEngine.parseModel(def)));
|
||
return vo;
|
||
}
|
||
|
||
private String asSnapshotString(Object bizSnapshot) {
|
||
if (bizSnapshot == null) {
|
||
return null;
|
||
}
|
||
if (bizSnapshot instanceof String) {
|
||
return (String) bizSnapshot;
|
||
}
|
||
try {
|
||
return objectMapper.writeValueAsString(bizSnapshot);
|
||
} catch (Exception e) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "bizSnapshot 不是可序列化的摘要内容");
|
||
}
|
||
}
|
||
|
||
private String asFormDataString(Map<String, Object> formData) {
|
||
if (formData == null || formData.isEmpty()) {
|
||
return null;
|
||
}
|
||
try {
|
||
return objectMapper.writeValueAsString(formData);
|
||
} catch (Exception e) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "formData 不是合法的 JSON 对象");
|
||
}
|
||
}
|
||
|
||
private static String uuid32() {
|
||
return UUID.randomUUID().toString().replace("-", "").toLowerCase();
|
||
}
|
||
}
|