Spring Boot 流程引擎服务:流程定义/实例/任务、审批人解析、 回调分发、oapi 接口及单元测试。 保留远程 .gitea/workflows/build.yml 与 docker/Dockerfile, 新增 .gitignore 排除 target/ 构建产物。
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
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.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.BoaUser;
|
||||
import com.yunhai.platform.openapi.client.OpenApiUserClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
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 FlwCallbackRegRepository regRepository;
|
||||
private final FlowInstanceService instanceService;
|
||||
private final FlowEngine flowEngine;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final OpenApiUserClient userClient;
|
||||
|
||||
/** 已发布流程列表(每条流程线取最高已发布版本) */
|
||||
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);
|
||||
}
|
||||
|
||||
/** 回调注册(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.setSecret(secret);
|
||||
reg.setStatus(0);
|
||||
regRepository.saveAndFlush(reg);
|
||||
probeReachable(body.getCallbackUrl(), body.getFlowGuid(), secret);
|
||||
OapiModels.CallbackRegResultVo vo = new OapiModels.CallbackRegResultVo();
|
||||
vo.setSecret(secret);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可达性探活(概览 §十:拦住配错域名的低级故障)。只做连接级判定:
|
||||
* 连不上(未知主机/拒绝/超时)视为不可达并拒绝注册;任何 HTTP 状态码(含 401/404)
|
||||
* 都算可达——接收方验签过滤器在拿到 secret 前必然拒绝 ping,鉴权语义不在此验证。
|
||||
*/
|
||||
private void probeReachable(String callbackUrl, String flowGuid, String secret) {
|
||||
try {
|
||||
RestTemplate template = rawTemplate();
|
||||
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 RestTemplate rawTemplate() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(3000);
|
||||
factory.setReadTimeout(3000);
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
|
||||
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 办理人放行判定依赖) */
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user