996 lines
52 KiB
Java
996 lines
52 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.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<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):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<OapiModels.AssignItem> assigns,
|
||
Integer submitterId) {
|
||
if (assigns == null || assigns.isEmpty()) {
|
||
return null;
|
||
}
|
||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||
Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>();
|
||
Set<String> seenNodeIds = new LinkedHashSet<>();
|
||
for (OapiModels.AssignItem item : assigns) {
|
||
if (item.getNodeId() == null || seenNodeIds.contains(item.getNodeId())) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"指派 nodeId 缺失或重复:" + item.getNodeId());
|
||
}
|
||
seenNodeIds.add(item.getNodeId());
|
||
FlowDefModel.Node node = model.node(item.getNodeId());
|
||
if (node == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR, "指派节点不存在:" + item.getNodeId());
|
||
}
|
||
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"节点未开启发起时指派:" + item.getNodeId());
|
||
}
|
||
if (item.getUserIds() == null || item.getUserIds().isEmpty()) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"指派人员为空:" + item.getNodeId());
|
||
}
|
||
Set<Integer> uids = new LinkedHashSet<>(item.getUserIds());
|
||
if (uids.contains(submitterId)) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"不允许指派发起人本人:" + item.getNodeId());
|
||
}
|
||
// 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝
|
||
List<AssigneeRef> candidates = assigneeResolver.resolve(schId, node);
|
||
Set<Integer> candidateIds = new LinkedHashSet<>();
|
||
for (AssigneeRef ref : candidates) {
|
||
candidateIds.add(ref.getUserId());
|
||
}
|
||
for (Integer uid : uids) {
|
||
if (!candidateIds.contains(uid)) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"指派人员不在节点候选集内:node=" + item.getNodeId() + " userId=" + uid);
|
||
}
|
||
}
|
||
boolean single = FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode);
|
||
int n = uids.size();
|
||
if (single && n != 1) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"单选节点须恰指派 1 人:" + item.getNodeId());
|
||
}
|
||
if (!single) {
|
||
int min = node.assignPolicy.min == null ? 1 : node.assignPolicy.min;
|
||
int max = node.assignPolicy.max == null ? candidates.size() : node.assignPolicy.max;
|
||
if (n < min || n > max) {
|
||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||
"指派人数越界[" + min + "~" + max + "]:" + item.getNodeId());
|
||
}
|
||
}
|
||
List<Map<String, Object>> rows = new ArrayList<>();
|
||
for (Integer uid : uids) {
|
||
String userName = assigneeResolver.requireUserName(uid);
|
||
Map<String, Object> row = new HashMap<>();
|
||
row.put("userId", uid);
|
||
row.put("userName", userName);
|
||
rows.add(row);
|
||
}
|
||
snapshot.put(item.getNodeId(), rows);
|
||
}
|
||
try {
|
||
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<OapiModels.PreviewNodeVo> nodes = new ArrayList<>();
|
||
for (FlowDefModel.Node node : model.nodes) {
|
||
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
|
||
continue;
|
||
}
|
||
List<AssigneeRef> candidates;
|
||
try {
|
||
candidates = assigneeResolver.resolve(schId, node);
|
||
} catch (BizException e) {
|
||
log.warn("[flow-center] applyPreview 节点候选解析失败,降级空候选:flowGuid={} node={}:{}",
|
||
flowGuid, node.id, e.getMessage());
|
||
candidates = new ArrayList<>();
|
||
}
|
||
List<OapiModels.PreviewUserVo> users = new ArrayList<>();
|
||
for (AssigneeRef ref : candidates) {
|
||
if (submitterId.equals(ref.getUserId())) {
|
||
continue;
|
||
}
|
||
OapiModels.PreviewUserVo u = new OapiModels.PreviewUserVo();
|
||
u.setUserId(ref.getUserId());
|
||
u.setUserName(ref.getUserName());
|
||
users.add(u);
|
||
}
|
||
OapiModels.PreviewNodeVo nv = new OapiModels.PreviewNodeVo();
|
||
nv.setNodeId(node.id);
|
||
nv.setNodeName(node.name);
|
||
nv.setCountersign(node.countersign == null ? "ALL" : node.countersign);
|
||
nv.setCandidates(users);
|
||
OapiModels.PreviewSelectionVo selection = new OapiModels.PreviewSelectionVo();
|
||
selection.setMode(FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode)
|
||
? FlowDefModel.ASSIGN_MODE_SINGLE : FlowDefModel.ASSIGN_MODE_MULTI);
|
||
selection.setMin(node.assignPolicy.min == null ? 1 : node.assignPolicy.min);
|
||
selection.setMax(node.assignPolicy.max == null ? users.size() : node.assignPolicy.max);
|
||
selection.setRequired(node.assignPolicy.required == null
|
||
? Boolean.FALSE : node.assignPolicy.required);
|
||
nv.setSelection(selection);
|
||
nodes.add(nv);
|
||
}
|
||
vo.setNodes(nodes);
|
||
return vo;
|
||
}
|
||
|
||
/** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */
|
||
public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) {
|
||
FlwInstance instance = 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;
|
||
}
|
||
|
||
/**
|
||
* 办件进度嵌入页换址(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<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();
|
||
}
|
||
}
|