feat: 提交流程中心后端代码
build / build-push (push) Successful in 28s

Spring Boot 流程引擎服务:流程定义/实例/任务、审批人解析、
回调分发、oapi 接口及单元测试。
保留远程 .gitea/workflows/build.yml 与 docker/Dockerfile,
新增 .gitignore 排除 target/ 构建产物。
This commit is contained in:
chenxing
2026-09-15 10:42:56 +08:00
parent 0296b2b5dd
commit c1103aeb29
65 changed files with 7119 additions and 0 deletions
@@ -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();
}
}