Spring Boot 流程引擎服务:流程定义/实例/任务、审批人解析、 回调分发、oapi 接口及单元测试。 保留远程 .gitea/workflows/build.yml 与 docker/Dockerfile, 新增 .gitignore 排除 target/ 构建产物。
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户面访问守卫(概览 §十):提交人只读写自己办件;办理人只读写分派给自己的任务;
|
||||
* 管理员本校可查、不可代批。
|
||||
* 管理员口径与 FlowAdminGuard / svc-hall ApplyViewGuard / Notice NoticeFlowGuard 对齐:
|
||||
* type==1(超管)或 roleId∈{2,5,7}(校级管理员)——三者必须同口径,否则校级管理员
|
||||
* 能列办件却在详情页 403(本类原先只认 type==1,2026-09-14 校正)。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowAccessGuard {
|
||||
|
||||
private static final int SUPER_ADMIN_TYPE = 1;
|
||||
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
|
||||
|
||||
private final FlwTaskRepository taskRepository;
|
||||
|
||||
/** 办件详情可见:提交人本人 / 该办件任务办理人 / 本校管理员(超管或校级管理员) */
|
||||
public void requireInstanceVisible(FlwInstance instance, BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
|
||||
}
|
||||
if (instance.getSubmitterId().equals(user.getId())) {
|
||||
return;
|
||||
}
|
||||
if (!taskRepository.findByInstanceIdOrderByIdAsc(instance.getId()).stream()
|
||||
.anyMatch(t -> user.getId().equals(t.getAssigneeId()))) {
|
||||
if (!isAdminOf(instance, user)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅提交人/办理人/本校管理员可查看该办件");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本校管理员(可查不可代批;办理权在 FlowTaskService 里再按 assigneeId 校验)。
|
||||
* 超管与校级管理员均限本校——跨校仍 403,不放大可见范围。
|
||||
*/
|
||||
private boolean isAdminOf(FlwInstance instance, BoaUser user) {
|
||||
Long current = currentSchId(user);
|
||||
if (current == null || !current.equals(instance.getSchId())) {
|
||||
return false;
|
||||
}
|
||||
if (user.getType() != null && user.getType() == SUPER_ADMIN_TYPE) {
|
||||
return true;
|
||||
}
|
||||
return user.getRoleIds() != null
|
||||
&& user.getRoleIds().stream().anyMatch(r -> r != null && ADMIN_ROLE_IDS.contains(r));
|
||||
}
|
||||
|
||||
private Long currentSchId(BoaUser user) {
|
||||
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
return null;
|
||||
}
|
||||
return Long.valueOf(user.getCurrentRole().getSchId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理面权限守卫(概览 §十):超管(type==1,平台约定)全平台可管;
|
||||
* 校级管理员(roleId∈{2,5,7},对齐 UserHome OtherUtil 先例)限本校。
|
||||
*/
|
||||
@Service
|
||||
public class FlowAdminGuard {
|
||||
|
||||
private static final int SUPER_ADMIN_TYPE = 1;
|
||||
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
|
||||
|
||||
/** 校验管理权并返回管辖学校 id(超管可用参数 schId 指定目标校,否则取当前身份学校) */
|
||||
public Long requireAdmin(BoaUser user, Long targetSchId) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
|
||||
}
|
||||
if (user.getType() != null && user.getType() == SUPER_ADMIN_TYPE) {
|
||||
if (targetSchId != null) {
|
||||
return targetSchId;
|
||||
}
|
||||
Long current = currentSchId(user);
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "超管请显式指定 schId(当前身份无学校归属)");
|
||||
}
|
||||
boolean roleMatch = user.getRoleIds() != null
|
||||
&& user.getRoleIds().stream().anyMatch(ADMIN_ROLE_IDS::contains);
|
||||
Long current = currentSchId(user);
|
||||
if (!roleMatch || current == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无流程管理权限");
|
||||
}
|
||||
if (targetSchId != null && !targetSchId.equals(current)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅可管理本校流程");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private Long currentSchId(BoaUser user) {
|
||||
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
return null;
|
||||
}
|
||||
return Long.valueOf(user.getCurrentRole().getSchId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.callback.CallbackProperties;
|
||||
import com.yunhai.flowcenter.callback.CallbackPushService;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BasePageResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 回调注册/推送日志/死信管理(概览 §9.2):
|
||||
* 启停、secret 重置(重置后旧签名立即失效)、推送日志查询、死信列表与手工重推。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowCallbackAdminService {
|
||||
|
||||
private final FlwCallbackRegRepository regRepository;
|
||||
private final FlwCallbackLogRepository logRepository;
|
||||
private final CallbackPushService pushService;
|
||||
private final CallbackProperties callbackProperties;
|
||||
|
||||
public List<FlwCallbackReg> listRegs(Long schId) {
|
||||
return regRepository.findBySchIdOrderByUpdateTimeDesc(schId);
|
||||
}
|
||||
|
||||
/** 启用/停用注册(停用后该注册不再收到推送,日志保留) */
|
||||
@Transactional
|
||||
public void setRegStatus(Long schId, Long regId, boolean enabled) {
|
||||
FlwCallbackReg reg = requireReg(schId, regId);
|
||||
reg.setStatus(enabled ? 0 : 1);
|
||||
regRepository.save(reg);
|
||||
}
|
||||
|
||||
/** secret 重置:新 secret 下发(管理端展示一次,不回显存量) */
|
||||
@Transactional
|
||||
public String resetSecret(Long schId, Long regId) {
|
||||
FlwCallbackReg reg = requireReg(schId, regId);
|
||||
String secret = UUID.randomUUID().toString().replace("-", "").toLowerCase();
|
||||
reg.setSecret(secret);
|
||||
regRepository.save(reg);
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** 推送日志分页(状态/事件/办件筛选) */
|
||||
public BasePageResult<FlwCallbackLog> pageLogs(Long schId, Integer status, String event,
|
||||
Long instanceId, Integer page, Integer size) {
|
||||
Page<FlwCallbackLog> p = logRepository.pageLogs(schId, status, event, instanceId,
|
||||
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
|
||||
size == null || size < 1 || size > 100 ? 10 : size));
|
||||
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(),
|
||||
page == null || page < 1 ? 1 : page,
|
||||
size == null || size < 1 || size > 100 ? 10 : size,
|
||||
p.getTotalPages(), p.hasNext());
|
||||
}
|
||||
|
||||
/** 死信列表 */
|
||||
public BasePageResult<FlwCallbackLog> pageDead(Long schId, Integer page, Integer size) {
|
||||
Page<FlwCallbackLog> p = logRepository.findBySchIdAndStatusOrderByIdDesc(schId,
|
||||
FlwEnums.CallbackLogStatus.DEAD.getCode(),
|
||||
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
|
||||
size == null || size < 1 || size > 100 ? 10 : size));
|
||||
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(),
|
||||
page == null || page < 1 ? 1 : page,
|
||||
size == null || size < 1 || size > 100 ? 10 : size,
|
||||
p.getTotalPages(), p.hasNext());
|
||||
}
|
||||
|
||||
/** 挂起列表:待推且 retryCount≥挂起阈值(持续重试中疑似消费方停机的可视入口) */
|
||||
public BasePageResult<FlwCallbackLog> pageHung(Long schId, Integer page, Integer size) {
|
||||
Page<FlwCallbackLog> p = logRepository.pageHung(schId, callbackProperties.getHangAlertRetries(),
|
||||
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
|
||||
size == null || size < 1 || size > 100 ? 10 : size));
|
||||
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(),
|
||||
page == null || page < 1 ? 1 : page,
|
||||
size == null || size < 1 || size > 100 ? 10 : size,
|
||||
p.getTotalPages(), p.hasNext());
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端手工标记死信(持续重试语义下死信的唯一人工入口:人工判定事件无意义时止血)。
|
||||
* 标记后可经既有 rePush 复活。
|
||||
*/
|
||||
@Transactional
|
||||
public void markDead(Long schId, Long logId) {
|
||||
FlwCallbackLog callbackLog = logRepository.findById(logId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "推送日志不存在:" + logId));
|
||||
if (!schId.equals(callbackLog.getSchId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "推送日志不属于该学校");
|
||||
}
|
||||
if (callbackLog.getStatus() != null
|
||||
&& callbackLog.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "已成功日志无需标记死信");
|
||||
}
|
||||
pushService.markDead(callbackLog, "管理端手工标记死信");
|
||||
}
|
||||
|
||||
/** 手工重推(死信/失败日志):置回待推并立即触发一次推送 */
|
||||
@Transactional
|
||||
public void rePush(Long schId, List<Long> logIds) {
|
||||
if (logIds == null || logIds.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要重推的日志");
|
||||
}
|
||||
List<FlwCallbackLog> logs = logRepository.findByIdInAndSchId(logIds, schId);
|
||||
if (logs.size() != logIds.size()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "存在不属于该校或不存在 dead/pending 日志");
|
||||
}
|
||||
for (FlwCallbackLog callbackLog : logs) {
|
||||
if (callbackLog.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
|
||||
continue;
|
||||
}
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setRetryCount(0);
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now());
|
||||
logRepository.save(callbackLog);
|
||||
}
|
||||
logRepository.flush();
|
||||
for (FlwCallbackLog callbackLog : logs) {
|
||||
if (callbackLog.getStatus() != null) {
|
||||
pushService.pushAsync(callbackLog.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private FlwCallbackReg requireReg(Long schId, Long regId) {
|
||||
FlwCallbackReg reg = regRepository.findById(regId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "回调注册不存在:" + regId));
|
||||
if (!reg.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "回调注册不属于该学校");
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.DefValidator;
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 流程定义管理(M1:JSON 文本编辑模式,概览 §9.2):
|
||||
* 保存=草稿直改/已发布升版本(flowGuid 跨版本不变);发布跑静态校验(把"任务无主"拦在发布期)。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowDefAdminService {
|
||||
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** 定义列表:status 空=全量(各流程线各版本都展示,管理端"版本历史"内聚在行操作里) */
|
||||
public List<FlwDef> list(Long schId, Integer status) {
|
||||
if (status != null) {
|
||||
return defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status);
|
||||
}
|
||||
return defRepository.findBySchIdOrderByUpdateTimeDesc(schId);
|
||||
}
|
||||
|
||||
/** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */
|
||||
public List<FlwDef> listAll(Long schId) {
|
||||
return defRepository.findPublishedLatestPerGuid(schId);
|
||||
}
|
||||
|
||||
public FlwDef get(Long schId, Long id) {
|
||||
FlwDef def = requireDef(schId, id);
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存(新建语义按 baseGuid/同名草稿分派,避免 uk(sch,code,version) 冲突):
|
||||
* - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1)
|
||||
* - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本"
|
||||
* - id 空 + 同名草稿存在:复用该草稿直改
|
||||
* - id 空 + 全新 code:新流程线(flowGuid 生成,version=1)
|
||||
*/
|
||||
@Transactional
|
||||
public FlwDef save(Long schId, Long id, String code, String name, String contentJson, String baseGuid) {
|
||||
// JSON 先解析再落库(语法错误不进库)
|
||||
FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson);
|
||||
if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "code/name 必填");
|
||||
}
|
||||
FlwDef def;
|
||||
if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) {
|
||||
// 在既有流程线上开新版本草稿
|
||||
defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "baseGuid 流程线不存在"));
|
||||
def = new FlwDef();
|
||||
def.setSchId(schId);
|
||||
def.setFlowGuid(baseGuid.trim());
|
||||
def.setCode(code.trim());
|
||||
def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1);
|
||||
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
} else if (id == null) {
|
||||
FlwDef draft = defRepository.findFirstBySchIdAndCodeAndStatus(
|
||||
schId, code.trim(), FlwEnums.DefStatus.DRAFT.getCode()).orElse(null);
|
||||
if (draft != null) {
|
||||
// 同名草稿复用直改
|
||||
def = draft;
|
||||
} else {
|
||||
def = new FlwDef();
|
||||
def.setSchId(schId);
|
||||
def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase());
|
||||
def.setCode(code.trim());
|
||||
def.setVersion(defRepository.maxVersionByCode(schId, code.trim()) + 1);
|
||||
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
}
|
||||
} else {
|
||||
def = requireDef(schId, id);
|
||||
if (!def.getCode().equals(code.trim())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程编码不可修改");
|
||||
}
|
||||
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
|
||||
// 升版本:复制行,保持 flowGuid
|
||||
FlwDef next = new FlwDef();
|
||||
next.setSchId(schId);
|
||||
next.setFlowGuid(def.getFlowGuid());
|
||||
next.setCode(def.getCode());
|
||||
next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1);
|
||||
next.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
def = next;
|
||||
}
|
||||
}
|
||||
def.setCode(code.trim());
|
||||
def.setName(name.trim());
|
||||
def.setContentJson(contentJson);
|
||||
// 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主)
|
||||
if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) {
|
||||
def.setCode(parsed.code.trim());
|
||||
}
|
||||
if (parsed.name != null && !parsed.name.trim().isEmpty()) {
|
||||
def.setName(parsed.name.trim());
|
||||
}
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布 */
|
||||
@Transactional
|
||||
public FlwDef publish(Long schId, Long id) {
|
||||
FlwDef def = requireDef(schId, id);
|
||||
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态");
|
||||
}
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
Set<String> enabledDeptKeys = mapRepository.findEnabledBySchId(schId).stream()
|
||||
.map(FlwAssigneeMap::getDeptKey).collect(Collectors.toSet());
|
||||
DefValidator.validate(model, enabledDeptKeys);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setPublishTime(LocalDateTime.now());
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
/** 停用:不可新发起,存量跑完 */
|
||||
@Transactional
|
||||
public void offline(Long schId, Long id) {
|
||||
FlwDef def = requireDef(schId, id);
|
||||
def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||
defRepository.save(def);
|
||||
}
|
||||
|
||||
/** 同一流程线全部版本(降序) */
|
||||
public List<FlwDef> versions(Long schId, String flowGuid) {
|
||||
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid);
|
||||
}
|
||||
|
||||
private FlwDef requireDef(Long schId, Long id) {
|
||||
FlwDef def = defRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义不存在:" + id));
|
||||
if (!def.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "流程定义不属于该学校");
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.client.OpenApiUserClient;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 流程示意图出图(方案 §3.2):后端按视图解析好节点 label/subLabel,前端只画图不解析。
|
||||
* 三分支定位:byGuid=最高已发布版(用户面);byInstance=办件 def 快照(用户面,可见性守卫在控制器);
|
||||
* build(defId 任意版本直调)=管理面草稿预览。
|
||||
* 视图口径:dept 视图不展示人员姓名(USERS 节点只显"指定办理人"且不调 UserBase,防科室视图泄名单);
|
||||
* user 视图 DEPT_KEY=映射成员名、USERS=UserBase 补全(失败降级 #id 不阻塞);缺省 auto 按节点自适应
|
||||
* (DEPT_KEY 走科室文案、USERS 走人员文案)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowDiagramService {
|
||||
|
||||
public static final String MODE_DEPT = "dept";
|
||||
public static final String MODE_USER = "user";
|
||||
public static final String MODE_AUTO = "auto";
|
||||
|
||||
/** dept 视图 USERS 节点固定副标(不列姓名) */
|
||||
private static final String USERS_HINT_DEPT_VIEW = "指定办理人";
|
||||
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
private final OpenApiUserClient userClient;
|
||||
|
||||
/** flowGuid 分支:最高已发布版(查询范式同 OapiFlowService.getFlow) */
|
||||
public FlowModels.DiagramVo byGuid(Long schId, String flowGuid, String mode) {
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布"));
|
||||
// 二道防线:查询本就按 schId 收敛,此处再显式比对(防仓库方法误用导致跨校出图)
|
||||
if (!schId.equals(def.getSchId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "跨校不可查看");
|
||||
}
|
||||
return build(def, null, mode);
|
||||
}
|
||||
|
||||
/** instanceId 分支:按办件 def 快照出图(可见性守卫在控制器 requireInstanceVisible) */
|
||||
public FlowModels.DiagramVo byInstance(FlwInstance instance, String mode) {
|
||||
FlwDef def = defRepository.findById(instance.getDefId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程定义不存在:" + instance.getDefId()));
|
||||
return build(def, instance.getId(), mode);
|
||||
}
|
||||
|
||||
/** 出图主体(管理面 defId 分支按 defId 任意版本直调,草稿可预览) */
|
||||
public FlowModels.DiagramVo build(FlwDef def, Long instanceId, String mode) {
|
||||
String m = normalizeMode(mode);
|
||||
FlowDefModel model = flowEngine.parseModel(def);
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
vo.setFlowGuid(def.getFlowGuid());
|
||||
vo.setDefId(def.getId());
|
||||
vo.setInstanceId(instanceId);
|
||||
vo.setName(def.getName());
|
||||
vo.setVersion(def.getVersion());
|
||||
vo.setStatus(def.getStatus());
|
||||
vo.setMode(m);
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>();
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
nodes.add(toNodeVo(def.getSchId(), n, m));
|
||||
}
|
||||
vo.setNodes(nodes);
|
||||
List<FlowModels.EdgeVo> edges = new ArrayList<>();
|
||||
for (FlowDefModel.Edge e : model.edges) {
|
||||
FlowModels.EdgeVo ev = new FlowModels.EdgeVo();
|
||||
ev.setFrom(e.from);
|
||||
ev.setTo(e.to);
|
||||
edges.add(ev);
|
||||
}
|
||||
vo.setEdges(edges);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private FlowModels.NodeVo toNodeVo(Long schId, FlowDefModel.Node n, String mode) {
|
||||
FlowModels.NodeVo vo = new FlowModels.NodeVo();
|
||||
vo.setId(n.id);
|
||||
vo.setType(n.type);
|
||||
vo.setCountersign(n.countersign);
|
||||
vo.setDeadlineHours(n.deadlineHours);
|
||||
if (!FlowDefModel.TYPE_APPROVAL.equals(n.type)) {
|
||||
vo.setLabel(fixedLabel(n.type));
|
||||
return vo;
|
||||
}
|
||||
vo.setLabel(n.name);
|
||||
FlowDefModel.Assignee a = n.assignee;
|
||||
if (a == null) {
|
||||
return vo;
|
||||
}
|
||||
vo.setAssigneeType(a.type);
|
||||
vo.setDeptKey(a.deptKey);
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
List<FlwAssigneeMap> maps = mapRepository.findEnabled(schId, a.deptKey);
|
||||
if (MODE_USER.equals(mode)) {
|
||||
vo.setSubLabel(namesWithSuffix(memberNames(maps), suffixes(n)));
|
||||
} else {
|
||||
// dept 视图与 auto 的 DEPT_KEY 自适应同文案:科室名 + 启用映射人数
|
||||
vo.setLabel(deptLabel(n, a, maps));
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.add("科室 · " + maps.size() + "人");
|
||||
parts.addAll(suffixes(n));
|
||||
vo.setSubLabel(String.join(" · ", parts));
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||||
if (MODE_DEPT.equals(mode)) {
|
||||
vo.setSubLabel(USERS_HINT_DEPT_VIEW);
|
||||
} else {
|
||||
vo.setSubLabel(namesWithSuffix(userNames(a.userIds), suffixes(n)));
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 科室视图 label 兜底链:有映射行取 deptName(全空→deptKey);无映射行→节点名(再退 deptKey) */
|
||||
private String deptLabel(FlowDefModel.Node n, FlowDefModel.Assignee a, List<FlwAssigneeMap> maps) {
|
||||
if (maps.isEmpty()) {
|
||||
return firstNonBlank(n.name, a.deptKey);
|
||||
}
|
||||
for (FlwAssigneeMap m : maps) {
|
||||
if (m.getDeptName() != null && !m.getDeptName().trim().isEmpty()) {
|
||||
return m.getDeptName();
|
||||
}
|
||||
}
|
||||
return firstNonBlank(a.deptKey, n.name);
|
||||
}
|
||||
|
||||
/** 人员名单(顿号连接)+ 会签/时限后缀(" · "分隔);名单与后缀均空则返回 null */
|
||||
private String namesWithSuffix(List<String> names, List<String> suffixes) {
|
||||
List<String> nonBlank = new ArrayList<>();
|
||||
if (names != null) {
|
||||
for (String s : names) {
|
||||
if (s != null && !s.isEmpty()) {
|
||||
nonBlank.add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nonBlank.isEmpty() && suffixes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(String.join("、", nonBlank));
|
||||
for (String s : suffixes) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(" · ");
|
||||
}
|
||||
sb.append(s);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private List<String> suffixes(FlowDefModel.Node n) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
if (FlowDefModel.COUNTERSIGN_ANY.equals(n.countersign)) {
|
||||
parts.add("任一人通过");
|
||||
}
|
||||
if (n.deadlineHours != null) {
|
||||
parts.add("时限" + n.deadlineHours + "h");
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
private List<String> memberNames(List<FlwAssigneeMap> maps) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (FlwAssigneeMap m : maps) {
|
||||
names.add(blankToId(m.getUserName(), m.getUserId()));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** USERS 定义直配人名(复用 OpenApiUserClient.getUserById 通道,失败/空名降级 #id 不抛错) */
|
||||
private List<String> userNames(List<Integer> userIds) {
|
||||
List<String> names = new ArrayList<>();
|
||||
if (userIds == null) {
|
||||
return names;
|
||||
}
|
||||
for (Integer uid : userIds) {
|
||||
String name = null;
|
||||
try {
|
||||
BoaUser u = userClient.getUserById(String.valueOf(uid));
|
||||
name = u == null ? null : u.getName();
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 示意图人名补全失败 userId={}:{}", uid, e.getMessage());
|
||||
}
|
||||
names.add(blankToId(name, uid));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private String blankToId(String name, Integer userId) {
|
||||
return name == null || name.trim().isEmpty() ? "#" + userId : name;
|
||||
}
|
||||
|
||||
private String fixedLabel(String type) {
|
||||
switch (type == null ? "" : type) {
|
||||
case FlowDefModel.TYPE_START:
|
||||
return "开始";
|
||||
case FlowDefModel.TYPE_END:
|
||||
return "结束";
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT:
|
||||
return "并行分发";
|
||||
case FlowDefModel.TYPE_PARALLEL_JOIN:
|
||||
return "汇聚等待";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeMode(String mode) {
|
||||
if (mode == null || mode.trim().isEmpty()) {
|
||||
return MODE_AUTO;
|
||||
}
|
||||
String m = mode.trim();
|
||||
if (MODE_DEPT.equals(m) || MODE_USER.equals(m) || MODE_AUTO.equals(m)) {
|
||||
return m;
|
||||
}
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "mode 仅支持 dept/user");
|
||||
}
|
||||
|
||||
private String firstNonBlank(String a, String b) {
|
||||
return a != null && !a.trim().isEmpty() ? a : b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 办件生命周期:代发起(幂等 + R4 重启重提)、撤回。
|
||||
*
|
||||
* <p><b>R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿)</b>:biz uk 命中已终态办件
|
||||
* (30 已拒绝 / 40 已撤回)时复用同一办件行"重启推进"——不新建、不报错:定义刷新为当前
|
||||
* 已发布版本、提交快照全量覆盖、旧分支行清空、残留待办失效、status 回 10、提交时间刷新、
|
||||
* version 自增(镜像方按 instanceVersion 单调推进到 10)。任务历史保留(1/2/3 状态行不动),
|
||||
* 轨迹跨轮连续。status=10/20(在途/已完成)命中则纯幂等返回原办件(duplicated=true)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowInstanceService {
|
||||
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlwBranchRepository branchRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/** 代发起申请体(oapi apply 与未来原生发起共用字段) */
|
||||
@RequiredArgsConstructor
|
||||
public static class ApplyCommand {
|
||||
public final Long schId;
|
||||
public final FlwDef def;
|
||||
public final String bizType;
|
||||
public final String bizId;
|
||||
public final String bizTitle;
|
||||
public final String bizUrl;
|
||||
public final String bizSnapshot;
|
||||
public final Integer submitterId;
|
||||
public final String submitterName;
|
||||
public final String formDataJson;
|
||||
}
|
||||
|
||||
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
|
||||
@Transactional
|
||||
public ApplyOutcome apply(ApplyCommand cmd) {
|
||||
FlwInstance existing = instanceRepository
|
||||
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
|
||||
if (existing != null) {
|
||||
int status = existing.getStatus();
|
||||
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|
||||
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
|
||||
return new ApplyOutcome(existing.getId(), true);
|
||||
}
|
||||
// 30/40:R4 重启推进(复用办件行,定义/快照/时间刷新,重新走 START)
|
||||
restart(existing, cmd);
|
||||
return new ApplyOutcome(existing.getId(), true);
|
||||
}
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setSchId(cmd.schId);
|
||||
instance.setDefId(cmd.def.getId());
|
||||
instance.setDefCode(cmd.def.getCode());
|
||||
instance.setDefVersion(cmd.def.getVersion());
|
||||
instance.setBizType(cmd.bizType);
|
||||
instance.setBizId(cmd.bizId);
|
||||
instance.setBizTitle(cmd.bizTitle);
|
||||
instance.setBizUrl(cmd.bizUrl);
|
||||
instance.setBizSnapshot(cmd.bizSnapshot);
|
||||
instance.setFormDataJson(cmd.formDataJson);
|
||||
instance.setSubmitterId(cmd.submitterId);
|
||||
instance.setSubmitterName(cmd.submitterName);
|
||||
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
instance.setSubmitTime(LocalDateTime.now());
|
||||
instanceRepository.saveAndFlush(instance);
|
||||
flowEngine.startAdvance(instance, cmd.def);
|
||||
publishSubmitted(instance);
|
||||
return new ApplyOutcome(instance.getId(), false);
|
||||
}
|
||||
|
||||
private void restart(FlwInstance instance, ApplyCommand cmd) {
|
||||
log.info("[flow-center] R4 重启推进:instance={} 原 status={} biz={}/{}",
|
||||
instance.getId(), instance.getStatus(), cmd.bizType, cmd.bizId);
|
||||
instance.setDefId(cmd.def.getId());
|
||||
instance.setDefCode(cmd.def.getCode());
|
||||
instance.setDefVersion(cmd.def.getVersion());
|
||||
instance.setBizTitle(cmd.bizTitle);
|
||||
instance.setBizUrl(cmd.bizUrl);
|
||||
instance.setBizSnapshot(cmd.bizSnapshot);
|
||||
instance.setFormDataJson(cmd.formDataJson);
|
||||
instance.setSubmitterId(cmd.submitterId);
|
||||
instance.setSubmitterName(cmd.submitterName);
|
||||
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
instance.setSubmitTime(LocalDateTime.now());
|
||||
instance.setFinishTime(null);
|
||||
instanceRepository.saveAndFlush(instance);
|
||||
taskRepository.invalidatePendingByInstance(instance.getId());
|
||||
branchRepository.deleteByInstance(instance.getId());
|
||||
flowEngine.startAdvance(instance, cmd.def);
|
||||
publishSubmitted(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回(概览 §5.3):仅当尚无任何任务被办理时允许;全部待办失效、分支失效、办件=40。
|
||||
*/
|
||||
@Transactional
|
||||
public void withdraw(Long schId, Long instanceId) {
|
||||
FlwInstance instance = requireInstance(schId, instanceId);
|
||||
if (instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的办件可撤回");
|
||||
}
|
||||
if (!taskRepository.findByInstanceIdAndStatusIn(instanceId,
|
||||
java.util.Arrays.asList(FlwEnums.TaskStatus.APPROVED.getCode(),
|
||||
FlwEnums.TaskStatus.REJECTED.getCode())).isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "已有任务被办理,不能撤回");
|
||||
}
|
||||
instance.setStatus(FlwEnums.InstanceStatus.WITHDRAWN.getCode());
|
||||
instance.setFinishTime(LocalDateTime.now());
|
||||
instanceRepository.saveAndFlush(instance);
|
||||
taskRepository.invalidatePendingByInstance(instanceId);
|
||||
branchRepository.invalidateRunningByInstance(instanceId);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("result", "WITHDRAWN");
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.WITHDRAWN, instanceId, null, data));
|
||||
}
|
||||
|
||||
public FlwInstance requireInstance(Long schId, Long instanceId) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
|
||||
if (!instance.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "办件不属于该学校");
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public FlwDef requireDef(Long defId) {
|
||||
return defRepository.findById(defId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义不存在:" + defId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 催办(概览 §5.3):提交人/管理员对办件的全部待办任务各催一次(URGE 事件 → UserMsg 通道,
|
||||
* msgNo=urge:{taskId}:{N} 幂等);次数上限取定义 settings.urge.maxTimes(默认 3;
|
||||
* enabled=false → 0 = 不限次)。非提交人调用须是本校管理员(控制器已按 BoaUser 判定放行)。
|
||||
*/
|
||||
@Transactional
|
||||
public void urgeInstance(Long schId, Long instanceId, Integer operatorId) {
|
||||
FlwInstance instance = requireInstance(schId, instanceId);
|
||||
FlowDefModel model = flowEngine.parseModel(requireDef(instance.getDefId()));
|
||||
int maxTimes = urgeMaxTimes(model);
|
||||
java.util.List<com.yunhai.flowcenter.entity.FlwTask> pendings =
|
||||
taskRepository.findByInstanceIdAndStatus(instanceId,
|
||||
com.yunhai.flowcenter.enums.FlwEnums.TaskStatus.PENDING.getCode());
|
||||
if (pendings.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "无待办任务可催办");
|
||||
}
|
||||
for (com.yunhai.flowcenter.entity.FlwTask task : pendings) {
|
||||
if (maxTimes > 0 && task.getUrgeCount() >= maxTimes) {
|
||||
continue;
|
||||
}
|
||||
task.setUrgeCount(task.getUrgeCount() + 1);
|
||||
taskRepository.save(task);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("taskId", task.getId());
|
||||
data.put("urgeCount", task.getUrgeCount());
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.URGE,
|
||||
instanceId, task.getId(), data));
|
||||
}
|
||||
}
|
||||
|
||||
private static int urgeMaxTimes(FlowDefModel model) {
|
||||
if (model == null || model.settings == null || model.settings.urge == null) {
|
||||
return 3;
|
||||
}
|
||||
FlowDefModel.Urge urge = model.settings.urge;
|
||||
if (Boolean.FALSE.equals(urge.enabled)) {
|
||||
return 0;
|
||||
}
|
||||
return urge.maxTimes == null || urge.maxTimes <= 0 ? 3 : urge.maxTimes;
|
||||
}
|
||||
|
||||
private void publishSubmitted(FlwInstance instance) {
|
||||
FlowDefModel model = flowEngine.parseModel(requireDef(instance.getDefId()));
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("nodeSummary", FlowEngine.nodeSummary(model));
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.SUBMITTED, instance.getId(), null, data));
|
||||
}
|
||||
|
||||
/** 代发起结果值对象 */
|
||||
@SuppressWarnings("unused")
|
||||
public static class ApplyOutcome {
|
||||
private final Long instanceId;
|
||||
private final boolean duplicated;
|
||||
|
||||
public ApplyOutcome(Long instanceId, boolean duplicated) {
|
||||
this.instanceId = instanceId;
|
||||
this.duplicated = duplicated;
|
||||
}
|
||||
|
||||
public Long getInstanceId() {
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
public boolean isDuplicated() {
|
||||
return duplicated;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
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 org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人显式映射,成员检索经
|
||||
* UserBase /oapi/user/byId 补全姓名快照(检索按 id 精确查;按名检索二期接 UserBase 模糊端点)。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowMapAdminService {
|
||||
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final OpenApiUserClient userClient;
|
||||
|
||||
public List<FlwAssigneeMap> listByDept(Long schId, String deptKey) {
|
||||
if (deptKey == null || deptKey.trim().isEmpty()) {
|
||||
return mapRepository.findEnabledBySchId(schId);
|
||||
}
|
||||
return mapRepository.findBySchIdAndDeptKeyOrderByUserIdAsc(schId, deptKey.trim());
|
||||
}
|
||||
|
||||
/** 新增/启用映射(uk: sch+dept+user;已存在则刷新姓名/状态) */
|
||||
@Transactional
|
||||
public FlwAssigneeMap save(Long schId, String deptKey, String deptName,
|
||||
Integer userId, String userName) {
|
||||
if (deptKey == null || deptKey.trim().isEmpty() || userId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "deptKey/userId 必填");
|
||||
}
|
||||
if (userName == null || userName.trim().isEmpty()) {
|
||||
userName = fetchName(userId);
|
||||
}
|
||||
FlwAssigneeMap map = mapRepository.findBySchIdAndDeptKeyAndUserId(schId, deptKey.trim(), userId);
|
||||
if (map == null) {
|
||||
map = new FlwAssigneeMap();
|
||||
map.setSchId(schId);
|
||||
map.setDeptKey(deptKey.trim());
|
||||
map.setUserId(userId);
|
||||
}
|
||||
map.setDeptName(deptName);
|
||||
map.setUserName(userName);
|
||||
map.setStatus(0);
|
||||
return mapRepository.save(map);
|
||||
}
|
||||
|
||||
/** 停用映射(保留历史,DEPT_KEY 解析只取 status=0) */
|
||||
@Transactional
|
||||
public void disable(Long schId, Long id) {
|
||||
FlwAssigneeMap map = mapRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "映射不存在:" + id));
|
||||
if (!map.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "映射不属于该学校");
|
||||
}
|
||||
map.setStatus(1);
|
||||
mapRepository.save(map);
|
||||
}
|
||||
|
||||
/** 成员检索:按用户 id 从 UserBase 取姓名快照(管理端录入校验) */
|
||||
public BoaUser lookupUser(Integer userId) {
|
||||
try {
|
||||
return userClient.getUserById(String.valueOf(userId));
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 用户检索失败(服务不可达)");
|
||||
}
|
||||
}
|
||||
|
||||
private String fetchName(Integer userId) {
|
||||
try {
|
||||
BoaUser user = userClient.getUserById(String.valueOf(userId));
|
||||
return user == null ? "" : (user.getName() == null ? "" : user.getName());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwBranch;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户面查询(我的办件/待办/已办/办件详情)。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowQueryService {
|
||||
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlwBranchRepository branchRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
|
||||
public BasePageResult<FlowModels.InstanceItem> pageMy(Long schId, Integer submitterId,
|
||||
Integer status, Integer page, Integer size) {
|
||||
Page<FlwInstance> p = instanceRepository.pageMy(schId, submitterId, status,
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
return toResult(p.getTotalElements(), p.getContent().stream().map(this::toInstanceItem)
|
||||
.collect(Collectors.toList()), page, size);
|
||||
}
|
||||
|
||||
public BasePageResult<FlowModels.TaskItem> pageTodo(Long schId, Integer assigneeId,
|
||||
Integer page, Integer size) {
|
||||
Page<FlwTask> p = taskRepository.pageTodo(schId, assigneeId,
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
List<FlowModels.TaskItem> items = p.getContent().stream()
|
||||
.map(this::toTaskItem).collect(Collectors.toList());
|
||||
fillInstanceInfo(items);
|
||||
return toResult(p.getTotalElements(), items, page, size);
|
||||
}
|
||||
|
||||
public BasePageResult<FlowModels.TaskItem> pageDone(Long schId, Integer assigneeId,
|
||||
Integer page, Integer size) {
|
||||
Page<FlwTask> p = taskRepository.pageDone(schId, assigneeId,
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
List<FlowModels.TaskItem> items = p.getContent().stream()
|
||||
.map(this::toTaskItem).collect(Collectors.toList());
|
||||
fillInstanceInfo(items);
|
||||
return toResult(p.getTotalElements(), items, page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件详情:快照 + 任务轨迹 + 分支进度 + 本人待办标记(办理页/详情页共用数据源)。
|
||||
* viewerId 必须由控制器从 BoaUser.getId() 传入(CAS 会话 LoginUser.userId 是登录名字符串,
|
||||
* 不是数字主键,不能直接转 Integer——2026-09-12 全链测试修复)。
|
||||
*/
|
||||
public FlowModels.InstanceDetail detail(Long schId, Long instanceId, Integer viewerId) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
if (!instance.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "办件不属于该学校");
|
||||
}
|
||||
FlowModels.InstanceDetail detail = new FlowModels.InstanceDetail();
|
||||
detail.setInstanceId(instance.getId());
|
||||
detail.setSchId(instance.getSchId());
|
||||
detail.setDefCode(instance.getDefCode());
|
||||
detail.setDefVersion(instance.getDefVersion());
|
||||
detail.setBizType(instance.getBizType());
|
||||
detail.setBizId(instance.getBizId());
|
||||
detail.setBizTitle(instance.getBizTitle());
|
||||
detail.setBizUrl(instance.getBizUrl());
|
||||
detail.setBizSnapshot(instance.getBizSnapshot());
|
||||
detail.setFormDataJson(instance.getFormDataJson());
|
||||
detail.setStatus(instance.getStatus());
|
||||
detail.setCurrentNodes(instance.getCurrentNodes());
|
||||
detail.setSubmitterId(instance.getSubmitterId());
|
||||
detail.setSubmitterName(instance.getSubmitterName());
|
||||
detail.setSubmitTime(str(instance.getSubmitTime()));
|
||||
detail.setFinishTime(str(instance.getFinishTime()));
|
||||
detail.setVersion(instance.getVersion());
|
||||
List<FlwTask> tasks = taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
List<Map<String, Object>> trace = new ArrayList<>();
|
||||
for (FlwTask t : tasks) {
|
||||
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", str(t.getArriveTime()));
|
||||
row.put("handleTime", str(t.getHandleTime()));
|
||||
row.put("deadline", str(t.getDeadline()));
|
||||
row.put("urgeCount", t.getUrgeCount());
|
||||
trace.add(row);
|
||||
}
|
||||
detail.setTrace(trace);
|
||||
List<Map<String, Object>> branches = new ArrayList<>();
|
||||
for (FlwBranch b : branchRepository.findByInstanceId(instanceId)) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("branchKey", b.getBranchKey());
|
||||
row.put("status", b.getStatus());
|
||||
branches.add(row);
|
||||
}
|
||||
detail.setBranches(branches);
|
||||
List<Long> myPending = new ArrayList<>();
|
||||
if (viewerId != null) {
|
||||
for (FlwTask t : tasks) {
|
||||
if (viewerId.equals(t.getAssigneeId())
|
||||
&& t.getStatus() == FlwEnums.TaskStatus.PENDING.getCode()) {
|
||||
myPending.add(t.getId());
|
||||
}
|
||||
}
|
||||
detail.setMyPendingTask(!myPending.isEmpty());
|
||||
detail.setMyPendingTaskIds(myPending);
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
detail.setNodeSummary(def == null ? "" : FlowEngine.nodeSummary(flowEngine.parseModel(def)));
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 任务定位办件详情(办理页 /task/{taskId} 数据源:UserMsg jumpPath 只带 taskId) */
|
||||
public FlowModels.InstanceDetail taskDetail(Long schId, Long taskId, Integer viewerId) {
|
||||
FlwTask task = taskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId));
|
||||
if (!task.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "任务不属于该学校");
|
||||
}
|
||||
return detail(schId, task.getInstanceId(), viewerId);
|
||||
}
|
||||
|
||||
private FlowModels.InstanceItem toInstanceItem(FlwInstance instance) {
|
||||
FlowModels.InstanceItem item = new FlowModels.InstanceItem();
|
||||
item.setInstanceId(instance.getId());
|
||||
item.setBizType(instance.getBizType());
|
||||
item.setBizId(instance.getBizId());
|
||||
item.setBizTitle(instance.getBizTitle());
|
||||
item.setStatus(instance.getStatus());
|
||||
item.setCurrentNodes(instance.getCurrentNodes());
|
||||
item.setSubmitterName(instance.getSubmitterName());
|
||||
item.setSubmitTime(str(instance.getSubmitTime()));
|
||||
item.setFinishTime(str(instance.getFinishTime()));
|
||||
item.setDefVersion(instance.getDefVersion());
|
||||
return item;
|
||||
}
|
||||
|
||||
private FlowModels.TaskItem toTaskItem(FlwTask t) {
|
||||
FlowModels.TaskItem item = new FlowModels.TaskItem();
|
||||
item.setTaskId(t.getId());
|
||||
item.setInstanceId(t.getInstanceId());
|
||||
item.setNodeId(t.getNodeId());
|
||||
item.setNodeName(t.getNodeName());
|
||||
item.setAssigneeName(t.getAssigneeName());
|
||||
item.setStatus(t.getStatus());
|
||||
item.setOpinion(t.getOpinion());
|
||||
item.setArriveTime(str(t.getArriveTime()));
|
||||
item.setHandleTime(str(t.getHandleTime()));
|
||||
item.setDeadline(str(t.getDeadline()));
|
||||
item.setUrgeCount(t.getUrgeCount());
|
||||
return item;
|
||||
}
|
||||
|
||||
private void fillInstanceInfo(List<FlowModels.TaskItem> items) {
|
||||
for (FlowModels.TaskItem item : items) {
|
||||
FlwInstance instance = instanceRepository.findById(item.getInstanceId()).orElse(null);
|
||||
if (instance != null) {
|
||||
item.setBizTitle(instance.getBizTitle());
|
||||
item.setBizType(instance.getBizType());
|
||||
item.setBizId(instance.getBizId());
|
||||
item.setInstanceStatus(instance.getStatus());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(LocalDateTime t) {
|
||||
return t == null ? null : t.toString();
|
||||
}
|
||||
|
||||
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> toResult(long total, List<T> content, Integer page, Integer size) {
|
||||
int pageIndex = page == null || page < 1 ? 1 : page;
|
||||
int pageSize = size == null || size < 1 || size > 100 ? 10 : size;
|
||||
int totalPages = (int) Math.ceil(total / (double) pageSize);
|
||||
return new BasePageResult<>(content, (int) total, pageIndex, pageSize, totalPages,
|
||||
pageIndex < totalPages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 任务办理:抢占(UPDATE WHERE status=0 原子语义)→ 事件发布 → 事务提交。
|
||||
* 拒绝终态清理在本事务内完成(END_REJECTED);同意后的节点放行推进交给
|
||||
* FlowNodeAdvanceListener 在 AFTER_COMMIT 新事务里做——会签并发办理时各方提交的
|
||||
* 快照互相看不到对方未提交的办理行,事务内判断"节点是否全部办结"必假,
|
||||
* 提交后重查才是唯一可靠时机(推进串行化由办件乐观锁兜底)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowTaskService {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwBranchRepository branchRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/**
|
||||
* 办理(同意/拒绝收敛点,概览 §8.5 不变式 2)。
|
||||
*
|
||||
* @return 办理后的任务(含最新状态)
|
||||
*/
|
||||
@Transactional
|
||||
public FlwTask handle(Long schId, Long taskId, boolean approved, String opinion, Integer operatorId) {
|
||||
FlwTask task = taskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId));
|
||||
if (!task.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "任务不属于该学校");
|
||||
}
|
||||
if (!task.getAssigneeId().equals(operatorId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅任务办理人本人可办理");
|
||||
}
|
||||
if (!approved && (opinion == null || opinion.trim().isEmpty())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "拒绝必须填写意见");
|
||||
}
|
||||
// 原子抢占:affected=1 才获得办理权(防双击/并发重复推进)
|
||||
int target = approved ? FlwEnums.TaskStatus.APPROVED.getCode() : FlwEnums.TaskStatus.REJECTED.getCode();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (taskRepository.claimAndFinish(taskId, target, now, opinion) != 1) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "任务已被办理或已失效,请刷新");
|
||||
}
|
||||
FlwTask handled = taskRepository.findById(taskId).orElse(null);
|
||||
FlwInstance instance = instanceRepository.findById(task.getInstanceId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
|
||||
if (instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
|
||||
// 极端并发:抢占成功但办件已被终态(如同时撤回)——本任务保持已办理态,但不做任何推进
|
||||
log.warn("[flow-center] 办理时办件已终态:instance={} status={} taskId={}",
|
||||
instance.getId(), instance.getStatus(), taskId);
|
||||
return handled;
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("nodeId", task.getNodeId());
|
||||
data.put("nodeName", task.getNodeName());
|
||||
data.put("assigneeId", task.getAssigneeId());
|
||||
data.put("assigneeName", task.getAssigneeName());
|
||||
data.put("approved", approved);
|
||||
if (opinion != null && !opinion.isEmpty()) {
|
||||
data.put("opinion", opinion);
|
||||
}
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.TASK_HANDLED,
|
||||
instance.getId(), taskId, data));
|
||||
if (!approved) {
|
||||
rejectFlow(instance, task, opinion);
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
|
||||
/** END_REJECTED 拒绝终态(概览 §5.3):同批待办失效→分支失效→办件=30→拒绝事件 */
|
||||
private void rejectFlow(FlwInstance instance, FlwTask task, String opinion) {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
instance.setFinishTime(LocalDateTime.now());
|
||||
instanceRepository.saveAndFlush(instance);
|
||||
taskRepository.invalidatePendingByInstance(instance.getId());
|
||||
branchRepository.invalidateRunningByInstance(instance.getId());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("result", "REJECTED");
|
||||
data.put("rejectNode", task.getNodeName() == null ? task.getNodeId() : task.getNodeName());
|
||||
data.put("rejectTaskId", task.getId());
|
||||
data.put("opinion", opinion);
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.REJECTED,
|
||||
instance.getId(), task.getId(), data));
|
||||
}
|
||||
|
||||
/** 手工催办(提交人/管理员):urge_count+1 + URGE 事件(UserMsg 通道) */
|
||||
@Transactional
|
||||
public void urge(Long schId, Long taskId, int maxTimes) {
|
||||
FlwTask task = taskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId));
|
||||
if (!task.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "任务不属于该学校");
|
||||
}
|
||||
if (task.getStatus() != FlwEnums.TaskStatus.PENDING.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅待办任务可催办");
|
||||
}
|
||||
if (maxTimes > 0 && task.getUrgeCount() >= maxTimes) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "已达催办次数上限(" + maxTimes + " 次)");
|
||||
}
|
||||
task.setUrgeCount(task.getUrgeCount() + 1);
|
||||
taskRepository.save(task);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("taskId", taskId);
|
||||
data.put("urgeCount", task.getUrgeCount());
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.URGE,
|
||||
task.getInstanceId(), taskId, data));
|
||||
}
|
||||
|
||||
public FlwTask requireTask(Long schId, Long taskId) {
|
||||
FlwTask task = taskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "任务不存在:" + taskId));
|
||||
if (!task.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "任务不属于该学校");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
/** def JSON 解析(推进监听器/详情页共用) */
|
||||
public FlowDefModel modelOf(FlwDef def) {
|
||||
return FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
}
|
||||
}
|
||||
@@ -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