feat(oapi): 办件进度/流程图token出图/托管撤回 + CAS直嵌示意图(两会话产物合入)
build / build-push (push) Successful in 1m29s

This commit is contained in:
chenxing
2026-09-17 01:03:57 +08:00
parent b8441065d0
commit 6afc7d7b92
10 changed files with 978 additions and 1 deletions
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.callback.CallbackAppSigner;
import com.yunhai.flowcenter.entity.FlwCallbackReg;
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;
@@ -29,6 +30,7 @@ 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;
@@ -62,6 +64,7 @@ public class OapiFlowService {
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>
@@ -116,6 +119,193 @@ public class OapiFlowService {
return instance == null ? null : toInstanceVo(instance);
}
/**
* 撤回(托管接入件,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(",")));
}
// 第 0 步固定"发起申请"(对外时间线图3/图4口径)
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(vo.getSubmitTime());
submit.setAssignees(new ArrayList<>(java.util.Collections.singletonList(submitter)));
List<OapiModels.ProgressStepVo> steps = new ArrayList<>();
steps.add(submit);
// 步骤聚合:nodeId 保序;同节点多任务行(会签/退回重到/失效清理)并入同一多人步骤
Map<String, OapiModels.ProgressStepVo> stepsByNode = new HashMap<>();
for (FlwTask t : taskRepository.findByInstanceIdOrderByIdAsc(instance.getId())) {
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;
});
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().toString());
row.setHandleTime(t.getHandleTime() == null ? null : t.getHandleTime().toString());
row.setEdgeName(t.getEdgeName());
step.getAssignees().add(row);
}
// 步骤 label:在办节点=待审核,其余取办理人行的汇总(任一拒绝=已拒绝 > 任一同意=已同意 > 全失效=失效)
for (OapiModels.ProgressStepVo s : steps) {
if (s == submit) {
continue;
}
s.setActive(activeNodes.contains(s.getNodeId()));
s.setLabel(s.isActive() ? "待审核" : stepLabel(s.getAssignees()));
}
vo.setSteps(steps);
return vo;
}
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 "已移交";
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,