Spring Boot 流程引擎服务:流程定义/实例/任务、审批人解析、 回调分发、oapi 接口及单元测试。 保留远程 .gitea/workflows/build.yml 与 docker/Dockerfile, 新增 .gitignore 排除 target/ 构建产物。
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
# Maven
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
.mvn/timing.properties
|
||||
.mvn/wrapper/maven-wrapper.properties
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.settings/
|
||||
.project
|
||||
.classpath
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.7.18</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.yunhai</groupId>
|
||||
<artifactId>flow-center</artifactId>
|
||||
<version>0.1</version>
|
||||
<name>flow-center</name>
|
||||
<description>流程中心(纯流程引擎:定义/引擎/任务/办件/回调分发;表单归办事大厅,本应用托管模式接入)</description>
|
||||
<properties>
|
||||
<java.version>1.8</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-platform-dependencies</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 基础平台全家桶(聚合 starter):CAS 认证 + 应用间签名 + 统一响应 + OpenAPI 客户端 +
|
||||
actuator 健康探活 + 共享 IDHttp(出站自动签名,UserMsg 通知直达),一行带齐(同 svc-hall 约定) -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- CAS 客户端(UserHome/Cms/svc-hall 同款显式声明) -->
|
||||
<dependency>
|
||||
<groupId>org.jasig.cas.client</groupId>
|
||||
<artifactId>cas-client-core</artifactId>
|
||||
<version>3.5.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>flow-center</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<mainClass>com.yunhai.flowcenter.FlowCenterApplication</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- 默认 surefire 只收 *Test.java;本工程引擎/契约/回调集成测试按 *IT 命名(打真实
|
||||
MySQL bsch_flowcenter_test),一并纳入 mvn test(IT 类之间勿并行跑) -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<includes>
|
||||
<include>**/*Test.java</include>
|
||||
<include>**/*IT.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.flowcenter;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 流程中心(V2.0 拆分后独立应用,原 svhall 短名改名 flow-center):
|
||||
* 纯流程引擎——流程定义/引擎/任务/办件实例/回调分发;不做表单(自定义表单归办事大厅 svc-hall)。
|
||||
* 两种消费方均以"托管模式"接入:代发起(biz uk 幂等)→ 审批 → X-Flw-* 事件回调 → 业务方镜像状态机;
|
||||
* 对账拉取兜底(《流程中心M1开工说明.md》§1)。
|
||||
*/
|
||||
@EnableScheduling
|
||||
@SpringBootApplication
|
||||
public class FlowCenterApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(FlowCenterApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 回调分发器(概览 §8.4 可靠性设计):引擎 AFTER_COMMIT 事件 → 匹配 flw_callback_reg
|
||||
* (flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
|
||||
* 落库成功即视为主业务完成,推送失败不影响审批(异步解耦是特性)。
|
||||
* @Scheduled(fixedDelay=30s) 扫 (status=0, next_retry_time<=now) 重推;死信留管理端手工重推。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CallbackDispatcher {
|
||||
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwCallbackRegRepository regRepository;
|
||||
private final FlwCallbackLogRepository logRepository;
|
||||
private final CallbackLogStore logStore;
|
||||
private final CallbackPushService pushService;
|
||||
private final CallbackProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private volatile ExecutorService pushExecutor;
|
||||
|
||||
private ExecutorService executor() {
|
||||
if (pushExecutor == null) {
|
||||
synchronized (this) {
|
||||
if (pushExecutor == null) {
|
||||
pushExecutor = Executors.newFixedThreadPool(Math.max(1, properties.getPushPoolSize()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return pushExecutor;
|
||||
}
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onFlowEvent(FlowEvent event) {
|
||||
// URGE 仅站内信通道,不进回调事件流(概览 §8.3 事件枚举固定六种)
|
||||
if (event.getType() == FlowEvent.Type.URGE) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dispatch(event);
|
||||
} catch (Exception e) {
|
||||
// 分发失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程
|
||||
log.error("[flow-center] 回调分发异常:instance={} event={} {}",
|
||||
event.getInstanceId(), event.getType().getCode(), e.toString(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(FlowEvent event) {
|
||||
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null);
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
if (def == null) {
|
||||
return;
|
||||
}
|
||||
String eventCode = event.getType().getCode();
|
||||
List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
|
||||
instance.getSchId(), def.getFlowGuid(), 0);
|
||||
for (FlwCallbackReg reg : regs) {
|
||||
if (!subscribed(reg.getEventTypes(), eventCode)) {
|
||||
continue;
|
||||
}
|
||||
persistAndPush(reg, instance, def, event);
|
||||
}
|
||||
}
|
||||
|
||||
private void persistAndPush(FlwCallbackReg reg, FlwInstance instance, FlwDef def, FlowEvent event) {
|
||||
try {
|
||||
String eventId = UUID.randomUUID().toString().replace("-", "").toLowerCase();
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("event", event.getType().getCode());
|
||||
payload.put("schId", instance.getSchId());
|
||||
payload.put("flowGuid", def.getFlowGuid());
|
||||
payload.put("instanceId", instance.getId());
|
||||
payload.put("instanceVersion", instance.getVersion());
|
||||
payload.put("bizType", instance.getBizType());
|
||||
payload.put("bizId", instance.getBizId());
|
||||
payload.put("bizTitle", instance.getBizTitle());
|
||||
payload.put("occurredTime", LocalDateTime.now().format(TIME));
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
if (event.getData() != null) {
|
||||
data.putAll(event.getData());
|
||||
}
|
||||
payload.put("data", data);
|
||||
String payloadJson = objectMapper.writeValueAsString(payload);
|
||||
|
||||
FlwCallbackLog callbackLog = new FlwCallbackLog();
|
||||
callbackLog.setSchId(instance.getSchId());
|
||||
callbackLog.setRegId(reg.getId());
|
||||
callbackLog.setEventId(eventId);
|
||||
callbackLog.setEvent(event.getType().getCode());
|
||||
callbackLog.setInstanceId(instance.getId());
|
||||
callbackLog.setBizType(instance.getBizType());
|
||||
callbackLog.setBizId(instance.getBizId());
|
||||
callbackLog.setPayloadJson(payloadJson);
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setRetryCount(0);
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now());
|
||||
// REQUIRES_NEW 落库(afterCommit 阶段原事务已完结,REQUIRED 会挂空事务)
|
||||
Long logId = logStore.persist(callbackLog);
|
||||
// 异步首推:失败由退避重试接手(1m 起)
|
||||
executor().submit(() -> pushService.pushAsync(logId));
|
||||
} catch (Exception e) {
|
||||
log.error("[flow-center] 回调日志落库失败(对账兜底会追平):instance={} event={} {}",
|
||||
instance.getId(), event.getType().getCode(), e.toString(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 重推扫描(fixedDelay 串行防重入;推拉结合里 Webhook 永远只是加速器) */
|
||||
@Scheduled(fixedDelay = 30_000)
|
||||
public void scanRetry() {
|
||||
List<FlwCallbackLog> due = logRepository.findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc(
|
||||
FlwEnums.CallbackLogStatus.PENDING.getCode(), LocalDateTime.now());
|
||||
for (FlwCallbackLog callbackLog : due) {
|
||||
executor().submit(() -> pushService.pushAsync(callbackLog.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
/** 订阅匹配:空=全部;逗号分隔事件码包含即命中 */
|
||||
private static boolean subscribed(String eventTypes, String eventCode) {
|
||||
if (eventTypes == null || eventTypes.trim().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (String t : eventTypes.split(",")) {
|
||||
if (eventCode.equals(t.trim())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 回调日志落库(独立 bean 保证 REQUIRES_NEW 生效):AFTER_COMMIT 监听阶段原事务已提交
|
||||
* 但事务资源仍挂在线程上,REQUIRED 会"加入"已完结事务并抛 no transaction is in progress——
|
||||
* 必须显式开新事务写日志。落库成功即视为主业务完成,推送失败不影响审批。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CallbackLogStore {
|
||||
|
||||
private final FlwCallbackLogRepository logRepository;
|
||||
|
||||
/** @return 落库后的日志行 id */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public Long persist(FlwCallbackLog callbackLog) {
|
||||
FlwCallbackLog saved = logRepository.saveAndFlush(callbackLog);
|
||||
return saved.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 回调推送配置(application.yml flow-center.callback.*)。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "flow-center.callback")
|
||||
public class CallbackProperties {
|
||||
|
||||
/** 推送线程池大小(概览 §8.4:单机小并发 5) */
|
||||
private int pushPoolSize = 5;
|
||||
|
||||
/**
|
||||
* 退避序列(分钟):1m→5m→30m→2h→6h;越过序列后按 {@link #capBackoffMinutes} 封顶持续重试,
|
||||
* 不自动死信(持续重试语义:消费方恢复即自动追平,消费方免对账)。
|
||||
*/
|
||||
private List<Long> retryBackoffMinutes = Arrays.asList(1L, 5L, 30L, 120L, 360L);
|
||||
|
||||
/**
|
||||
* 挂起告警阈值:retryCount 首次等于该值时 UserMsg 告警恰一次(retryCount 单调递增保证)。
|
||||
* 默认 6 = 越过退避表(5 项)后的第一次失败。
|
||||
*/
|
||||
private int hangAlertRetries = 6;
|
||||
|
||||
/** 越过退避表后的封顶重试间隔(分钟),默认 60 */
|
||||
private long capBackoffMinutes = 60;
|
||||
|
||||
/** 告警接收人(本校管理员 userId,逗号分隔;空=不告警只记日志)——挂起告警与死信告警共用 */
|
||||
private String alertUserIds = "";
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
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.flowcenter.notify.UserMsgClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.web.client.RestTemplate;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 回调推送执行(概览 §8.4 协议):POST callback_url + X-Flw-* 四头
|
||||
* (Signature = Base64(HmacSHA256(secret, timestamp + "." + nonce + "." + body)))。
|
||||
* 2xx 即成功(不解析响应体);失败按退避序列排下次重试,越过序列后封顶 cap-backoff-minutes
|
||||
* <b>持续重试不终止</b>(T1.1:消费方只承诺"瞬时失败回 5xx",恢复后自动追平,消费方免对账);
|
||||
* 挂起(retryCount 首达 hang-alert-retries)时 UserMsg 告警恰一次;
|
||||
* 死信(status=2)仅两个入口——注册停用、管理端手工标记,重试次数永不自动触发。
|
||||
* 推送一律在新事务里落库(AFTER_COMMIT 监听器/调度线程均无外层事务)。
|
||||
* 推送本身与落库解耦:payload_json 是完整快照,可重放。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CallbackPushService {
|
||||
|
||||
private static final Set<Long> IN_FLIGHT = new HashSet<>();
|
||||
|
||||
private final FlwCallbackLogRepository logRepository;
|
||||
private final FlwCallbackRegRepository regRepository;
|
||||
private final CallbackProperties properties;
|
||||
private final UserMsgClient userMsgClient;
|
||||
|
||||
/** 推送指定日志行(幂等入口:成功/终态/在途跳过) */
|
||||
public void pushAsync(Long logId) {
|
||||
synchronized (IN_FLIGHT) {
|
||||
if (!IN_FLIGHT.add(logId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
doPush(logId);
|
||||
} finally {
|
||||
synchronized (IN_FLIGHT) {
|
||||
IN_FLIGHT.remove(logId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void doPush(Long logId) {
|
||||
FlwCallbackLog callbackLog = logRepository.findById(logId).orElse(null);
|
||||
if (callbackLog == null
|
||||
|| callbackLog.getStatus() != FlwEnums.CallbackLogStatus.PENDING.getCode()) {
|
||||
return;
|
||||
}
|
||||
FlwCallbackReg reg = regRepository.findById(callbackLog.getRegId()).orElse(null);
|
||||
if (reg == null || reg.getStatus() != 0) {
|
||||
// 注册已停用:保留日志待查,不再推送(置死信便于管理端一次性清理)
|
||||
markDead(callbackLog, "registration disabled");
|
||||
return;
|
||||
}
|
||||
String body = callbackLog.getPayloadJson();
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-Flw-EventId", callbackLog.getEventId());
|
||||
headers.set("X-Flw-Timestamp", ts);
|
||||
headers.set("X-Flw-Nonce", nonce);
|
||||
headers.set("X-Flw-Signature", sign(reg.getSecret(), ts, nonce, body));
|
||||
RestTemplate template = rawTemplate();
|
||||
ResponseEntity<String> resp = template.exchange(reg.getCallbackUrl(), HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
int status = resp.getStatusCodeValue();
|
||||
if (status >= 200 && status < 300) {
|
||||
markSuccess(callbackLog, status);
|
||||
} else {
|
||||
markRetryOrFail(callbackLog, "HTTP " + status, String.valueOf(status));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
markRetryOrFail(callbackLog, rootMessage(e), null);
|
||||
}
|
||||
}
|
||||
|
||||
private void markSuccess(FlwCallbackLog callbackLog, int httpStatus) {
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.SUCCESS.getCode());
|
||||
callbackLog.setRetryCount(callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount());
|
||||
callbackLog.setLastHttpStatus(String.valueOf(httpStatus));
|
||||
callbackLog.setLastError(null);
|
||||
callbackLog.setPushTime(LocalDateTime.now());
|
||||
saveQuietly(callbackLog);
|
||||
log.debug("[flow-center] 回调推送成功:eventId={} http={}", callbackLog.getEventId(), httpStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败落定(持续重试语义):越过退避表后按 {@link CallbackProperties#getCapBackoffMinutes()}
|
||||
* 封顶继续排期,<b>绝不自动死信</b>;retryCount 首次等于挂起阈值时告警恰一次。
|
||||
*/
|
||||
private void markRetryOrFail(FlwCallbackLog callbackLog, String error, String httpStatus) {
|
||||
int retryCount = (callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount()) + 1;
|
||||
callbackLog.setRetryCount(retryCount);
|
||||
callbackLog.setLastHttpStatus(httpStatus);
|
||||
callbackLog.setLastError(truncate(error));
|
||||
java.util.List<Long> backoff = properties.getRetryBackoffMinutes();
|
||||
long backoffMinutes = retryCount <= backoff.size()
|
||||
? backoff.get(retryCount - 1)
|
||||
: Math.max(1, properties.getCapBackoffMinutes());
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now().plusMinutes(backoffMinutes));
|
||||
saveQuietly(callbackLog);
|
||||
log.warn("[flow-center] 回调推送失败(第 {} 次重试,{} 分钟后):eventId={} {}",
|
||||
retryCount, backoffMinutes, callbackLog.getEventId(), error);
|
||||
// 挂起告警恰一次:retryCount 每失败 +1 单调递增,等于阈值的时机只有一次;
|
||||
// msgNo("hang:"+eventId) 幂等双保险(管理端 rePush 重置 retryCount 后再挂起可再告警,属人工重启语义)
|
||||
if (retryCount == properties.getHangAlertRetries()) {
|
||||
alertHung(callbackLog, error, backoffMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
/** 挂起告警本校管理员(持续重试越过退避表=消费方疑似停机;UserMsg 不可达只记日志,不影响重试) */
|
||||
private void alertHung(FlwCallbackLog callbackLog, String error, long capMinutes) {
|
||||
java.util.List<Integer> userIds = parseAlertUserIds();
|
||||
if (userIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
userMsgClient.sendUserMsg(callbackLog.getSchId().intValue(), userIds, 1,
|
||||
"flow.hung", String.valueOf(callbackLog.getInstanceId()),
|
||||
"回调挂起告警",
|
||||
"办件 " + callbackLog.getInstanceId() + " 事件 " + callbackLog.getEvent()
|
||||
+ " 推送连续失败 " + callbackLog.getRetryCount() + " 次,已转入持续重试(封顶 "
|
||||
+ capMinutes + " 分钟/次)。消费方恢复后将自动追平,无需人工干预;"
|
||||
+ "如确认该事件已无意义,请在管理端标记死信。最近错误:" + truncate(error),
|
||||
null, "hang:" + callbackLog.getEventId(), null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 挂起告警发送失败:eventId={} {}", callbackLog.getEventId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记死信(public:管理端手工标记的唯一执行入口;重试路径已不再自动调用)。
|
||||
* 仍被注册停用场景使用(doPush:注册已停用即无意义重试,置死信便于管理端清理)。
|
||||
*/
|
||||
public void markDead(FlwCallbackLog callbackLog, String error) {
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.DEAD.getCode());
|
||||
callbackLog.setLastError(truncate(error));
|
||||
saveQuietly(callbackLog);
|
||||
log.error("[flow-center] 回调推送进入死信:eventId={} biz={}/{} error={}",
|
||||
callbackLog.getEventId(), callbackLog.getBizType(), callbackLog.getBizId(), error);
|
||||
alertDead(callbackLog, error);
|
||||
}
|
||||
|
||||
private java.util.List<Integer> parseAlertUserIds() {
|
||||
java.util.List<Integer> userIds = new java.util.ArrayList<>();
|
||||
String alertUserIds = properties.getAlertUserIds();
|
||||
if (alertUserIds == null || alertUserIds.trim().isEmpty()) {
|
||||
return userIds;
|
||||
}
|
||||
for (String s : alertUserIds.split(",")) {
|
||||
if (!s.trim().isEmpty()) {
|
||||
userIds.add(Integer.valueOf(s.trim()));
|
||||
}
|
||||
}
|
||||
return userIds;
|
||||
}
|
||||
|
||||
/** 死信告警本校管理员(配置 alertUserIds;UserMsg 不可达只记日志,不影响死信落定) */
|
||||
private void alertDead(FlwCallbackLog callbackLog, String error) {
|
||||
java.util.List<Integer> userIds = parseAlertUserIds();
|
||||
if (userIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
userMsgClient.sendUserMsg(callbackLog.getSchId().intValue(), userIds, 1,
|
||||
"flow.dead", String.valueOf(callbackLog.getInstanceId()),
|
||||
"回调推送死信告警",
|
||||
"办件 " + callbackLog.getInstanceId() + " 事件 " + callbackLog.getEvent()
|
||||
+ " 推送失败进入死信:" + error,
|
||||
null, "dead:" + callbackLog.getEventId(), null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 死信告警发送失败:eventId={} {}", callbackLog.getEventId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void saveQuietly(FlwCallbackLog callbackLog) {
|
||||
try {
|
||||
logRepository.save(callbackLog);
|
||||
} catch (Exception e) {
|
||||
log.error("[flow-center] 回调日志落库失败:eventId={} {}", callbackLog.getEventId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static String sign(String secret, String timestamp, String nonce, String body) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] raw = mac.doFinal((timestamp + "." + nonce + "." + body).getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("HmacSHA256 计算失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private RestTemplate rawTemplate() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(5000);
|
||||
factory.setReadTimeout(10000);
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable e) {
|
||||
Throwable cur = e;
|
||||
while (cur.getCause() != null && cur.getCause() != cur) {
|
||||
cur = cur.getCause();
|
||||
}
|
||||
return cur.toString();
|
||||
}
|
||||
|
||||
private static String truncate(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
return s.length() > 1000 ? s.substring(0, 1000) : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.yunhai.flowcenter.common;
|
||||
|
||||
import com.yunhai.flowcenter.entity.BaseEntity;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.PreUpdate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 审计字段自动填充(CmsAuditListener/SvcHallAuditListener 同款)。
|
||||
* 账号取 ThreadLocal 中的 CAS 会话用户;定时任务/回调推送等无会话场景为 null。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class FlowCenterAuditListener {
|
||||
|
||||
@PrePersist
|
||||
public void prePersist(BaseEntity entity) {
|
||||
String account = currentAccount();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
entity.setCreateBy(account);
|
||||
entity.setCreateTime(now);
|
||||
entity.setUpdateBy(account);
|
||||
entity.setUpdateTime(now);
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate(BaseEntity entity) {
|
||||
entity.setUpdateBy(currentAccount());
|
||||
entity.setUpdateTime(LocalDateTime.now());
|
||||
}
|
||||
|
||||
private String currentAccount() {
|
||||
LoginUser user = UserContextHolder.get();
|
||||
return user == null ? null : user.getLoginName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yunhai.flowcenter.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* 本地开发跨端口 CORS(V2.0 拍板项#2):办事大厅 front(8087)/本应用 front(8088) 直调
|
||||
* /api/flow/* 属跨端口请求,需带凭据的 CORS 头(CAS cookie 不隔离端口,免二次登录成立的前提)。
|
||||
* 同主域生产无跨端口问题,配置 allowed-origins 置空即整体关闭。
|
||||
*/
|
||||
@Configuration
|
||||
public class FlowCorsConfiguration {
|
||||
|
||||
public FlowCorsConfiguration(
|
||||
@Value("${flow-center.cors.allowed-origins:}") String allowedOrigins) {
|
||||
this.allowedOrigins = allowedOrigins;
|
||||
}
|
||||
|
||||
private final String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<CorsFilter> flowCorsFilter() {
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
if (allowedOrigins != null && !allowedOrigins.trim().isEmpty()) {
|
||||
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split("\\s*,\\s*")));
|
||||
config.setAllowedMethods(Collections.singletonList("*"));
|
||||
config.setAllowedHeaders(Collections.singletonList("*"));
|
||||
config.setAllowCredentials(true);
|
||||
// 只对用户面生效;/oapi/* 是应用签名调用,不经浏览器
|
||||
source.registerCorsConfiguration("/api/**", config);
|
||||
}
|
||||
FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
return bean;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.yunhai.flowcenter.controller.api;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.service.FlowAdminGuard;
|
||||
import com.yunhai.flowcenter.service.FlowCallbackAdminService;
|
||||
import com.yunhai.flowcenter.service.FlowDefAdminService;
|
||||
import com.yunhai.flowcenter.service.FlowDiagramService;
|
||||
import com.yunhai.flowcenter.service.FlowMapAdminService;
|
||||
import com.yunhai.platform.utils.model.BasePageResult;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 管理端(/api/flow/admin/**,CAS + 管理员角色,FlowAdminGuard 校验):
|
||||
* 定义 JSON 编辑/发布/停用、办理人映射 CRUD(成员检索调 UserBase)、
|
||||
* 回调注册管理/推送日志/死信重推、办件查询。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/flow/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class FlowAdminController {
|
||||
|
||||
private final FlowAdminGuard adminGuard;
|
||||
private final FlowDefAdminService defAdminService;
|
||||
private final FlowMapAdminService mapAdminService;
|
||||
private final FlowCallbackAdminService callbackAdminService;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlowDiagramService diagramService;
|
||||
|
||||
// ==================== 流程定义 ====================
|
||||
|
||||
@GetMapping("/def/list")
|
||||
public List<FlwDef> defList(BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status) {
|
||||
return defAdminService.list(adminGuard.requireAdmin(user, null), status);
|
||||
}
|
||||
|
||||
@GetMapping("/def/get")
|
||||
public FlwDef defGet(BoaUser user, @RequestParam("id") Long id) {
|
||||
return defAdminService.get(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
/** 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿) */
|
||||
@PostMapping("/def/save")
|
||||
public FlwDef defSave(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
Long id = body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id")));
|
||||
return defAdminService.save(schId, id,
|
||||
(String) body.get("code"), (String) body.get("name"),
|
||||
(String) body.get("contentJson"), (String) body.get("baseGuid"));
|
||||
}
|
||||
|
||||
@PostMapping("/def/publish/{id}")
|
||||
public FlwDef defPublish(BoaUser user, @PathVariable("id") Long id) {
|
||||
return defAdminService.publish(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
@PostMapping("/def/offline/{id}")
|
||||
public void defOffline(BoaUser user, @PathVariable("id") Long id) {
|
||||
defAdminService.offline(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
@GetMapping("/def/versions")
|
||||
public List<FlwDef> defVersions(BoaUser user, @RequestParam("flowGuid") String flowGuid) {
|
||||
return defAdminService.versions(adminGuard.requireAdmin(user, null), flowGuid);
|
||||
}
|
||||
|
||||
/** 流程定义示意图(方案 §3.1):requireAdmin 后按 defId 任意版本出图(草稿可预览) */
|
||||
@GetMapping("/def/diagram")
|
||||
public FlowModels.DiagramVo defDiagram(BoaUser user, @RequestParam("id") Long id,
|
||||
@RequestParam(value = "mode", required = false) String mode) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
FlwDef def = defAdminService.get(schId, id);
|
||||
return diagramService.build(def, null, mode);
|
||||
}
|
||||
|
||||
// ==================== 办理人映射 ====================
|
||||
|
||||
@GetMapping("/map/list")
|
||||
public List<FlwAssigneeMap> mapList(BoaUser user,
|
||||
@RequestParam(value = "deptKey", required = false) String deptKey) {
|
||||
return mapAdminService.listByDept(adminGuard.requireAdmin(user, null), deptKey);
|
||||
}
|
||||
|
||||
@PostMapping("/map/save")
|
||||
public FlwAssigneeMap mapSave(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return mapAdminService.save(schId,
|
||||
(String) body.get("deptKey"), (String) body.get("deptName"),
|
||||
body.get("userId") == null ? null : Integer.valueOf(String.valueOf(body.get("userId"))),
|
||||
(String) body.get("userName"));
|
||||
}
|
||||
|
||||
@PostMapping("/map/disable/{id}")
|
||||
public void mapDisable(BoaUser user, @PathVariable("id") Long id) {
|
||||
mapAdminService.disable(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
/** 成员检索(UserBase /oapi/user/byId) */
|
||||
@GetMapping("/map/lookupUser")
|
||||
public com.yunhai.platform.utils.model.BoaUser lookupUser(BoaUser user,
|
||||
@RequestParam("userId") Integer userId) {
|
||||
adminGuard.requireAdmin(user, null);
|
||||
return mapAdminService.lookupUser(userId);
|
||||
}
|
||||
|
||||
// ==================== 回调注册/推送日志/死信 ====================
|
||||
|
||||
@GetMapping("/callback/regList")
|
||||
public List<FlwCallbackReg> regList(BoaUser user) {
|
||||
return callbackAdminService.listRegs(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
@PostMapping("/callback/regStatus/{id}")
|
||||
public void regStatus(BoaUser user, @PathVariable("id") Long id,
|
||||
@RequestParam("enabled") boolean enabled) {
|
||||
callbackAdminService.setRegStatus(adminGuard.requireAdmin(user, null), id, enabled);
|
||||
}
|
||||
|
||||
/** secret 重置(新 secret 返回给前端展示一次) */
|
||||
@PostMapping("/callback/resetSecret/{id}")
|
||||
public Map<String, String> resetSecret(BoaUser user, @PathVariable("id") Long id) {
|
||||
String secret = callbackAdminService.resetSecret(adminGuard.requireAdmin(user, null), id);
|
||||
return java.util.Collections.singletonMap("secret", secret);
|
||||
}
|
||||
|
||||
@GetMapping("/callback/logs")
|
||||
public BasePageResult<FlwCallbackLog> logs(BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@RequestParam(value = "event", required = false) String event,
|
||||
@RequestParam(value = "instanceId", required = false) Long instanceId,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return callbackAdminService.pageLogs(adminGuard.requireAdmin(user, null), status, event, instanceId, page, size);
|
||||
}
|
||||
|
||||
/** 挂起列表(待推且重试达阈值;持续重试中疑似消费方停机的可视入口) */
|
||||
@GetMapping("/callback/hung")
|
||||
public BasePageResult<FlwCallbackLog> hung(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return callbackAdminService.pageHung(adminGuard.requireAdmin(user, null), page, size);
|
||||
}
|
||||
|
||||
/** 管理端手工标记死信(死信唯一人工入口;标记后可 rePush 复活) */
|
||||
@PostMapping("/callback/markDead/{id}")
|
||||
public void markDead(BoaUser user, @PathVariable("id") Long id) {
|
||||
callbackAdminService.markDead(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
@GetMapping("/callback/dead")
|
||||
public BasePageResult<FlwCallbackLog> dead(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return callbackAdminService.pageDead(adminGuard.requireAdmin(user, null), page, size);
|
||||
}
|
||||
|
||||
@PostMapping("/callback/repush")
|
||||
public void repush(BoaUser user, @RequestBody List<Long> logIds) {
|
||||
callbackAdminService.rePush(adminGuard.requireAdmin(user, null), logIds);
|
||||
}
|
||||
|
||||
// ==================== 办件查询 ====================
|
||||
|
||||
@GetMapping("/instance/page")
|
||||
public BasePageResult<FlwInstance> instancePage(
|
||||
BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword,
|
||||
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
|
||||
size == null || size < 1 || size > 100 ? 10 : size,
|
||||
Sort.by(Sort.Order.desc("id"))));
|
||||
int pi = page == null || page < 1 ? 1 : page;
|
||||
int ps = size == null || size < 1 || size > 100 ? 10 : size;
|
||||
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(), pi, ps,
|
||||
p.getTotalPages(), p.hasNext());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.flowcenter.controller.api;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 前端壳基础端点(对齐 UserBase BaseController 的 /api/base/* 形态):
|
||||
* GetUserInfo 返回当前登录人;GetUserMod 返回本地路由菜单——flow-center 是独立登录态应用,
|
||||
* 菜单不走 UserBase app_module 下发(概览 §9.4),modId 为本应用内部约定。
|
||||
*/
|
||||
@RestController
|
||||
public class FlowBaseController {
|
||||
|
||||
/** 左侧菜单(本地路由,顺序即展示顺序) */
|
||||
private static final String[][] MENUS = {
|
||||
{"100", "工作台", "/workbench"},
|
||||
{"101", "我的办件", "/my"},
|
||||
{"102", "办件记录", "/admin/instances"},
|
||||
{"103", "流程定义", "/admin/defs"},
|
||||
{"104", "办理人映射", "/admin/maps"},
|
||||
{"105", "回调管理", "/admin/callbacks"},
|
||||
};
|
||||
|
||||
@GetMapping("/api/base/GetUserInfo")
|
||||
public BoaUser getUserInfo(BoaUser authUserWithDept) {
|
||||
return authUserWithDept;
|
||||
}
|
||||
|
||||
@GetMapping("/api/base/GetUserMod")
|
||||
public List<Map<String, Object>> getUserMod() {
|
||||
List<Map<String, Object>> modList = new ArrayList<>();
|
||||
for (String[] m : MENUS) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("id", Integer.valueOf(m[0]));
|
||||
item.put("modId", Integer.valueOf(m[0]));
|
||||
item.put("name", m[1]);
|
||||
item.put("page", m[2]);
|
||||
item.put("children", new ArrayList<>());
|
||||
modList.add(item);
|
||||
}
|
||||
return modList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.yunhai.flowcenter.controller.api;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.service.FlowAccessGuard;
|
||||
import com.yunhai.flowcenter.service.FlowDiagramService;
|
||||
import com.yunhai.flowcenter.service.FlowInstanceService;
|
||||
import com.yunhai.flowcenter.service.FlowQueryService;
|
||||
import com.yunhai.flowcenter.service.FlowTaskService;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BasePageResult;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户面(/api/flow/**,CAS 会话):我的办件/待办/已办/办件详情/办理/撤回/催办。
|
||||
* 一切审批入口的收敛点(概览 §8.5 不变式 2):业务系统内嵌页、消息跳转最终都调这套 API。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/flow")
|
||||
@RequiredArgsConstructor
|
||||
public class FlowUserController {
|
||||
|
||||
private final FlowQueryService queryService;
|
||||
private final FlowTaskService taskService;
|
||||
private final FlowInstanceService instanceService;
|
||||
private final FlowAccessGuard accessGuard;
|
||||
private final FlowDiagramService diagramService;
|
||||
|
||||
/** 我提交的办件 */
|
||||
@GetMapping("/my")
|
||||
public BasePageResult<FlowModels.InstanceItem> my(BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return queryService.pageMy(requireSchId(user), user.getId(), status, page, size);
|
||||
}
|
||||
|
||||
/** 我的待办(主路径,走 assignee 索引) */
|
||||
@GetMapping("/todo")
|
||||
public BasePageResult<FlowModels.TaskItem> todo(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return queryService.pageTodo(requireSchId(user), user.getId(), page, size);
|
||||
}
|
||||
|
||||
/** 我的已办 */
|
||||
@GetMapping("/done")
|
||||
public BasePageResult<FlowModels.TaskItem> done(BoaUser user,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return queryService.pageDone(requireSchId(user), user.getId(), page, size);
|
||||
}
|
||||
|
||||
/** 办件详情(提交人/办理人/本校管理员可见) */
|
||||
@GetMapping("/instance/{id}")
|
||||
public FlowModels.InstanceDetail detail(BoaUser user, @PathVariable("id") Long id) {
|
||||
FlwInstance instance = instanceService.requireInstance(requireSchId(user), id);
|
||||
accessGuard.requireInstanceVisible(instance, user);
|
||||
return queryService.detail(requireSchId(user), id, user.getId());
|
||||
}
|
||||
|
||||
/** 任务定位办件详情(办理页 /task/{taskId} 数据源;可见性走办件详情同一守卫) */
|
||||
@GetMapping("/task/{id}/detail")
|
||||
public FlowModels.InstanceDetail taskDetail(BoaUser user, @PathVariable("id") Long id) {
|
||||
Long schId = requireSchId(user);
|
||||
FlwTask task = taskService.requireTask(schId, id);
|
||||
FlwInstance instance = instanceService.requireInstance(schId, task.getInstanceId());
|
||||
accessGuard.requireInstanceVisible(instance, user);
|
||||
return queryService.detail(schId, instance.getId(), user.getId());
|
||||
}
|
||||
|
||||
/** 同意(opinion 可选)——一切审批入口的收敛点 */
|
||||
@PostMapping("/task/{id}/approve")
|
||||
public void approve(BoaUser user, @PathVariable("id") Long id,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
String opinion = body == null ? null : body.get("opinion");
|
||||
taskService.handle(requireSchId(user), id, true, opinion, user.getId());
|
||||
}
|
||||
|
||||
/** 拒绝(opinion 必填) */
|
||||
@PostMapping("/task/{id}/reject")
|
||||
public void reject(BoaUser user, @PathVariable("id") Long id,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
String opinion = body == null ? null : body.get("opinion");
|
||||
taskService.handle(requireSchId(user), id, false, opinion, user.getId());
|
||||
}
|
||||
|
||||
/** 撤回(提交人;仅尚无任务被办理时) */
|
||||
@PostMapping("/instance/{id}/withdraw")
|
||||
public void withdraw(BoaUser user, @PathVariable("id") Long id) {
|
||||
FlwInstance instance = instanceService.requireInstance(requireSchId(user), id);
|
||||
if (!instance.getSubmitterId().equals(user.getId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅提交人本人可撤回");
|
||||
}
|
||||
instanceService.withdraw(requireSchId(user), id);
|
||||
}
|
||||
|
||||
/** 催办(提交人/管理员;对全部待办任务各催一次,msgNo 幂等) */
|
||||
@PostMapping("/instance/{id}/urge")
|
||||
public void urge(BoaUser user, @PathVariable("id") Long id) {
|
||||
instanceService.urgeInstance(requireSchId(user), id, user.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程示意图(方案 §3.1):flowGuid=最高已发布版(登录+本校);
|
||||
* instanceId=办件 def 快照版,可见性与办件详情同口径(requireInstanceVisible)。
|
||||
*/
|
||||
@GetMapping("/diagram")
|
||||
public FlowModels.DiagramVo diagram(BoaUser user,
|
||||
@RequestParam(value = "flowGuid", required = false) String flowGuid,
|
||||
@RequestParam(value = "instanceId", required = false) Long instanceId,
|
||||
@RequestParam(value = "mode", required = false) String mode) {
|
||||
Long schId = requireSchId(user);
|
||||
if (flowGuid != null && !flowGuid.trim().isEmpty()) {
|
||||
return diagramService.byGuid(schId, flowGuid.trim(), mode);
|
||||
}
|
||||
if (instanceId != null) {
|
||||
FlwInstance instance = instanceService.requireInstance(schId, instanceId);
|
||||
accessGuard.requireInstanceVisible(instance, user);
|
||||
return diagramService.byInstance(instance, mode);
|
||||
}
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:flowGuid 或 instanceId");
|
||||
}
|
||||
|
||||
private Long requireSchId(BoaUser user) {
|
||||
if (user == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
|
||||
}
|
||||
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属(区域账号无流程业务)");
|
||||
}
|
||||
return Long.valueOf(user.getCurrentRole().getSchId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.yunhai.flowcenter.controller.oapi;
|
||||
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.service.OapiFlowService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 托管接入件(/oapi/flow/**,appKey 签名强制,V2.0 Part D #5 前置 M1)。
|
||||
* 出入参逐字对齐 svc-hall FlowCenterClient(《流程中心M1开工说明.md》§5 契约锚点):
|
||||
* - GET /oapi/flow/list?schId= → data=[{flowGuid,name,version,nodeSummary}]
|
||||
* - GET /oapi/flow/{flowGuid}?schId= → data={flowGuid,name,version,nodeSummary}
|
||||
* - POST /oapi/flow/apply → data={instanceId,duplicated}
|
||||
* - GET /oapi/flow/instance?bizType=&bizId=&schId= → data={status,version,instanceId,nodeSummary,traceJson,…}
|
||||
* - POST /oapi/flow/callback/reg?schId= → data={secret}
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/oapi/flow")
|
||||
@RequiredArgsConstructor
|
||||
public class OapiFlowController {
|
||||
|
||||
private final OapiFlowService flowService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public List<OapiModels.FlowSummaryVo> list(@RequestParam("schId") Long schId) {
|
||||
return flowService.listFlows(schId);
|
||||
}
|
||||
|
||||
@GetMapping("/{flowGuid}")
|
||||
public OapiModels.FlowSummaryVo getFlow(@PathVariable("flowGuid") String flowGuid,
|
||||
@RequestParam("schId") Long schId) {
|
||||
return flowService.getFlow(schId, flowGuid);
|
||||
}
|
||||
|
||||
@PostMapping("/apply")
|
||||
public OapiModels.ApplyResultVo apply(@RequestParam("schId") Long schId,
|
||||
@RequestBody OapiModels.ApplyBody body) {
|
||||
return flowService.apply(schId, body);
|
||||
}
|
||||
|
||||
@GetMapping("/instance")
|
||||
public OapiModels.InstanceVo instance(@RequestParam("bizType") String bizType,
|
||||
@RequestParam("bizId") String bizId,
|
||||
@RequestParam("schId") Long schId) {
|
||||
return flowService.instanceByBiz(schId, bizType, bizId);
|
||||
}
|
||||
|
||||
@PostMapping("/callback/reg")
|
||||
public OapiModels.CallbackRegResultVo registerCallback(
|
||||
@RequestParam("schId") Long schId,
|
||||
@RequestAttribute(value = "yh.app.auth", required = false) String appKey,
|
||||
@RequestBody OapiModels.CallbackRegBody body) {
|
||||
return flowService.registerCallback(schId, appKey, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
/**
|
||||
* 办理人解析结果(id+姓名快照),任务创建与通知共用。
|
||||
*/
|
||||
@Value
|
||||
public class AssigneeRef {
|
||||
Integer userId;
|
||||
String userName;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 办理人解析(概览 §六):DEPT_KEY 查显式映射表(零跨服务依赖);USERS 定义直配,
|
||||
* 姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。SUBMITTER_DEPT/FORM_FIELD 二期。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AssigneeResolver {
|
||||
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final OpenApiUserClient userClient;
|
||||
|
||||
public List<AssigneeRef> resolve(Long schId, FlowDefModel.Node node) {
|
||||
FlowDefModel.Assignee a = node.assignee;
|
||||
if (a == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少办理人配置:" + node.id);
|
||||
}
|
||||
List<AssigneeRef> refs = new ArrayList<>();
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
List<FlwAssigneeMap> maps = mapRepository.findEnabled(schId, a.deptKey);
|
||||
for (FlwAssigneeMap m : maps) {
|
||||
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||||
if (a.userIds != null) {
|
||||
for (Integer uid : a.userIds) {
|
||||
refs.add(new AssigneeRef(uid, fetchName(uid)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "一期不支持办理人类型:" + a.type);
|
||||
}
|
||||
if (refs.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"节点[" + node.id + "]无可用办理人(映射可能已被停用)");
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
private String fetchName(Integer userId) {
|
||||
try {
|
||||
BoaUser user = userClient.getUserById(String.valueOf(userId));
|
||||
return user == null ? "" : (user.getName() == null ? "" : user.getName());
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] UserBase 用户名补全失败 userId={}:{}", userId, e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 定义发布静态校验(概览 §5.2 约束):把"任务无主/死图"拦在发布期——
|
||||
* START/END 存在且唯一、无孤儿节点、无环、每条 DEPT_KEY 在 flw_assignee_map 有 ≥1 启用映射、
|
||||
* 分发/汇聚节点边数约束、会签/拒绝策略取值合法。校验失败抛 BizException 带全部问题清单。
|
||||
*/
|
||||
public final class DefValidator {
|
||||
|
||||
private DefValidator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param enabledDeptKeys 本校 flw_assignee_map 中启用映射的 dept_key 全集
|
||||
*/
|
||||
public static void validate(FlowDefModel model, Set<String> enabledDeptKeys) {
|
||||
List<String> problems = new ArrayList<>();
|
||||
|
||||
if (model.nodes == null || model.nodes.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点");
|
||||
}
|
||||
// 节点 id 唯一且非空
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (n.id == null || n.id.trim().isEmpty()) {
|
||||
problems.add("存在空 id 节点");
|
||||
continue;
|
||||
}
|
||||
if (!ids.add(n.id)) {
|
||||
problems.add("节点 id 重复:" + n.id);
|
||||
}
|
||||
}
|
||||
// START/END 唯一;类型合法
|
||||
int starts = 0;
|
||||
int ends = 0;
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (n.type == null) {
|
||||
problems.add("节点缺少 type:" + n.id);
|
||||
continue;
|
||||
}
|
||||
switch (n.type) {
|
||||
case FlowDefModel.TYPE_START:
|
||||
starts++;
|
||||
break;
|
||||
case FlowDefModel.TYPE_END:
|
||||
ends++;
|
||||
break;
|
||||
case FlowDefModel.TYPE_APPROVAL:
|
||||
validateApproval(n, enabledDeptKeys, problems);
|
||||
break;
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT:
|
||||
case FlowDefModel.TYPE_PARALLEL_JOIN:
|
||||
break;
|
||||
default:
|
||||
problems.add("未知节点类型:" + n.type + "(节点 " + n.id + ")");
|
||||
}
|
||||
}
|
||||
if (starts != 1) {
|
||||
problems.add("START 节点必须存在且唯一(当前 " + starts + " 个)");
|
||||
}
|
||||
if (ends != 1) {
|
||||
problems.add("END 节点必须存在且唯一(当前 " + ends + " 个)");
|
||||
}
|
||||
// start 属性指向 START 节点
|
||||
FlowDefModel.Node startNode = model.node(model.start);
|
||||
if (startNode == null || !FlowDefModel.TYPE_START.equals(startNode.type)) {
|
||||
problems.add("start 属性必须指向 START 节点");
|
||||
}
|
||||
// 边引用完整 + 度约束
|
||||
Set<String> outCount = new HashSet<>();
|
||||
for (FlowDefModel.Edge e : model.edges) {
|
||||
if (model.node(e.from) == null) {
|
||||
problems.add("边引用不存在的 from 节点:" + e.from);
|
||||
}
|
||||
if (model.node(e.to) == null) {
|
||||
problems.add("边引用不存在的 to 节点:" + e.to);
|
||||
}
|
||||
}
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
int out = model.outEdges(n.id).size();
|
||||
int in = model.inEdges(n.id).size();
|
||||
if (FlowDefModel.TYPE_START.equals(n.type) || FlowDefModel.TYPE_APPROVAL.equals(n.type)
|
||||
|| FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type)) {
|
||||
if (out != 1) {
|
||||
problems.add(nodeLabel(n) + " 出边必须恰为 1 条(当前 " + out + ")");
|
||||
}
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type) && out < 1) {
|
||||
problems.add(nodeLabel(n) + " 出边至少 1 条");
|
||||
}
|
||||
if (FlowDefModel.TYPE_END.equals(n.type) && out > 0) {
|
||||
problems.add(nodeLabel(n) + " 不允许有出边");
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type) && in < 1) {
|
||||
problems.add(nodeLabel(n) + " 入边至少 1 条");
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type) && n.waitFor != null
|
||||
&& !FlowDefModel.COUNTERSIGN_ALL.equals(n.waitFor)) {
|
||||
problems.add(nodeLabel(n) + " waitFor 一期仅支持 ALL");
|
||||
}
|
||||
}
|
||||
// 汇聚点必须有上游分发(分支进度行由 split 创建)
|
||||
boolean hasSplit = model.nodes.stream().anyMatch(n -> FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type));
|
||||
boolean hasJoin = model.nodes.stream().anyMatch(n -> FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type));
|
||||
if (hasJoin && !hasSplit) {
|
||||
problems.add("存在 PARALLEL_JOIN 但无 PARALLEL_SPLIT");
|
||||
}
|
||||
// 可达性(无孤儿)+ 无环:从 start 出发 DFS,环检测用着色
|
||||
if (startNode != null) {
|
||||
Set<String> visited = new HashSet<>();
|
||||
Set<String> inStack = new HashSet<>();
|
||||
dfs(model, startNode.id, visited, inStack, problems);
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (!visited.contains(n.id)) {
|
||||
problems.add("孤儿节点(从 start 不可达):" + n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 拒绝策略取值
|
||||
if (model.settings.rejectPolicy != null
|
||||
&& !FlowDefModel.REJECT_END_REJECTED.equals(model.settings.rejectPolicy)) {
|
||||
problems.add("rejectPolicy 一期仅支持 END_REJECTED");
|
||||
}
|
||||
if (!problems.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "发布校验失败:" + String.join(";", problems));
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateApproval(FlowDefModel.Node n, Set<String> enabledDeptKeys, List<String> problems) {
|
||||
FlowDefModel.Assignee a = n.assignee;
|
||||
if (a == null || a.type == null) {
|
||||
problems.add(nodeLabel(n) + " 缺少 assignee 配置");
|
||||
return;
|
||||
}
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
if (a.deptKey == null || a.deptKey.trim().isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " DEPT_KEY 办理人缺少 deptKey");
|
||||
} else if (enabledDeptKeys != null && !enabledDeptKeys.contains(a.deptKey)) {
|
||||
problems.add(nodeLabel(n) + " 的 DEPT_KEY=" + a.deptKey + " 无启用中的办理人映射");
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||||
if (a.userIds == null || a.userIds.isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " USERS 办理人缺少 userIds");
|
||||
}
|
||||
} else {
|
||||
problems.add(nodeLabel(n) + " 未知办理人类型:" + a.type + "(一期支持 DEPT_KEY/USERS)");
|
||||
}
|
||||
if (n.countersign != null && !FlowDefModel.COUNTERSIGN_ALL.equals(n.countersign)
|
||||
&& !FlowDefModel.COUNTERSIGN_ANY.equals(n.countersign)) {
|
||||
problems.add(nodeLabel(n) + " countersign 取值非法:" + n.countersign);
|
||||
}
|
||||
if (n.deadlineHours != null && n.deadlineHours <= 0) {
|
||||
problems.add(nodeLabel(n) + " deadlineHours 必须为正整数");
|
||||
}
|
||||
}
|
||||
|
||||
private static void dfs(FlowDefModel model, String current, Set<String> visited,
|
||||
Set<String> inStack, List<String> problems) {
|
||||
visited.add(current);
|
||||
inStack.add(current);
|
||||
for (FlowDefModel.Edge e : model.outEdges(current)) {
|
||||
if (inStack.contains(e.to)) {
|
||||
problems.add("流程图存在环:" + current + " → " + e.to);
|
||||
continue;
|
||||
}
|
||||
if (!visited.contains(e.to)) {
|
||||
dfs(model, e.to, visited, inStack, problems);
|
||||
}
|
||||
}
|
||||
inStack.remove(current);
|
||||
}
|
||||
|
||||
private static String nodeLabel(FlowDefModel.Node n) {
|
||||
return "节点[" + n.id + (n.name == null || n.name.isEmpty() ? "" : " " + n.name) + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 节点放行推进(AFTER_COMMIT 监听器经代理调用本 bean,REQUIRES_NEW 生效——
|
||||
* 事务已提交后的同步监听阶段没有活动事务,@Modifying 批量语句必须有新事务承载):
|
||||
* 该节点待办清零且办件在途才推进;并发冲突由 flw_instance.version 乐观锁暴露,监听器层负责重试。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowAdvanceService {
|
||||
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
|
||||
/** @return 是否实际推进了 */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean tryAdvance(Long instanceId, String nodeId) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId).orElse(null);
|
||||
if (instance == null
|
||||
|| instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
|
||||
return false;
|
||||
}
|
||||
// 已推进过的节点不再放行(currentNodes 由引擎在放行时移除,乐观锁保证互斥)
|
||||
String current = instance.getCurrentNodes();
|
||||
boolean active = current != null && java.util.Arrays.stream(current.split(","))
|
||||
.anyMatch(nodeId::equals);
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
long pending = taskRepository.findByInstanceIdAndStatus(instanceId,
|
||||
FlwEnums.TaskStatus.PENDING.getCode()).stream()
|
||||
.filter(t -> nodeId.equals(t.getNodeId()))
|
||||
.count();
|
||||
if (pending > 0) {
|
||||
return false;
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
if (def == null) {
|
||||
log.error("[flow-center] 办件定义缺失,推进中止:instance={} defId={}",
|
||||
instanceId, instance.getDefId());
|
||||
return false;
|
||||
}
|
||||
flowEngine.continueAdvance(instance, def, nodeId);
|
||||
instanceRepository.save(instance);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 流程定义 JSON 模型(概览 §5.1 规范,一期 5 节点)。
|
||||
* 序列化/反序列化统一走 {@link #parse(ObjectMapper, String)},未知属性忽略(向前兼容)。
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class FlowDefModel {
|
||||
|
||||
/** 节点类型(一期 5 种,二期按需扩展 NOTIFY/CONDITION) */
|
||||
public static final String TYPE_START = "START";
|
||||
public static final String TYPE_END = "END";
|
||||
public static final String TYPE_APPROVAL = "APPROVAL";
|
||||
public static final String TYPE_PARALLEL_SPLIT = "PARALLEL_SPLIT";
|
||||
public static final String TYPE_PARALLEL_JOIN = "PARALLEL_JOIN";
|
||||
|
||||
/** 会签模式 */
|
||||
public static final String COUNTERSIGN_ALL = "ALL";
|
||||
public static final String COUNTERSIGN_ANY = "ANY";
|
||||
|
||||
/** 拒绝策略(一期仅 END_REJECTED) */
|
||||
public static final String REJECT_END_REJECTED = "END_REJECTED";
|
||||
|
||||
/** 办理人类型(概览 §六:一期 DEPT_KEY/USERS;SUBMITTER_DEPT/FORM_FIELD 二期) */
|
||||
public static final String ASSIGNEE_DEPT_KEY = "DEPT_KEY";
|
||||
public static final String ASSIGNEE_USERS = "USERS";
|
||||
|
||||
public String code;
|
||||
public String name;
|
||||
public String start;
|
||||
public List<Node> nodes;
|
||||
public List<Edge> edges;
|
||||
public Settings settings;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Node {
|
||||
public String id;
|
||||
public String type;
|
||||
public String name;
|
||||
public Assignee assignee;
|
||||
/** ALL(默认)|ANY */
|
||||
public String countersign;
|
||||
/** PARALLEL_JOIN 汇聚条件,一期仅 ALL */
|
||||
public String waitFor;
|
||||
/** 办理时限小时数(自然时间,任务 arrive_time + N 小时;可空) */
|
||||
public Integer deadlineHours;
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Assignee {
|
||||
/** DEPT_KEY | USERS */
|
||||
public String type;
|
||||
public String deptKey;
|
||||
public List<Integer> userIds;
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Edge {
|
||||
public String from;
|
||||
public String to;
|
||||
public String name;
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Settings {
|
||||
public String rejectPolicy = REJECT_END_REJECTED;
|
||||
public Urge urge = new Urge();
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Urge {
|
||||
public Boolean enabled = true;
|
||||
public Integer intervalHours = 24;
|
||||
public Integer maxTimes = 3;
|
||||
}
|
||||
|
||||
public static FlowDefModel parse(ObjectMapper objectMapper, String json) {
|
||||
try {
|
||||
FlowDefModel model = objectMapper.readValue(json, FlowDefModel.class);
|
||||
if (model == null || model.nodes == null || model.edges == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义 JSON 缺少 nodes/edges");
|
||||
}
|
||||
if (model.settings == null) {
|
||||
model.settings = new Settings();
|
||||
}
|
||||
return model;
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义 JSON 不合法:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public Node node(String nodeId) {
|
||||
for (Node n : nodes) {
|
||||
if (n.id != null && n.id.equals(nodeId)) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Node nodeById(String nodeId) {
|
||||
Node n = node(nodeId);
|
||||
if (n == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点:" + nodeId);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 出边集合(保持定义顺序) */
|
||||
public List<Edge> outEdges(String nodeId) {
|
||||
java.util.List<Edge> out = new java.util.ArrayList<>();
|
||||
for (Edge e : edges) {
|
||||
if (nodeId != null && nodeId.equals(e.from)) {
|
||||
out.add(e);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<Edge> inEdges(String nodeId) {
|
||||
java.util.List<Edge> in = new java.util.ArrayList<>();
|
||||
for (Edge e : edges) {
|
||||
if (nodeId != null && nodeId.equals(e.to)) {
|
||||
in.add(e);
|
||||
}
|
||||
}
|
||||
return in;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.repository.FlwBranchRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 流程引擎推进核心(概览 §5.2 激活规则):由调用方事务驱动——
|
||||
* 发起后 startAdvance 从 START 推进;任务全同意后 continueAdvance 推进下一节点。
|
||||
* 只做状态迁移与事件发布(AFTER_COMMIT 解耦通知/回调),不感知任何业务方。
|
||||
*
|
||||
* <p>推进算法(递归激活,branchKey 携带分支上下文):START→下节点;APPROVAL→建任务等待;
|
||||
* PARALLEL_SPLIT→逐出边建分支行并递归;PARALLEL_JOIN→标记到达、全到达才放行;END→办件完成。
|
||||
* 拒绝/撤回的终止清理在 FlowTaskService(终点语义与任务抢占耦合)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowEngine {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlwBranchRepository branchRepository;
|
||||
private final AssigneeResolver assigneeResolver;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/** 发起后首推:从 START 激活(instance 已建好、status=10) */
|
||||
public void startAdvance(FlwInstance instance, FlwDef def) {
|
||||
FlowDefModel model = parseModel(def);
|
||||
setCurrentNodes(instance, null);
|
||||
advance(instance, model, model.start, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 某节点全部同意后的放行推进:移出活跃节点集并激活唯一出边目标。
|
||||
* 分支上下文由 {@link #branchKeyOf} 从图上回溯推导(分支内串行链的中间节点无任务级分支标记)。
|
||||
*/
|
||||
public void continueAdvance(FlwInstance instance, FlwDef def, String fromNodeId) {
|
||||
FlowDefModel model = parseModel(def);
|
||||
FlowDefModel.Node node = model.nodeById(fromNodeId);
|
||||
List<FlowDefModel.Edge> outs = model.outEdges(fromNodeId);
|
||||
if (outs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
removeCurrentNode(instance, fromNodeId);
|
||||
String branchKey = branchKeyOf(model, fromNodeId);
|
||||
String next = outs.get(0).to;
|
||||
FlowDefModel.Node nextNode = model.nodeById(next);
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(nextNode.type)) {
|
||||
// 会签合并守门:标记本分支到达、全部到达才放行(直通无分支键时 JOIN 直通放行)
|
||||
advance(instance, model, next, branchKey);
|
||||
} else {
|
||||
activate(instance, model, nextNode, branchKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回溯 fromNodeId 所在分支的 branchKey(= PARALLEL_SPLIT 出边目标节点 id):
|
||||
* 沿入边反向走到 split,split→N 边的 N 即分支键;遇 START/PARALLEL_JOIN 边界即停
|
||||
* (join 之后属汇聚链,branchKey 已消耗,返回 null)。
|
||||
*/
|
||||
private String branchKeyOf(FlowDefModel model, String fromNodeId) {
|
||||
Set<String> visited = new HashSet<>();
|
||||
Deque<String> queue = new ArrayDeque<>();
|
||||
queue.add(fromNodeId);
|
||||
visited.add(fromNodeId);
|
||||
while (!queue.isEmpty()) {
|
||||
String cur = queue.poll();
|
||||
for (FlowDefModel.Edge e : model.inEdges(cur)) {
|
||||
FlowDefModel.Node from = model.node(e.from);
|
||||
if (from == null) {
|
||||
continue;
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) {
|
||||
return e.to;
|
||||
}
|
||||
if (FlowDefModel.TYPE_START.equals(from.type)
|
||||
|| FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type)
|
||||
|| !visited.add(e.from)) {
|
||||
continue;
|
||||
}
|
||||
queue.add(e.from);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 统一激活入口:含 JOIN 的到达标记与放行判断 */
|
||||
private void advance(FlwInstance instance, FlowDefModel model, String nodeId, String branchKey) {
|
||||
FlowDefModel.Node node = model.nodeById(nodeId);
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(node.type)) {
|
||||
if (branchKey != null) {
|
||||
FlwBranch branch = branchRepository.findByInstanceIdAndBranchKey(instance.getId(), branchKey)
|
||||
.orElse(null);
|
||||
if (branch != null) {
|
||||
branch.setStatus(FlwEnums.BranchStatus.ARRIVED.getCode());
|
||||
branchRepository.save(branch);
|
||||
}
|
||||
}
|
||||
long running = branchRepository.countByInstanceIdAndStatus(
|
||||
instance.getId(), FlwEnums.BranchStatus.RUNNING.getCode());
|
||||
if (running > 0) {
|
||||
log.debug("[flow-center] JOIN {} 等待分支汇聚:instance={} remaining={}",
|
||||
nodeId, instance.getId(), running);
|
||||
return;
|
||||
}
|
||||
List<FlowDefModel.Edge> outs = model.outEdges(nodeId);
|
||||
if (outs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
activate(instance, model, model.nodeById(outs.get(0).to), null);
|
||||
return;
|
||||
}
|
||||
activate(instance, model, node, branchKey);
|
||||
}
|
||||
|
||||
/** 非 JOIN 节点激活 */
|
||||
private void activate(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node, String branchKey) {
|
||||
switch (node.type) {
|
||||
case FlowDefModel.TYPE_START: {
|
||||
List<FlowDefModel.Edge> outs = model.outEdges(node.id);
|
||||
if (!outs.isEmpty()) {
|
||||
advance(instance, model, outs.get(0).to, branchKey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FlowDefModel.TYPE_APPROVAL:
|
||||
createTasks(instance, model, node);
|
||||
addCurrentNode(instance, node.id);
|
||||
break;
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT: {
|
||||
for (FlowDefModel.Edge edge : model.outEdges(node.id)) {
|
||||
FlwBranch branch = new FlwBranch();
|
||||
branch.setInstanceId(instance.getId());
|
||||
branch.setBranchKey(edge.to);
|
||||
branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode());
|
||||
branchRepository.save(branch);
|
||||
advance(instance, model, edge.to, edge.to);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FlowDefModel.TYPE_END:
|
||||
complete(instance);
|
||||
break;
|
||||
default:
|
||||
log.warn("[flow-center] 未知节点类型 {}(节点 {}),推进中止", node.type, node.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件 */
|
||||
private void createTasks(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node) {
|
||||
List<AssigneeRef> assignees = assigneeResolver.resolve(instance.getSchId(), node);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime deadline = node.deadlineHours == null ? null : now.plusHours(node.deadlineHours);
|
||||
for (AssigneeRef ref : assignees) {
|
||||
FlwTask task = new FlwTask();
|
||||
task.setInstanceId(instance.getId());
|
||||
task.setSchId(instance.getSchId());
|
||||
task.setNodeId(node.id);
|
||||
task.setNodeName(node.name);
|
||||
task.setAssigneeId(ref.getUserId());
|
||||
task.setAssigneeName(ref.getUserName());
|
||||
task.setStatus(FlwEnums.TaskStatus.PENDING.getCode());
|
||||
task.setArriveTime(now);
|
||||
task.setDeadline(deadline);
|
||||
task.setUrgeCount(0);
|
||||
taskRepository.save(task);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("nodeId", node.id);
|
||||
data.put("nodeName", node.name);
|
||||
data.put("nodeSummary", nodeSummary(model));
|
||||
data.put("totalTasks", assignees.size());
|
||||
data.put("doneTasks", 0);
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.TASK_CREATED,
|
||||
instance.getId(), task.getId(), data));
|
||||
}
|
||||
}
|
||||
|
||||
/** END 激活:办件完成、残留任务/分支失效、完成事件 */
|
||||
private void complete(FlwInstance instance) {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.COMPLETED.getCode());
|
||||
instance.setFinishTime(LocalDateTime.now());
|
||||
setCurrentNodes(instance, null);
|
||||
taskRepository.invalidatePendingByInstance(instance.getId());
|
||||
branchRepository.invalidateRunningByInstance(instance.getId());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("result", "APPROVED");
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.COMPLETED, instance.getId(), null, data));
|
||||
}
|
||||
|
||||
public FlowDefModel parseModel(FlwDef def) {
|
||||
return FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
}
|
||||
|
||||
/** 节点链摘要(APPROVAL 节点名串联,oapi list/detail 与回调 payload 共用) */
|
||||
public static String nodeSummary(FlowDefModel model) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (FlowDefModel.TYPE_APPROVAL.equals(n.type)) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append("→");
|
||||
}
|
||||
sb.append(n.name == null || n.name.isEmpty() ? n.id : n.name);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void addCurrentNode(FlwInstance instance, String nodeId) {
|
||||
String cur = instance.getCurrentNodes();
|
||||
instance.setCurrentNodes(cur == null || cur.isEmpty() ? nodeId : cur + "," + nodeId);
|
||||
}
|
||||
|
||||
private void removeCurrentNode(FlwInstance instance, String nodeId) {
|
||||
String cur = instance.getCurrentNodes();
|
||||
if (cur == null || cur.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String part : cur.split(",")) {
|
||||
if (!part.equals(nodeId) && !part.isEmpty()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(",");
|
||||
}
|
||||
sb.append(part);
|
||||
}
|
||||
}
|
||||
instance.setCurrentNodes(sb.toString());
|
||||
}
|
||||
|
||||
private void setCurrentNodes(FlwInstance instance, String value) {
|
||||
instance.setCurrentNodes(value == null ? "" : value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 引擎关键状态变迁事件(概览 §5.2 v1.1 增补):事务内发布,AFTER_COMMIT 后由
|
||||
* CallbackDispatcher(回调给业务方)与 UserMsgNotifier(通知人)消费——引擎本身不知道
|
||||
* 也不依赖任何业务方的存在。携带 instanceId 而非实体快照:监听方在提交后按需重读(含最新 version)。
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class FlowEvent {
|
||||
|
||||
public enum Type {
|
||||
/** 代发起/重提成功(instance=10) */
|
||||
SUBMITTED("flow.submitted"),
|
||||
/** 任一审批任务创建(并行会签每个任务各发一条,data 带 n/m 汇总) */
|
||||
TASK_CREATED("flow.task.created"),
|
||||
/** 任一任务被办理(同意/拒绝各发一条,进度同步用) */
|
||||
TASK_HANDLED("flow.task.handled"),
|
||||
/** END 激活,instance=20 */
|
||||
COMPLETED("flow.completed"),
|
||||
/** END_REJECTED,instance=30 */
|
||||
REJECTED("flow.rejected"),
|
||||
/** 提交人撤回,instance=40 */
|
||||
WITHDRAWN("flow.withdrawn"),
|
||||
/** 催办(仅 UserMsg 通道,非回调事件枚举) */
|
||||
URGE("flow.urge");
|
||||
|
||||
private final String code;
|
||||
|
||||
Type(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static Type ofCode(String code) {
|
||||
for (Type t : values()) {
|
||||
if (t.code.equals(code)) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private final Type type;
|
||||
private final Long instanceId;
|
||||
/** 任务事件带 taskId(SUBMITTED 等办件级事件为 null) */
|
||||
private final Long taskId;
|
||||
/** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */
|
||||
private final Map<String, Object> data;
|
||||
|
||||
public static FlowEvent of(Type type, Long instanceId) {
|
||||
return new FlowEvent(type, instanceId, null, new HashMap<>());
|
||||
}
|
||||
|
||||
public static FlowEvent of(Type type, Long instanceId, Long taskId, Map<String, Object> data) {
|
||||
return new FlowEvent(type, instanceId, taskId, data == null ? new HashMap<>() : data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/**
|
||||
* 节点放行推进监听器:TASK_HANDLED 事件 AFTER_COMMIT 后在新事务里重查——
|
||||
* 该节点待办是否清零、办件是否仍在途,全部成立才继续推进(下一节点/JOIN 合并/END)。
|
||||
* 会签并发办理时各方提交的快照互相看不到对方未提交的办理行,事务内判断
|
||||
* "节点是否全部办结"必假,提交后重查才是唯一可靠时机;推进串行化由办件乐观锁兜底
|
||||
* (重试循环在监听器侧,跨 bean 调用 {@link FlowAdvanceService#tryAdvance} 保证 REQUIRES_NEW 生效)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FlowNodeAdvanceListener {
|
||||
|
||||
private final FlowAdvanceService advanceService;
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onTaskHandled(FlowEvent event) {
|
||||
if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) {
|
||||
return;
|
||||
}
|
||||
String nodeId = (String) event.getData().get("nodeId");
|
||||
if (nodeId == null) {
|
||||
return;
|
||||
}
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
advanceService.tryAdvance(event.getInstanceId(), nodeId);
|
||||
return;
|
||||
} catch (ObjectOptimisticLockingFailureException e) {
|
||||
log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}",
|
||||
attempt, event.getInstanceId(), nodeId);
|
||||
}
|
||||
}
|
||||
log.error("[flow-center] 推进重试耗尽(请人工检查):instance={} node={}",
|
||||
event.getInstanceId(), nodeId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import com.yunhai.flowcenter.common.FlowCenterAuditListener;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.EntityListeners;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.MappedSuperclass;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 所有实体的公共基类:自增主键 + 审计四件套(FlowCenterAuditListener 自动填充,CmsAuditListener 同款)。
|
||||
* 主键用 Long:flw_instance.id 即对外办件标识(svc-hall 侧 flow_instance_id 为 BIGINT,对齐)。
|
||||
* 多租户键 sch_id 不在基类——各业务实体自带列(Notice 先例,语义不同不混入)。
|
||||
*/
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(FlowCenterAuditListener.class)
|
||||
public abstract class BaseEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "create_by", updatable = false)
|
||||
private String createBy;
|
||||
|
||||
@Column(name = "create_time", updatable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_by")
|
||||
private String updateBy;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 办理人映射(概览 §4.7,一期显式维护):dept_key 即流程 JSON 里 DEPT_KEY 引用的科室编码;
|
||||
* user_name 为快照供任务列表免跨服务查名。外部托管模式复用同一张映射表——办理人解析与发起入口无关。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_assignee_map",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_map_sch_dept_user",
|
||||
columnNames = {"sch_id", "dept_key", "user_id"}),
|
||||
indexes = @Index(name = "idx_flw_map_sch_dept", columnList = "sch_id, dept_key, status"))
|
||||
public class FlwAssigneeMap extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 科室编码(流程 JSON DEPT_KEY 引用,如 ybb=医保办) */
|
||||
@Column(name = "dept_key", nullable = false, length = 64)
|
||||
private String deptKey;
|
||||
|
||||
/** 科室名快照 */
|
||||
@Column(name = "dept_name", length = 128)
|
||||
private String deptName;
|
||||
|
||||
/** 办理人 id */
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Integer userId;
|
||||
|
||||
/** 办理人姓名快照(管理端成员检索时从 UserBase 补全) */
|
||||
@Column(name = "user_name", length = 128)
|
||||
private String userName;
|
||||
|
||||
/** 0 启用 */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 0;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
|
||||
/**
|
||||
* 申请材料(概览 §4.6):外部托管模式材料留在业务方(biz_snapshot 引用文件名清单),
|
||||
* 本表仅服务显式上传场景(M1 不开发上传端点,表结构先行)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_attachment",
|
||||
indexes = @Index(name = "idx_flw_att_inst", columnList = "instance_id, sch_id"))
|
||||
public class FlwAttachment extends BaseEntity {
|
||||
|
||||
/** 归属办件 */
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private Long instanceId;
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 表单字段键(无表单模式为 null) */
|
||||
@Column(name = "field_key", length = 64)
|
||||
private String fieldKey;
|
||||
|
||||
@Column(name = "file_name", length = 255)
|
||||
private String fileName;
|
||||
|
||||
/** 磁盘相对路径({FILE_PATH} 下) */
|
||||
@Column(name = "file_path", length = 500)
|
||||
private String filePath;
|
||||
|
||||
/** 对外可访问 URL */
|
||||
@Column(name = "web_url", length = 500)
|
||||
private String webUrl;
|
||||
|
||||
@Column(name = "file_size")
|
||||
private Long fileSize;
|
||||
|
||||
@Column(name = "mime_type", length = 128)
|
||||
private String mimeType;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 并行分支进度(概览 §4.5):PARALLEL_SPLIT 为每条出边建一行,到达 PARALLEL_JOIN 时
|
||||
* 标记已到达,全部到达才放行(waitFor=ALL);branch_key=出边目标节点 id。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_branch",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_branch_inst_key",
|
||||
columnNames = {"instance_id", "branch_key"}))
|
||||
public class FlwBranch extends BaseEntity {
|
||||
|
||||
/** 归属办件 */
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private Long instanceId;
|
||||
|
||||
/** 分支键(split 出边目标节点 id) */
|
||||
@Column(name = "branch_key", nullable = false, length = 64)
|
||||
private String branchKey;
|
||||
|
||||
/** 0 进行中 / 1 已到达汇聚点 / 2 已失效 */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = FlwEnums.BranchStatus.RUNNING.getCode();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 回调推送日志 = 重试队列(概览 §4.9):eventId 唯一(重试不变,业务方按它幂等去重);
|
||||
* payload_json 完整快照可重放;索引 (status, next_retry_time) 是扫描主路径。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_callback_log",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event", columnNames = "event_id"),
|
||||
indexes = {
|
||||
@Index(name = "idx_flw_log_scan", columnList = "status, next_retry_time"),
|
||||
@Index(name = "idx_flw_log_inst", columnList = "instance_id")})
|
||||
public class FlwCallbackLog extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 归属注册 */
|
||||
@Column(name = "reg_id", nullable = false)
|
||||
private Long regId;
|
||||
|
||||
/** 事件唯一键(UUID 去横线 32 位),重试不变 */
|
||||
@Column(name = "event_id", nullable = false, length = 32)
|
||||
private String eventId;
|
||||
|
||||
/** 事件类型(flow.submitted/task.created/task.handled/completed/rejected/withdrawn) */
|
||||
@Column(name = "event", nullable = false, length = 32)
|
||||
private String event;
|
||||
|
||||
/** 办件 */
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private Long instanceId;
|
||||
|
||||
/** 业务键冗余(业务方消费主键) */
|
||||
@Column(name = "biz_type", length = 32)
|
||||
private String bizType;
|
||||
|
||||
@Column(name = "biz_id", length = 64)
|
||||
private String bizId;
|
||||
|
||||
/** 完整推送体快照(含签名原文,可重放) */
|
||||
@Column(name = "payload_json", nullable = false, columnDefinition = "LONGTEXT")
|
||||
private String payloadJson;
|
||||
|
||||
/** 0 待推/重试中 / 1 成功 / 2 死信 */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = FlwEnums.CallbackLogStatus.PENDING.getCode();
|
||||
|
||||
/** 已重试次数 */
|
||||
@Column(name = "retry_count", nullable = false)
|
||||
private Integer retryCount = 0;
|
||||
|
||||
/** 下次推送时间 */
|
||||
@Column(name = "next_retry_time")
|
||||
private LocalDateTime nextRetryTime;
|
||||
|
||||
/** 末次 HTTP 状态(管理端排障用) */
|
||||
@Column(name = "last_http_status", length = 32)
|
||||
private String lastHttpStatus;
|
||||
|
||||
/** 末次错误 */
|
||||
@Column(name = "last_error", length = 1000)
|
||||
private String lastError;
|
||||
|
||||
/** 成功时间 */
|
||||
@Column(name = "push_time")
|
||||
private LocalDateTime pushTime;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 回调注册(概览 §4.8):同一应用同一流程一条注册;secret 注册时生成本地下发,
|
||||
* 与应用级 appSecret 分离、粒度到流程(泄露可单独重置)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_callback_reg",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_reg_sch_app_flow",
|
||||
columnNames = {"sch_id", "app_key", "flow_guid"}),
|
||||
indexes = @Index(name = "idx_flw_reg_flow", columnList = "sch_id, flow_guid, status"))
|
||||
public class FlwCallbackReg extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 业务方应用标识(调用方 appKey,来自验签上下文) */
|
||||
@Column(name = "app_key", nullable = false, length = 64)
|
||||
private String appKey;
|
||||
|
||||
/** 订阅的流程 */
|
||||
@Column(name = "flow_guid", nullable = false, length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 业务方接收端点 */
|
||||
@Column(name = "callback_url", nullable = false, length = 500)
|
||||
private String callbackUrl;
|
||||
|
||||
/** 订阅事件逗号分隔(概览 §8.3 枚举),空=全部 */
|
||||
@Column(name = "event_types", length = 200)
|
||||
private String eventTypes;
|
||||
|
||||
/** 回调签名密钥(HmacSHA256,注册时生成下发) */
|
||||
@Column(name = "secret", nullable = false, length = 64)
|
||||
private String secret;
|
||||
|
||||
/** 0 启用 / 1 停用 */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 0;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 流程定义(概览 §4.1 + V2.0 Part D #2/#3 修订)。
|
||||
* flow_guid 绑定"这条流程线"而非某个版本:升版后业务方绑定关系零改动,新发起自动走
|
||||
* 当前已发布最高版本;item_code/form_id 留可空列(M1 不启用,原生模式/表单二期)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_def",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_sch_code_ver",
|
||||
columnNames = {"sch_id", "code", "version"}),
|
||||
indexes = @Index(name = "idx_flw_def_guid", columnList = "sch_id, flow_guid, status, version"))
|
||||
public class FlwDef extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 对外稳定流程标识:创建草稿时生成(UUID 去横线小写),同一 code 跨版本不变 */
|
||||
@Column(name = "flow_guid", nullable = false, length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 流程编码(本应用内部管理键) */
|
||||
@Column(name = "code", nullable = false, length = 64)
|
||||
private String code;
|
||||
|
||||
/** 流程名 */
|
||||
@Column(name = "name", nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
/** 版本,发布后不可改,改定义=升版本 */
|
||||
@Column(name = "version", nullable = false)
|
||||
private Integer version;
|
||||
|
||||
/** 关联 CMS 事项业务键(原生模式用,M1 不启用;与外部模式的 biz_* 互斥) */
|
||||
@Column(name = "item_code", length = 64)
|
||||
private String itemCode;
|
||||
|
||||
/** 关联表单定义(外部托管模式可无表单,M1 恒 null;二期通用表单预留) */
|
||||
@Column(name = "form_id")
|
||||
private Long formId;
|
||||
|
||||
/** §5.1 流程定义 JSON */
|
||||
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
|
||||
private String contentJson;
|
||||
|
||||
/** 0 草稿 / 1 已发布 / 2 停用(停用=不可新发起,存量跑完) */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = FlwEnums.DefStatus.DRAFT.getCode();
|
||||
|
||||
/** 发布时间 */
|
||||
@Column(name = "publish_time")
|
||||
private LocalDateTime publishTime;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import javax.persistence.Version;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存
|
||||
* biz_title/biz_url/biz_snapshot 快照与关联键;uk(sch_id, biz_type, biz_id) 幂等——
|
||||
* 同一业务对象一条办件,重复代发起返回原办件;驳回/撤回后重提复用同一行(R4 重启语义)。
|
||||
*
|
||||
* <p>version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件),
|
||||
* 由 JPA @Version 在 flush 时自增。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_instance",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_instance_biz",
|
||||
columnNames = {"sch_id", "biz_type", "biz_id"}),
|
||||
indexes = {
|
||||
@Index(name = "idx_flw_inst_submitter", columnList = "sch_id, submitter_id, status"),
|
||||
@Index(name = "idx_flw_inst_status_time", columnList = "sch_id, status, submit_time")})
|
||||
public class FlwInstance extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 定义快照(发布版本 id;重提重启时刷新为当前已发布版本) */
|
||||
@Column(name = "def_id", nullable = false)
|
||||
private Long defId;
|
||||
|
||||
/** 定义编码快照 */
|
||||
@Column(name = "def_code", nullable = false, length = 64)
|
||||
private String defCode;
|
||||
|
||||
/** 定义版本快照 */
|
||||
@Column(name = "def_version", nullable = false)
|
||||
private Integer defVersion;
|
||||
|
||||
/** 表单提交快照(无表单托管模式为 NULL,透传存业务方 formData) */
|
||||
@Column(name = "form_data_json", columnDefinition = "LONGTEXT")
|
||||
private String formDataJson;
|
||||
|
||||
/** CMS 事项快照(原生模式,M1 不启用恒 null) */
|
||||
@Column(name = "item_code", length = 64)
|
||||
private String itemCode;
|
||||
|
||||
@Column(name = "item_title", length = 200)
|
||||
private String itemTitle;
|
||||
|
||||
/** 业务类型键(外部托管模式,如 svc_item) */
|
||||
@Column(name = "biz_type", length = 32)
|
||||
private String bizType;
|
||||
|
||||
/** 业务主键(如 svc-hall applyId) */
|
||||
@Column(name = "biz_id", length = 64)
|
||||
private String bizId;
|
||||
|
||||
/** 办件标题快照:待办列表/消息/回调 payload 统一携带 */
|
||||
@Column(name = "biz_title", length = 200)
|
||||
private String bizTitle;
|
||||
|
||||
/** 业务方详情页链接:办理页"查看业务详情"跳转入口 */
|
||||
@Column(name = "biz_url", length = 500)
|
||||
private String bizUrl;
|
||||
|
||||
/** 业务摘要 JSON 快照:办理页展示用(只收摘要,不收富文本正文,概览 §十边界) */
|
||||
@Column(name = "biz_snapshot", columnDefinition = "LONGTEXT")
|
||||
private String bizSnapshot;
|
||||
|
||||
/** 提交人 id(外部模式=业务方当前登录人快照,本校校验在入口做) */
|
||||
@Column(name = "submitter_id", nullable = false)
|
||||
private Integer submitterId;
|
||||
|
||||
/** 提交人姓名快照 */
|
||||
@Column(name = "submitter_name", nullable = false, length = 128)
|
||||
private String submitterName;
|
||||
|
||||
/** 10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = FlwEnums.InstanceStatus.APPROVING.getCode();
|
||||
|
||||
/** 当前活跃节点 id 集合(冗余逗号分隔,列表页免展开) */
|
||||
@Column(name = "current_nodes", length = 200)
|
||||
private String currentNodes;
|
||||
|
||||
@Column(name = "submit_time")
|
||||
private LocalDateTime submitTime;
|
||||
|
||||
@Column(name = "finish_time")
|
||||
private LocalDateTime finishTime;
|
||||
|
||||
/**
|
||||
* 乐观锁版本:推进串行化 + 回调 instanceVersion 单调递增。
|
||||
* 注意不可初始化(=0 会让 Spring Data 判定非新实体走 merge,id 不回填);
|
||||
* insert 时由 Hibernate 写入 0。
|
||||
*/
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Integer version;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 审批任务(概览 §4.4):会签 ALL 模式下每办理人一条,全部同意才放行;
|
||||
* 办理抢占靠 UPDATE ... WHERE status=0 的原子语义(服务层用乐观锁 version 兜底并发推进)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_task",
|
||||
indexes = {
|
||||
@Index(name = "idx_flw_task_inst_status", columnList = "instance_id, status"),
|
||||
@Index(name = "idx_flw_task_assignee", columnList = "assignee_id, status, arrive_time")})
|
||||
public class FlwTask extends BaseEntity {
|
||||
|
||||
/** 归属办件 */
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private Long instanceId;
|
||||
|
||||
/** 租户键(冗余,待办列表主路径免 join) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 定义节点 id(JSON 里的 node.id) */
|
||||
@Column(name = "node_id", nullable = false, length = 64)
|
||||
private String nodeId;
|
||||
|
||||
/** 节点名快照 */
|
||||
@Column(name = "node_name", length = 128)
|
||||
private String nodeName;
|
||||
|
||||
/** 办理人 id(DEPT_KEY 映射/USERS 直配解析结果) */
|
||||
@Column(name = "assignee_id", nullable = false)
|
||||
private Integer assigneeId;
|
||||
|
||||
/** 办理人姓名快照(免跨服务查名) */
|
||||
@Column(name = "assignee_name", length = 128)
|
||||
private String assigneeName;
|
||||
|
||||
/** 0 待办 / 1 同意 / 2 拒绝 / 3 失效 */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = FlwEnums.TaskStatus.PENDING.getCode();
|
||||
|
||||
/** 办理意见(reject 必填,纯文本不收 HTML) */
|
||||
@Column(name = "opinion", length = 500)
|
||||
private String opinion;
|
||||
|
||||
@Column(name = "arrive_time", nullable = false)
|
||||
private LocalDateTime arriveTime;
|
||||
|
||||
@Column(name = "handle_time")
|
||||
private LocalDateTime handleTime;
|
||||
|
||||
/** 办理时限(一期自然时间,定义方自行折算工作日——拍板项#4) */
|
||||
@Column(name = "deadline")
|
||||
private LocalDateTime deadline;
|
||||
|
||||
/** 已催办次数 */
|
||||
@Column(name = "urge_count", nullable = false)
|
||||
private Integer urgeCount = 0;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.yunhai.flowcenter.enums;
|
||||
|
||||
/**
|
||||
* 流程中心状态枚举(概览 §4.3/§4.4/§4.5/§4.1/§4.9 口径,一期冻结)。
|
||||
*/
|
||||
public final class FlwEnums {
|
||||
|
||||
private FlwEnums() {
|
||||
}
|
||||
|
||||
/** 办件状态:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 */
|
||||
public enum InstanceStatus {
|
||||
APPROVING(10), COMPLETED(20), REJECTED(30), WITHDRAWN(40);
|
||||
|
||||
private final int code;
|
||||
|
||||
InstanceStatus(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/** 任务状态:0 待办 / 1 同意 / 2 拒绝 / 3 失效(拒绝/撤回/重启重提时的清理目标) */
|
||||
public enum TaskStatus {
|
||||
PENDING(0), APPROVED(1), REJECTED(2), INVALIDATED(3);
|
||||
|
||||
private final int code;
|
||||
|
||||
TaskStatus(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/** 并行分支状态:0 进行中 / 1 已到达汇聚点 / 2 已失效 */
|
||||
public enum BranchStatus {
|
||||
RUNNING(0), ARRIVED(1), INVALIDATED(2);
|
||||
|
||||
private final int code;
|
||||
|
||||
BranchStatus(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/** 流程定义状态:0 草稿 / 1 已发布 / 2 停用(停用=不可新发起,存量跑完) */
|
||||
public enum DefStatus {
|
||||
DRAFT(0), PUBLISHED(1), DISABLED(2);
|
||||
|
||||
private final int code;
|
||||
|
||||
DefStatus(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/** 回调推送日志状态:0 待推/重试中 / 1 成功 / 2 死信 */
|
||||
public enum CallbackLogStatus {
|
||||
PENDING(0), SUCCESS(1), DEAD(2);
|
||||
|
||||
private final int code;
|
||||
|
||||
CallbackLogStatus(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.yunhai.flowcenter.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户面/管理面 VO(/api/flow/**)。
|
||||
*/
|
||||
public final class FlowModels {
|
||||
|
||||
private FlowModels() {
|
||||
}
|
||||
|
||||
/** 我的办件列表行 */
|
||||
@Data
|
||||
public static class InstanceItem {
|
||||
private Long instanceId;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private Integer status;
|
||||
private String currentNodes;
|
||||
private String submitterName;
|
||||
private String submitTime;
|
||||
private String finishTime;
|
||||
private String defName;
|
||||
private Integer defVersion;
|
||||
}
|
||||
|
||||
/** 待办/已办列表行 */
|
||||
@Data
|
||||
public static class TaskItem {
|
||||
private Long taskId;
|
||||
private Long instanceId;
|
||||
private String bizTitle;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private Integer instanceStatus;
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
private String assigneeName;
|
||||
private Integer status;
|
||||
private String opinion;
|
||||
private String arriveTime;
|
||||
private String handleTime;
|
||||
private String deadline;
|
||||
private Integer urgeCount;
|
||||
}
|
||||
|
||||
/** 办件详情(办理页/详情页数据源) */
|
||||
@Data
|
||||
public static class InstanceDetail {
|
||||
private Long instanceId;
|
||||
private Long schId;
|
||||
private String defCode;
|
||||
private Integer defVersion;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private String bizUrl;
|
||||
/** 业务摘要 JSON(办理页区块渲染;无富文本正文——概览 §十边界) */
|
||||
private String bizSnapshot;
|
||||
private String formDataJson;
|
||||
private Integer status;
|
||||
private String currentNodes;
|
||||
private Integer submitterId;
|
||||
private String submitterName;
|
||||
private String submitTime;
|
||||
private String finishTime;
|
||||
private Integer version;
|
||||
private String nodeSummary;
|
||||
private List<Map<String, Object>> trace;
|
||||
private List<Map<String, Object>> branches;
|
||||
/** 当前用户可否办理(存在本人的待办任务) */
|
||||
private boolean myPendingTask;
|
||||
private List<Long> myPendingTaskIds;
|
||||
}
|
||||
|
||||
/** 流程示意图出参(用户面 /diagram 与管理面 /admin/def/diagram 共用,方案 §3.1) */
|
||||
@Data
|
||||
public static class DiagramVo {
|
||||
private String flowGuid;
|
||||
private Long defId;
|
||||
private Long instanceId;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private Integer status;
|
||||
/** 实际出图口径:dept|user|auto(后端归一化后回显) */
|
||||
private String mode;
|
||||
private List<NodeVo> nodes;
|
||||
private List<EdgeVo> edges;
|
||||
}
|
||||
|
||||
/** 示意图节点(label/subLabel 已按视图解析好,前端只画不解析) */
|
||||
@Data
|
||||
public static class NodeVo {
|
||||
private String id;
|
||||
private String type;
|
||||
private String label;
|
||||
private String subLabel;
|
||||
private String deptKey;
|
||||
private String assigneeType;
|
||||
private String countersign;
|
||||
private Integer deadlineHours;
|
||||
}
|
||||
|
||||
/** 示意图连边 */
|
||||
@Data
|
||||
public static class EdgeVo {
|
||||
private String from;
|
||||
private String to;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.yunhai.flowcenter.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* /oapi/flow/** 契约模型(出入参字段与 svc-hall FlowCenterClient 逐字对齐,
|
||||
* 契约锚点:《流程中心M1开工说明.md》§5——两侧已编码,改动须双向同步)。
|
||||
*/
|
||||
public final class OapiModels {
|
||||
|
||||
private OapiModels() {
|
||||
}
|
||||
|
||||
/** 流程摘要(list 元素 / {flowGuid} 详情) */
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class FlowSummaryVo {
|
||||
private String flowGuid;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private String nodeSummary;
|
||||
}
|
||||
|
||||
/** 代发起请求体(POST /oapi/flow/apply;formData 为 JSON 对象透传) */
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class ApplyBody {
|
||||
private String flowGuid;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private String bizUrl;
|
||||
/** 业务摘要 JSON 快照(字符串或对象均可,统一落字符串) */
|
||||
private Object bizSnapshot;
|
||||
private Integer submitterId;
|
||||
private String submitterName;
|
||||
/** 表单数据对象(业务方编辑页产出,无表单模式不传) */
|
||||
private Map<String, Object> formData;
|
||||
}
|
||||
|
||||
/** 代发起响应(biz uk 幂等:重复代发起 HTTP 200 + duplicated:true 返回既有办件) */
|
||||
@Data
|
||||
public static class ApplyResultVo {
|
||||
private Long instanceId;
|
||||
private boolean duplicated;
|
||||
}
|
||||
|
||||
/** 按业务键查办件响应(对账兜底;traceJson 含 assigneeId 轨迹供办理人放行判定) */
|
||||
@Data
|
||||
public static class InstanceVo {
|
||||
private Long instanceId;
|
||||
private Integer status;
|
||||
private Integer version;
|
||||
private String nodeSummary;
|
||||
private String traceJson;
|
||||
private List<Integer> assigneeIds;
|
||||
private String currentNodes;
|
||||
private String bizTitle;
|
||||
private Integer submitterId;
|
||||
private String submitterName;
|
||||
private String submitTime;
|
||||
private String finishTime;
|
||||
}
|
||||
|
||||
/** 回调注册请求体(POST /oapi/flow/callback/reg) */
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class CallbackRegBody {
|
||||
private String flowGuid;
|
||||
private String callbackUrl;
|
||||
/** 订阅事件逗号分隔,空=全部 */
|
||||
private String eventTypes;
|
||||
}
|
||||
|
||||
/** 回调注册响应(secret 注册时生成下发) */
|
||||
@Data
|
||||
public static class CallbackRegResultVo {
|
||||
private String secret;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.yunhai.flowcenter.notify;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.http.IDHttp;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* UserMsg 站内信客户端(概览 §七 契约,M1 手工三件套路线的<b>直达版</b>):
|
||||
* 经共享 IDHttp(yh-openapi 装配的出站自动签名 RestTemplate,X-App-Key/X-Timestamp/X-Signature
|
||||
* = SignUtil.signRequest 请求级 canonical,与 svc-hall FlowCenterClient 同一套通道)直调
|
||||
* UserMsg /oapi/msg/**。前提:UserMsg 侧 trusted-apps 白名单收本应用 appKey(本地占位 100019);
|
||||
* SDK(yh-usermsg-spring-boot-starter)落地后整体切换为 SDK 调用,契约不变。
|
||||
* 发送方不阻断主业务:失败只记日志(对齐 PartyLearn 容错语义),由调用方在 AFTER_COMMIT 监听器里容错。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserMsgClient {
|
||||
|
||||
private final IDHttp http;
|
||||
|
||||
@Value("${flow-center.usermsg.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${flow-center.usermsg.base-url:http://localhost:8080}")
|
||||
private String baseUrl;
|
||||
|
||||
public boolean enabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** 发送站内信(content 必填;msgNo 幂等,重发安全) */
|
||||
public void sendUserMsg(Integer schId, List<Integer> userIds, Integer msgType, String bizType,
|
||||
String bizId, String title, String content, String jumpPath, String msgNo,
|
||||
Integer expireDays) {
|
||||
if (!enabled || userIds == null || userIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
SendMsgRequest body = new SendMsgRequest();
|
||||
body.setUserIds(userIds);
|
||||
body.setMsgType(msgType);
|
||||
body.setBizType(bizType);
|
||||
body.setBizId(bizId);
|
||||
body.setTitle(title);
|
||||
body.setContent(content);
|
||||
if (jumpPath != null) {
|
||||
body.setJumpType(1);
|
||||
body.setJumpPath(jumpPath);
|
||||
}
|
||||
body.setMsgNo(msgNo);
|
||||
body.setSchId(schId);
|
||||
body.setExpireDays(expireDays);
|
||||
try {
|
||||
http.post(trimTrailing(baseUrl) + "/oapi/msg/sendUserMsg", body, Object.class);
|
||||
} catch (BizException e) {
|
||||
log.warn("[flow-center] UserMsg 发送被拒:msgNo={} msg={}", msgNo, e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] UserMsg 发送失败(不阻断主业务):msgNo={} {}", msgNo, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/** 待办角标增量(+1 到达 / -1 办结) */
|
||||
public void todoInc(Integer schId, List<Integer> userIds, int count) {
|
||||
if (!enabled || userIds == null || userIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
TodoCountRequest body = new TodoCountRequest();
|
||||
body.setUserIds(userIds);
|
||||
body.setCount(count);
|
||||
body.setSchId(schId);
|
||||
try {
|
||||
http.post(trimTrailing(baseUrl) + "/oapi/msg/todo/inc", body, Object.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] UserMsg 角标增量失败(不阻断主业务):userIds={} count={} {}",
|
||||
userIds, count, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimTrailing(String url) {
|
||||
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
|
||||
}
|
||||
|
||||
/** /oapi/msg/sendUserMsg 请求体(UserMsg SendMsgRequest 同构,字段逐字对齐) */
|
||||
@Data
|
||||
public static class SendMsgRequest {
|
||||
private List<Integer> userIds;
|
||||
private String title;
|
||||
/** 必填(UserMsg @NotBlank) */
|
||||
private String content;
|
||||
private String msgNo;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
/** 0系统通知/1业务提醒/2审批待办 */
|
||||
private Integer msgType;
|
||||
/** 0无跳转/1应用内路径/2外链 */
|
||||
private Integer jumpType;
|
||||
private String jumpPath;
|
||||
private Integer schId;
|
||||
private Integer expireDays;
|
||||
}
|
||||
|
||||
/** /oapi/msg/todo/inc 请求体(UserMsg TodoCountRequest 同构) */
|
||||
@Data
|
||||
public static class TodoCountRequest {
|
||||
private List<Integer> userIds;
|
||||
private Integer count;
|
||||
private Integer schId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.yunhai.flowcenter.notify;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* 站内信/待办角标通知(概览 §七 契约,回调通知"业务系统"、本类通知"人",两通道互不替代):
|
||||
* - 任务到达:msgType=2、bizType=flow.task、msgNo=task:{taskId}、jumpPath=/task/{taskId}、todo +1
|
||||
* - 任务被办理:todo -1(办理人)
|
||||
* - 完成/拒绝/撤回:msgType=1、bizType=flow.result、content 必填(结果+意见)、msgNo 带 version
|
||||
* (重提重启多轮终态互不幂等吞掉)
|
||||
* - 催办:bizType=flow.urge、msgNo=urge:{taskId}:{第N次}
|
||||
* 全部在 AFTER_COMMIT 监听器里发送,失败不阻断(UserMsgClient 内部容错)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class UserMsgNotifier {
|
||||
|
||||
private final UserMsgClient userMsgClient;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onFlowEvent(FlowEvent event) {
|
||||
switch (event.getType()) {
|
||||
case TASK_CREATED:
|
||||
notifyTaskCreated(event);
|
||||
break;
|
||||
case TASK_HANDLED:
|
||||
notifyTaskHandled(event);
|
||||
break;
|
||||
case COMPLETED:
|
||||
case REJECTED:
|
||||
case WITHDRAWN:
|
||||
notifyResult(event);
|
||||
break;
|
||||
case URGE:
|
||||
notifyUrge(event);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyTaskCreated(FlowEvent event) {
|
||||
FlwTask task = taskRepository.findById(event.getTaskId()).orElse(null);
|
||||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||||
if (task == null || instance == null) {
|
||||
return;
|
||||
}
|
||||
String title = "您有新的待办:" + safe(instance.getBizTitle());
|
||||
String content = "「" + safe(instance.getBizTitle()) + "」等待您审批"
|
||||
+ (task.getNodeName() == null ? "" : "(" + task.getNodeName() + ")");
|
||||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||||
Collections.singletonList(task.getAssigneeId()), 2,
|
||||
"flow.task", String.valueOf(instance.getId()), title, content,
|
||||
"/task/" + task.getId(), "task:" + task.getId(), 30);
|
||||
userMsgClient.todoInc(instance.getSchId().intValue(),
|
||||
Collections.singletonList(task.getAssigneeId()), 1);
|
||||
}
|
||||
|
||||
private void notifyTaskHandled(FlowEvent event) {
|
||||
FlwTask task = taskRepository.findById(event.getTaskId()).orElse(null);
|
||||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||||
if (task == null || instance == null) {
|
||||
return;
|
||||
}
|
||||
userMsgClient.todoInc(instance.getSchId().intValue(),
|
||||
Collections.singletonList(task.getAssigneeId()), -1);
|
||||
}
|
||||
|
||||
private void notifyResult(FlowEvent event) {
|
||||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
Object result = event.getData() == null ? null : event.getData().get("result");
|
||||
String outcome;
|
||||
String bizTitle = safe(instance.getBizTitle());
|
||||
switch (event.getType()) {
|
||||
case COMPLETED:
|
||||
outcome = "已通过审批";
|
||||
break;
|
||||
case REJECTED:
|
||||
outcome = "审批被拒绝";
|
||||
break;
|
||||
default:
|
||||
outcome = "已撤回";
|
||||
break;
|
||||
}
|
||||
StringBuilder content = new StringBuilder("您提交的「" + bizTitle + "」" + outcome);
|
||||
if (result != null) {
|
||||
content.append("(结果:").append(result).append(")");
|
||||
}
|
||||
Object opinion = event.getData() == null ? null : event.getData().get("opinion");
|
||||
if (opinion != null && !String.valueOf(opinion).isEmpty()) {
|
||||
content.append(",意见:").append(opinion);
|
||||
}
|
||||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||||
Collections.singletonList(instance.getSubmitterId()), 1,
|
||||
"flow.result", String.valueOf(instance.getId()),
|
||||
"办件状态更新:" + bizTitle, content.toString(),
|
||||
"/instance/" + instance.getId(),
|
||||
"result:" + instance.getId() + ":" + instance.getVersion(), 30);
|
||||
}
|
||||
|
||||
private void notifyUrge(FlowEvent event) {
|
||||
FlwTask task = taskRepository.findById(event.getTaskId()).orElse(null);
|
||||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||||
if (task == null || instance == null) {
|
||||
return;
|
||||
}
|
||||
int count = task.getUrgeCount() == null ? 1 : task.getUrgeCount();
|
||||
String content = "「" + safe(instance.getBizTitle()) + "」任务("
|
||||
+ (task.getNodeName() == null ? task.getNodeId() : task.getNodeName())
|
||||
+ ")被催办(第 " + count + " 次),请尽快办理";
|
||||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||||
Collections.singletonList(task.getAssigneeId()), 1,
|
||||
"flow.urge", String.valueOf(instance.getId()),
|
||||
"催办提醒:" + safe(instance.getBizTitle()), content,
|
||||
"/task/" + task.getId(), "urge:" + task.getId() + ":" + count, 7);
|
||||
}
|
||||
|
||||
private FlwInstance instanceOf(Long instanceId) {
|
||||
return instanceId == null ? null : instanceRepository.findById(instanceId).orElse(null);
|
||||
}
|
||||
|
||||
private static String safe(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface FlwAssigneeMapRepository extends JpaRepository<FlwAssigneeMap, Long> {
|
||||
|
||||
/** DEPT_KEY 解析:启用映射的办理人列表(概览 §六) */
|
||||
@Query("select m from FlwAssigneeMap m where m.schId = :schId and m.deptKey = :deptKey and m.status = 0")
|
||||
List<FlwAssigneeMap> findEnabled(@Param("schId") Long schId, @Param("deptKey") String deptKey);
|
||||
|
||||
/** 发布校验用:本校全部启用映射 */
|
||||
List<FlwAssigneeMap> findEnabledBySchId(Long schId);
|
||||
|
||||
List<FlwAssigneeMap> findBySchIdAndDeptKeyOrderByUserIdAsc(Long schId, String deptKey);
|
||||
|
||||
FlwAssigneeMap findBySchIdAndDeptKeyAndUserId(Long schId, String deptKey, Integer userId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAttachment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface FlwAttachmentRepository extends JpaRepository<FlwAttachment, Long> {
|
||||
|
||||
List<FlwAttachment> findByInstanceIdOrderByIdAsc(Long instanceId);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwBranch;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FlwBranchRepository extends JpaRepository<FlwBranch, Long> {
|
||||
|
||||
List<FlwBranch> findByInstanceId(Long instanceId);
|
||||
|
||||
Optional<FlwBranch> findByInstanceIdAndBranchKey(Long instanceId, String branchKey);
|
||||
|
||||
long countByInstanceIdAndStatus(Long instanceId, Integer status);
|
||||
|
||||
@Modifying
|
||||
@Query("update FlwBranch b set b.status = 2 where b.instanceId = :instanceId and b.status = 0")
|
||||
int invalidateRunningByInstance(@Param("instanceId") Long instanceId);
|
||||
|
||||
@Modifying
|
||||
@Query("delete from FlwBranch b where b.instanceId = :instanceId")
|
||||
int deleteByInstance(@Param("instanceId") Long instanceId);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog, Long> {
|
||||
|
||||
/** 重推扫描主路径:待推/重试中且到点 */
|
||||
List<FlwCallbackLog> findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc(
|
||||
Integer status, LocalDateTime now);
|
||||
|
||||
/** 管理端推送日志分页(状态/事件/办件可空) */
|
||||
@Query("select l from FlwCallbackLog l where l.schId = :schId "
|
||||
+ "and (:status is null or l.status = :status) "
|
||||
+ "and (:event is null or l.event = :event) "
|
||||
+ "and (:instanceId is null or l.instanceId = :instanceId) order by l.id desc")
|
||||
Page<FlwCallbackLog> pageLogs(@Param("schId") Long schId, @Param("status") Integer status,
|
||||
@Param("event") String event, @Param("instanceId") Long instanceId,
|
||||
Pageable pageable);
|
||||
|
||||
/** 死信列表 */
|
||||
Page<FlwCallbackLog> findBySchIdAndStatusOrderByIdDesc(Long schId, Integer status, Pageable pageable);
|
||||
|
||||
/** 挂起列表(管理端):待推且重试次数已达挂起阈值(持续重试中疑似消费方停机) */
|
||||
@Query("select l from FlwCallbackLog l where l.schId = :schId and l.status = 0 "
|
||||
+ "and l.retryCount >= :minRetry order by l.id desc")
|
||||
Page<FlwCallbackLog> pageHung(@Param("schId") Long schId, @Param("minRetry") int minRetry, Pageable pageable);
|
||||
|
||||
List<FlwCallbackLog> findByIdInAndSchId(Collection<Long> ids, Long schId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FlwCallbackRegRepository extends JpaRepository<FlwCallbackReg, Long> {
|
||||
|
||||
Optional<FlwCallbackReg> findBySchIdAndAppKeyAndFlowGuid(Long schId, String appKey, String flowGuid);
|
||||
|
||||
/** CallbackDispatcher 匹配:该流程下所有启用注册(event 订阅在内存过滤,量小) */
|
||||
List<FlwCallbackReg> findBySchIdAndFlowGuidAndStatus(Long schId, String flowGuid, Integer status);
|
||||
|
||||
List<FlwCallbackReg> findBySchIdOrderByUpdateTimeDesc(Long schId);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
|
||||
|
||||
Optional<FlwDef> findBySchIdAndFlowGuidAndStatusAndVersionOrderByIdDesc(Long schId, String flowGuid, Integer status, Integer version);
|
||||
|
||||
/** flow_guid 解析规则:该 (sch_id, flow_guid) 下已发布中 version 最大者(概览 §4.1) */
|
||||
Optional<FlwDef> findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(Long schId, String flowGuid, Integer status);
|
||||
|
||||
/** 任意状态(回调注册只要求流程线存在) */
|
||||
Optional<FlwDef> findFirstBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid);
|
||||
|
||||
/** 同名草稿(保存新建时复用,避免 uk 冲突) */
|
||||
Optional<FlwDef> findFirstBySchIdAndCodeAndStatus(Long schId, String code, Integer status);
|
||||
|
||||
@Query("select coalesce(max(d.version), 0) from FlwDef d where d.schId = :schId and d.code = :code")
|
||||
int maxVersionByCode(@Param("schId") Long schId, @Param("code") String code);
|
||||
|
||||
@Query("select coalesce(max(d.version), 0) from FlwDef d where d.schId = :schId and d.flowGuid = :flowGuid")
|
||||
int maxVersionByGuid(@Param("schId") Long schId, @Param("flowGuid") String flowGuid);
|
||||
|
||||
Optional<FlwDef> findFirstBySchIdAndCodeOrderByVersionDesc(Long schId, String code);
|
||||
|
||||
List<FlwDef> findBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid);
|
||||
|
||||
List<FlwDef> findBySchIdAndStatusOrderByUpdateTimeDesc(Long schId, Integer status);
|
||||
|
||||
List<FlwDef> findBySchIdOrderByUpdateTimeDesc(Long schId);
|
||||
|
||||
/** 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本 */
|
||||
@Query("select d from FlwDef d where d.schId = :schId and d.status = 1 "
|
||||
+ "and d.version = (select max(d2.version) from FlwDef d2 where d2.schId = d.schId "
|
||||
+ "and d2.flowGuid = d.flowGuid and d2.status = 1) order by d.updateTime desc")
|
||||
List<FlwDef> findPublishedLatestPerGuid(@Param("schId") Long schId);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long> {
|
||||
|
||||
/** biz uk 幂等主键 */
|
||||
Optional<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
|
||||
|
||||
List<FlwInstance> findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status);
|
||||
|
||||
/** 我提交的办件(分页,状态可空) */
|
||||
@Query("select i from FlwInstance i where i.schId = :schId and i.submitterId = :submitterId "
|
||||
+ "and (:status is null or i.status = :status) order by i.id desc")
|
||||
Page<FlwInstance> pageMy(@Param("schId") Long schId, @Param("submitterId") Integer submitterId,
|
||||
@Param("status") Integer status, Pageable pageable);
|
||||
|
||||
/** 管理端办件分页(状态/关键字可空) */
|
||||
@Query("select i from FlwInstance i where i.schId = :schId "
|
||||
+ "and (:status is null or i.status = :status) "
|
||||
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) order by i.id desc")
|
||||
Page<FlwInstance> pageAdmin(@Param("schId") Long schId, @Param("status") Integer status,
|
||||
@Param("keyword") String keyword, Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
|
||||
|
||||
List<FlwTask> findByInstanceIdOrderByIdAsc(Long instanceId);
|
||||
|
||||
List<FlwTask> findByInstanceIdAndStatus(Long instanceId, Integer status);
|
||||
|
||||
List<FlwTask> findByInstanceIdAndStatusIn(Long instanceId, List<Integer> statuses);
|
||||
|
||||
/** 任务办理抢占(概览 §5.4):affected=1 才获得推进权,防双击/并发重复推进 */
|
||||
@Modifying
|
||||
@Query("update FlwTask t set t.status = :target, t.handleTime = :handleTime, t.opinion = :opinion "
|
||||
+ "where t.id = :id and t.status = 0")
|
||||
int claimAndFinish(@Param("id") Long id, @Param("target") Integer target,
|
||||
@Param("handleTime") LocalDateTime handleTime, @Param("opinion") String opinion);
|
||||
|
||||
@Modifying
|
||||
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0")
|
||||
int invalidatePendingByInstance(@Param("instanceId") Long instanceId);
|
||||
|
||||
@Modifying
|
||||
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId")
|
||||
int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId);
|
||||
|
||||
/** 我的待办(主路径,走 assignee 索引) */
|
||||
@Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 "
|
||||
+ "order by t.arriveTime asc, t.id asc")
|
||||
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable);
|
||||
|
||||
/** 我的已办(办理过的历史任务) */
|
||||
@Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId and t.status in (1, 2) "
|
||||
+ "order by t.handleTime desc, t.id desc")
|
||||
Page<FlwTask> pageDone(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable);
|
||||
|
||||
/** 超期待催任务(定时扫描:待办+超期+未达催办上限) */
|
||||
@Query("select t from FlwTask t where t.status = 0 and t.deadline is not null and t.deadline < :now "
|
||||
+ "and t.urgeCount < :maxTimes")
|
||||
List<FlwTask> findOverdue(@Param("now") LocalDateTime now, @Param("maxTimes") int maxTimes);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
server:
|
||||
port: 8085
|
||||
# 本地无 context-path(同 Notice dev / svc-hall dev);生产经 CONTEXT_PATH=/flow-center 注入,
|
||||
# Caddy 只反代 /flow-center/api/* 与 /flow-center/oapi/*,页面走前端静态
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: flow-center
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/bsch_flowcenter?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: 123456
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
minimum-idle: 5
|
||||
maximum-pool-size: 20
|
||||
jpa:
|
||||
hibernate:
|
||||
# 开发期 update 自动建表;生产环境建议 none + 升级脚本(sql/ 目录)
|
||||
ddl-auto: update
|
||||
show-sql: false
|
||||
open-in-view: false
|
||||
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
properties:
|
||||
hibernate:
|
||||
jdbc:
|
||||
time_zone: Asia/Shanghai
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: Asia/Shanghai
|
||||
|
||||
# ===== 基础平台 yh 配置(Cms/Notice/svc-hall 同款三件套)=====
|
||||
yh:
|
||||
auth:
|
||||
enabled: true
|
||||
sso:
|
||||
# 本地全链测试:指向 local-debug 的 CAS(8443 根路径,RSA+AJAX 登录页);生产改回 sso.yunhaixingtong.com
|
||||
server-url-prefix: http://localhost:8443
|
||||
# 本地 dev 指向后端直连(脚本化登录 service=client-host-url+请求路径);生产改回 http://localhost:8088
|
||||
client-host-url: http://localhost:8085
|
||||
allow-headers: all
|
||||
allow-origins: all
|
||||
# 用户面与管理面全在 /api/* 下;本应用无匿名面。
|
||||
# 业务方托管调用走 /oapi/*(appKey 签名,RequireAppSignFilter 强制 401)
|
||||
url-pattern: /api/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
# 本应用自身身份(客户端签名用)——UserBase 注册后分配(进白名单),占位值上线前必须替换,
|
||||
# 生产由 projman/Nacos 注入,禁止明文入库仓库(V2.0 R7)。
|
||||
# 本地占位 appKey=100019(svc-hall=100018 顺延);联调时需在 UserBase(及对端 trusted-apps)侧同步此占位对。
|
||||
# 注意:占位 secret 必须带引号且含字母——裸 32 个 0 会被 YAML 按八进制整数解析成 "0",SignUtil 校验直接 500
|
||||
app-key: 100019
|
||||
app-secret: "flow-center-dev-secret-100019"
|
||||
# 提供方强制验签(两级模型 §4.1,狗粮迁移 T1.3):本地 OpenApiAuthEnforceConfiguration 副本已删,
|
||||
# 换此一行——/oapi/** 无签名三件套即 401(starter 版 RequireAppSignFilter,order +11 行为等价)
|
||||
server:
|
||||
enforce-patterns: [/oapi/*]
|
||||
# 中央密钥链(两级模型 §4.2):trusted-apps 先(本地确定性兜底),未命中走 SignKeyResolver
|
||||
# (Redis sign:app:* → Caffeine → UserBase 兜底端点)——任何在 UserBase 注册过的 appKey 自动可验
|
||||
trust-registry: true
|
||||
# 入站验签白名单:svc-hall 办事大厅(appKey=100018)、CMS(appKey=100005)、Notice(appKey=100617,
|
||||
# 与生产权威 deploy/notice.yaml 一致——本地联调对齐生产号段,D8);
|
||||
# 开 trust-registry 后此表仍保留为主兜底(Redis 停摆时白名单确定性工作)
|
||||
trusted-apps:
|
||||
"100018": "svc-hall-dev-secret-100018"
|
||||
"100005": "2c7885de594742cc840b8762910b2d2e"
|
||||
"100617": "2a3b4c5d6e7f8091a2b3c4d5e6f70819"
|
||||
open:
|
||||
enabled: true
|
||||
# 本地全链测试:指向 local-debug 的 UserBase;生产改回 base.yunhaixingtong.com
|
||||
base-host: http://localhost:8060
|
||||
base-path: /userbase
|
||||
app-key: 100019
|
||||
app-secret: "flow-center-dev-secret-100019"
|
||||
|
||||
# ===== 流程中心专属配置 =====
|
||||
flow-center:
|
||||
# 提交人本校校验(概览 §5.5):经 UserBase /oapi/user/byId 核验,生产必须 true(fail-closed);
|
||||
# 本地 UserBase(8060) 已就绪 → true 走真校验
|
||||
submitter-school-check:
|
||||
enabled: true
|
||||
# (原 public-base-url 已删)UserMsg jumpPath 是裸相对路径(/task/{taskId}),落地页基址由
|
||||
# 消费方(UserHome 消息代理)按 bizType→应用 front 基址拼 "#/..." 承接,本配置在 Java 侧
|
||||
# 零引用,属死配置(走查报告 BP-R3-1);勿再回加。
|
||||
# 本地开发跨端口 CORS(V2.0 拍板项#2):svc-hall front(8087)/本应用 front(8088) 直调 /api/flow/*
|
||||
# 需要带凭据的 CORS 头;同主域生产置空即可关闭
|
||||
cors:
|
||||
# 8077 = Notice front dev 端口(T1.4:Notice 详情页直调 /api/flow/* 撤回/催办需带凭据 CORS)
|
||||
allowed-origins: http://localhost:8087,http://localhost:8088,http://localhost:8077
|
||||
# 回调推送线程池(概览 §8.4:单机小并发 5)
|
||||
callback:
|
||||
push-pool-size: 5
|
||||
# 退避序列(分钟):1m→5m→30m→2h→6h;越过序列后按 cap-backoff-minutes 封顶持续重试(不自动死信)
|
||||
retry-backoff-minutes: 1,5,30,120,360
|
||||
cap-backoff-minutes: 60
|
||||
hang-alert-retries: 6
|
||||
# 挂起/死信告警接收人(本校管理员 userId;本地联调可留空)
|
||||
alert-user-ids: ""
|
||||
usermsg:
|
||||
# UserMsg 基址(IDHttp 出站自动签名直达 /oapi/msg/**;UserMsg 侧 trusted-apps 须收本应用占位 appKey=100019)
|
||||
enabled: true
|
||||
base-url: http://localhost:8080
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.yunhai.flowcenter: debug
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never
|
||||
health:
|
||||
redis:
|
||||
# 引 data-redis 仅服务于 trust-registry 密钥链;关健康指标防 Redis 停摆拉 DOWN /actuator/health
|
||||
# (组合链 trusted-apps 先行兜底,Redis 不可用时验签仍确定性工作)
|
||||
enabled: false
|
||||
@@ -0,0 +1,449 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.service.FlowInstanceService;
|
||||
import com.yunhai.flowcenter.service.OapiFlowService;
|
||||
import com.yunhai.flowcenter.support.FlowTestSupport;
|
||||
import com.yunhai.flowcenter.support.OapiSignedClient;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 回调分发 IT:注册(探活)→ 代发起 → AFTER_COMMIT 事件 → 落 flw_callback_log →
|
||||
* 异步推送 X-Flw-* 四头 → stub 接收端验签 + payload 字段断言;推送失败持续重试(T1.1:
|
||||
* 越过退避表封顶 1h 不死信、挂起告警恰一次、瞬时失败自动治愈);挂起可视/标记死信/rePush 复活(T1.2)。
|
||||
* 注意:本类必须单类独跑(平台 IT 判据)。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
class CallbackDispatchIT extends FlowTestSupport {
|
||||
|
||||
private static final String APP_KEY = "100018";
|
||||
private static final String APP_SECRET = "svc-hall-dev-secret-100018";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private OapiFlowService oapiFlowService;
|
||||
@Autowired
|
||||
private FlowInstanceService instanceService;
|
||||
@Autowired
|
||||
private CallbackPushService pushService;
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.service.FlowCallbackAdminService callbackAdminService;
|
||||
@Autowired
|
||||
private FlwCallbackRegRepository regRepository;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private HttpServer stubServer;
|
||||
private int stubPort;
|
||||
private final CopyOnWriteArrayList<Received> received = new CopyOnWriteArrayList<>();
|
||||
|
||||
/** 计数版 UserMsgClient:只数挂起告警(hang:* msgNo),不真正外发(@Primary 注入 CallbackPushService) */
|
||||
static final CountingUserMsgClient STUB_USER_MSG = new CountingUserMsgClient();
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public CountingUserMsgClient countingUserMsgClient() {
|
||||
return STUB_USER_MSG;
|
||||
}
|
||||
}
|
||||
|
||||
static class CountingUserMsgClient extends com.yunhai.flowcenter.notify.UserMsgClient {
|
||||
final java.util.concurrent.atomic.AtomicInteger hangAlerts =
|
||||
new java.util.concurrent.atomic.AtomicInteger();
|
||||
|
||||
CountingUserMsgClient() {
|
||||
super(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendUserMsg(Integer schId, List<Integer> userIds, Integer msgType, String bizType,
|
||||
String bizId, String title, String content, String jumpPath, String msgNo,
|
||||
Integer expireDays) {
|
||||
if (msgNo != null && msgNo.startsWith("hang:")) {
|
||||
hangAlerts.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class Received {
|
||||
String body;
|
||||
String eventId;
|
||||
String timestamp;
|
||||
String nonce;
|
||||
String signature;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
cleanDb();
|
||||
received.clear();
|
||||
STUB_USER_MSG.hangAlerts.set(0);
|
||||
stubServer = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
stubPort = stubServer.getAddress().getPort();
|
||||
stubServer.createContext("/callback/flow", exchange -> {
|
||||
Received r = new Received();
|
||||
r.body = readBody(exchange);
|
||||
r.eventId = exchange.getRequestHeaders().getFirst("X-Flw-EventId");
|
||||
r.timestamp = exchange.getRequestHeaders().getFirst("X-Flw-Timestamp");
|
||||
r.nonce = exchange.getRequestHeaders().getFirst("X-Flw-Nonce");
|
||||
r.signature = exchange.getRequestHeaders().getFirst("X-Flw-Signature");
|
||||
received.add(r);
|
||||
byte[] resp = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, resp.length);
|
||||
exchange.getResponseBody().write(resp);
|
||||
exchange.close();
|
||||
});
|
||||
stubServer.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
stubServer.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullChain_register_apply_pushesSignedEvents() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t20", "回调全链", linearDef("ybb", "审核"));
|
||||
// 1. 注册(探活命中 stub)→ secret 下发
|
||||
OapiModels.CallbackRegResultVo reg = oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow", null));
|
||||
assertTrue(reg.getSecret() != null && reg.getSecret().length() >= 16);
|
||||
|
||||
// 2. 代发起 → flow.submitted + flow.task.created 两事件先后落库并推送
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-1\",\"bizTitle\":\"张三的回调件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
org.springframework.http.ResponseEntity<JsonNode> applyResp =
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
// 3. 轮询 stub:submitted + task.created 两事件都到齐(探活 ping 不计)
|
||||
waitUntil(() -> received.stream().anyMatch(r -> r.body.contains("flow.submitted"))
|
||||
&& received.stream().anyMatch(r -> r.body.contains("flow.task.created")), 10_000);
|
||||
boolean hasSubmitted = false;
|
||||
boolean hasTaskCreated = false;
|
||||
for (Received r : received) {
|
||||
JsonNode payload = readJson(r.body);
|
||||
String event = payload.get("event").asText();
|
||||
if ("ping".equals(event)) {
|
||||
continue; // 注册探活 ping 不参与事件协议断言
|
||||
}
|
||||
if ("flow.submitted".equals(event)) {
|
||||
hasSubmitted = true;
|
||||
}
|
||||
if ("flow.task.created".equals(event)) {
|
||||
hasTaskCreated = true;
|
||||
}
|
||||
// 协议断言:必带字段
|
||||
assertEquals("1", payload.get("schId").asText());
|
||||
assertEquals(def.getFlowGuid(), payload.get("flowGuid").asText());
|
||||
assertEquals("svc_item", payload.get("bizType").asText());
|
||||
assertEquals("cb-1", payload.get("bizId").asText());
|
||||
assertEquals("张三的回调件", payload.get("bizTitle").asText());
|
||||
assertTrue(payload.get("instanceVersion").asInt() >= 1, "instanceVersion 应为办件乐观锁版本");
|
||||
assertNotNull(payload.get("eventId"));
|
||||
assertNotNull(payload.get("occurredTime"));
|
||||
// 签名断言:Base64(HmacSHA256(secret, ts.nonce.body))
|
||||
String expected = hmac(reg.getSecret(), r.timestamp, r.nonce, r.body);
|
||||
assertEquals(expected, r.signature, "推送必须带合法 X-Flw-Signature");
|
||||
assertEquals(r.eventId, payload.get("eventId").asText(), "头与体 eventId 一致");
|
||||
}
|
||||
assertTrue(hasSubmitted, "应有 flow.submitted 事件");
|
||||
assertTrue(hasTaskCreated, "应有 flow.task.created 事件");
|
||||
// 4. 日志表成功态(异步推送落定,轮询等待)
|
||||
assertTrue(logRepository.findAll().size() >= 2);
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()), 10_000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushFailure_persistsRetryBeyondBackoffList() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t21", "持续重试", linearDef("ybb", "审核"));
|
||||
// 只订阅 flow.submitted:单事件单日志行,断言确定
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow", "flow.submitted"));
|
||||
FlwCallbackReg regRow = regRepository.findBySchIdAndAppKeyAndFlowGuid(SCH, APP_KEY, def.getFlowGuid())
|
||||
.orElseThrow(IllegalStateException::new);
|
||||
// 注册指向死端口模拟业务方宕机(探活已过,直接改库)
|
||||
regRow.setCallbackUrl("http://localhost:1/dead");
|
||||
regRepository.save(regRow);
|
||||
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-2\",\"bizTitle\":\"张三的挂起件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
|
||||
// 首推失败:retryCount=1(异步首推,轮询等待)
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
// 手工逐次触发扫描语义再失败 5 轮(共 6 次,越过退避表 5 项)
|
||||
for (int round = 0; round < 5; round++) {
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
}
|
||||
FlwCallbackLog hung = logRepository.findAll().get(0);
|
||||
// T1.1 核心语义:持续重试不自动死信——status 仍=0、retryCount=6、nextRetry≈now+60min(封顶)
|
||||
assertEquals(FlwEnums.CallbackLogStatus.PENDING.getCode(), hung.getStatus(),
|
||||
"越过退避表后必须保持待推持续重试,不得自动死信");
|
||||
assertEquals(6, hung.getRetryCount());
|
||||
assertNotNull(hung.getNextRetryTime());
|
||||
java.time.LocalDateTime expectedNext = java.time.LocalDateTime.now().plusMinutes(60);
|
||||
assertTrue(hung.getNextRetryTime().isAfter(expectedNext.minusMinutes(5))
|
||||
&& hung.getNextRetryTime().isBefore(expectedNext.plusMinutes(5)),
|
||||
"越过退避表后 nextRetryTime 应封顶 +60 分钟: " + hung.getNextRetryTime());
|
||||
assertTrue(logRepository.findAll().stream()
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()),
|
||||
"任何行都不应出现死信(status=2)");
|
||||
assertNotNull(hung.getLastError());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hangAlertSentExactlyOnce() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t23", "挂起告警", linearDef("ybb", "审核"));
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow", "flow.submitted"));
|
||||
FlwCallbackReg regRow = regRepository.findBySchIdAndAppKeyAndFlowGuid(SCH, APP_KEY, def.getFlowGuid())
|
||||
.orElseThrow(IllegalStateException::new);
|
||||
regRow.setCallbackUrl("http://localhost:1/dead");
|
||||
regRepository.save(regRow);
|
||||
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-4\",\"bizTitle\":\"张三的告警件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
|
||||
// 失败到第 6 次(=挂起阈值):告警恰一次
|
||||
for (int round = 0; round < 5; round++) {
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
}
|
||||
assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 6 次失败后挂起告警应恰发送一次");
|
||||
|
||||
// 第 7 次失败:仍是 1(恰一次)
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 7 次失败后告警计数仍应为 1");
|
||||
assertEquals(7, logRepository.findAll().get(0).getRetryCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transientFailureRecovers() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t24", "瞬时失败治愈", linearDef("ybb", "审核"));
|
||||
// 抖动端点:探活(第1次) 200、首推(第2次) 500、其后 200——模拟消费方瞬时故障后恢复
|
||||
AtomicInteger flakyCount = new AtomicInteger(0);
|
||||
stubServer.createContext("/callback/flaky", exchange -> {
|
||||
boolean fail = flakyCount.incrementAndGet() == 2;
|
||||
byte[] resp = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(fail ? 500 : 200, resp.length);
|
||||
exchange.getResponseBody().write(resp);
|
||||
exchange.close();
|
||||
});
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flaky", "flow.submitted"));
|
||||
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-5\",\"bizTitle\":\"张三的抖动件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
|
||||
// 首推 500 → 进入重试(status=0, retryCount=1)
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
// 消费方恢复后(扫描/重推触发)→ 200 → status=1 + pushTime 落定
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
FlwCallbackLog recovered = logRepository.findAll().get(0);
|
||||
assertEquals(FlwEnums.CallbackLogStatus.SUCCESS.getCode(), recovered.getStatus(),
|
||||
"瞬时失败恢复后应自动治愈为成功");
|
||||
assertNotNull(recovered.getPushTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hungList_paged_andMarkDead_thenRepushRevives() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t25", "挂起管理", linearDef("ybb", "审核"));
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow", "flow.submitted"));
|
||||
FlwCallbackReg regRow = regRepository.findBySchIdAndAppKeyAndFlowGuid(SCH, APP_KEY, def.getFlowGuid())
|
||||
.orElseThrow(IllegalStateException::new);
|
||||
regRow.setCallbackUrl("http://localhost:1/dead");
|
||||
regRepository.save(regRow);
|
||||
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-6\",\"bizTitle\":\"张三的管理件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
for (int round = 0; round < 5; round++) {
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
}
|
||||
FlwCallbackLog hung = logRepository.findAll().get(0);
|
||||
assertEquals(6, hung.getRetryCount());
|
||||
|
||||
// 挂起列表:status=0 且 retryCount≥阈值的分页行,不含正常行
|
||||
com.yunhai.platform.utils.model.BasePageResult<FlwCallbackLog> hungPage =
|
||||
callbackAdminService.pageHung(SCH, 1, 10);
|
||||
assertTrue(hungPage.getContent().stream().anyMatch(l -> l.getId().equals(hung.getId())));
|
||||
assertTrue(hungPage.getContent().stream()
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()
|
||||
&& l.getRetryCount() >= 6));
|
||||
|
||||
// 管理端手工标记死信(唯一死信入口)
|
||||
callbackAdminService.markDead(SCH, hung.getId());
|
||||
FlwCallbackLog dead = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new);
|
||||
assertEquals(FlwEnums.CallbackLogStatus.DEAD.getCode(), dead.getStatus());
|
||||
|
||||
// 恢复消费方地址后既有 rePush 复活:置回待推立即推送 → 成功
|
||||
regRow.setCallbackUrl("http://localhost:" + stubPort + "/callback/flow");
|
||||
regRepository.save(regRow);
|
||||
callbackAdminService.rePush(SCH, java.util.Collections.singletonList(hung.getId()));
|
||||
waitUntil(() -> {
|
||||
FlwCallbackLog row = logRepository.findById(hung.getId()).orElse(null);
|
||||
return row != null && row.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode();
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsubscribedEvent_notDelivered() throws InterruptedException {
|
||||
mapDept("ybb", 101, "甲");
|
||||
com.yunhai.flowcenter.entity.FlwDef def = saveAndPublishDef("t22", "订阅过滤", linearDef("ybb", "审核"));
|
||||
// 只订阅 flow.completed:submitted/task.created 不推送
|
||||
oapiFlowService.registerCallback(SCH, APP_KEY,
|
||||
regBody(def.getFlowGuid(), "http://localhost:" + stubPort + "/callback/flow",
|
||||
"flow.completed"));
|
||||
OapiSignedClient client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-3\",\"bizTitle\":\"张三的订阅件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
Thread.sleep(1500);
|
||||
// stub 收到的注册探活 ping 不算事件推送
|
||||
long eventPushes = received.stream()
|
||||
.filter(r -> !r.body.contains("\"event\":\"ping\"")).count();
|
||||
assertEquals(0, eventPushes, "未订阅事件不应推送");
|
||||
}
|
||||
|
||||
/** 组注册请求体(registerCallback 返回下发 secret) */
|
||||
private OapiModels.CallbackRegBody regBody(String flowGuid, String url, String eventTypes) {
|
||||
OapiModels.CallbackRegBody body = new OapiModels.CallbackRegBody();
|
||||
body.setFlowGuid(flowGuid);
|
||||
body.setCallbackUrl(url);
|
||||
body.setEventTypes(eventTypes);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 轮询等待(替代 Awaitility,离线环境无该依赖) */
|
||||
private static void waitUntil(java.util.function.BooleanSupplier cond, long timeoutMs) {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (cond.getAsBoolean()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertTrue(cond.getAsBoolean(), "等待条件超时");
|
||||
}
|
||||
|
||||
private static String readBody(HttpExchange exchange) {
|
||||
try (InputStream in = exchange.getRequestBody()) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
return out.toString("UTF-8");
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode readJson(String s) {
|
||||
try {
|
||||
return objectMapper.readTree(s);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String hmac(String secret, String ts, String nonce, String body) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] raw = mac.doFinal((ts + "." + nonce + "." + body).getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 回调推送配置默认值冻结测试(T1.1 防误改):退避表 1/5/30/120/360、挂起告警阈值 6、
|
||||
* 封顶间隔 60 分钟、告警接收人默认空(不告警)。
|
||||
*/
|
||||
class CallbackPropertiesTest {
|
||||
|
||||
@Test
|
||||
void defaultsFrozen() {
|
||||
CallbackProperties p = new CallbackProperties();
|
||||
assertEquals(5, p.getPushPoolSize());
|
||||
assertEquals(Arrays.asList(1L, 5L, 30L, 120L, 360L), p.getRetryBackoffMinutes());
|
||||
assertEquals(6, p.getHangAlertRetries(), "挂起告警阈值默认 6(越过退避表 5 次后的第一次失败)");
|
||||
assertEquals(60L, p.getCapBackoffMinutes(), "越过退避表后的封顶重试间隔 60 分钟");
|
||||
assertEquals("", p.getAlertUserIds());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 发布静态校验单测(概览 §5.2 约束):把"任务无主/死图"拦在发布期。
|
||||
*/
|
||||
class DefValidatorTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private FlowDefModel parse(String json) {
|
||||
return FlowDefModel.parse(MAPPER, json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validLinearDef_passes() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingEnd_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":[{\"id\":\"start\",\"type\":\"START\"}],"
|
||||
+ "\"edges\":[]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), Collections.emptySet()));
|
||||
assertTrue(e.getMessage().contains("END"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoStarts_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},{\"id\":\"start2\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[]}";
|
||||
assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), Collections.emptySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanNode_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"},"
|
||||
+ "{\"id\":\"n_orphan\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("孤儿"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingDeptMapping_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"gjc\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("gjc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cycle_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"n2\"},"
|
||||
+ "{\"from\":\"n2\",\"to\":\"n1\"},{\"from\":\"n2\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("环"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usersAssignee_passesWithoutMapping() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[11,12]}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json), Collections.emptySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyUserIds_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"USERS\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), Collections.emptySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewSample_def() {
|
||||
// 概览 §5.1 缩样(并行+分支内串行):配齐 6 个 DEPT_KEY 映射应通过
|
||||
String json = "{"
|
||||
+ "\"code\":\"staff\",\"name\":\"教职工入职\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"人事处登记\"},"
|
||||
+ "{\"id\":\"n_hr\",\"type\":\"APPROVAL\",\"name\":\"人才办审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"rsd_rcb\"},\"countersign\":\"ALL\"},"
|
||||
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"t_gjc\",\"type\":\"APPROVAL\",\"name\":\"国际处\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"gjc\"}},"
|
||||
+ "{\"id\":\"t_jwc\",\"type\":\"APPROVAL\",\"name\":\"教务处\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"jwc\"}},"
|
||||
+ "{\"id\":\"t_ybb\",\"type\":\"APPROVAL\",\"name\":\"医保办\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"t_bwc\",\"type\":\"APPROVAL\",\"name\":\"保卫处\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"bwc\"}},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n_hr\"},{\"from\":\"n_hr\",\"to\":\"split1\"},"
|
||||
+ "{\"from\":\"split1\",\"to\":\"t_gjc\"},{\"from\":\"split1\",\"to\":\"t_jwc\"},"
|
||||
+ "{\"from\":\"split1\",\"to\":\"t_ybb\"},{\"from\":\"t_ybb\",\"to\":\"t_bwc\"},"
|
||||
+ "{\"from\":\"t_bwc\",\"to\":\"join1\"},{\"from\":\"t_gjc\",\"to\":\"join1\"},"
|
||||
+ "{\"from\":\"t_jwc\",\"to\":\"join1\"},{\"from\":\"join1\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json),
|
||||
new HashSet<>(Arrays.asList("rsd_rcb", "gjc", "jwc", "ybb", "bwc")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
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.service.FlowInstanceService;
|
||||
import com.yunhai.flowcenter.service.FlowTaskService;
|
||||
import com.yunhai.flowcenter.support.FlowTestSupport;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 引擎闭环 IT(真实 MySQL bsch_flowcenter_test;不开测试事务——AFTER_COMMIT 推进
|
||||
* 监听器依赖真实提交):激活/ALL 会签合并/并行分支/拒绝/撤回/催办/R4 重启重提/乐观锁。
|
||||
* 注意:本类必须单类独跑(平台 IT 判据,与他类合跑共享库必假红)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class FlowEngineIT extends FlowTestSupport {
|
||||
|
||||
@Autowired
|
||||
private FlowInstanceService instanceService;
|
||||
@Autowired
|
||||
private FlowTaskService taskService;
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cleanDb();
|
||||
}
|
||||
|
||||
private FlowInstanceService.ApplyOutcome apply(String flowGuid, String bizId,
|
||||
Integer submitterId, String submitterName) {
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
SCH, flowGuid, 1).orElseThrow(IllegalStateException::new);
|
||||
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
|
||||
SCH, def, "svc_item", bizId, "张三的测试办件", "http://biz/1", "{\"k\":\"v\"}",
|
||||
submitterId, submitterName, null);
|
||||
return instanceService.apply(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_createsInstanceAndCountersignTasks_allApprove_completes() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("ybb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t1", "会签流程", linearDef("ybb", "联合审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-1", 201, "张三");
|
||||
assertFalse(outcome.isDuplicated());
|
||||
|
||||
FlwInstance instance = instanceRepository.findById(outcome.getInstanceId()).orElse(null);
|
||||
assertNotNull(instance);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instance.getStatus());
|
||||
// ALL 会签:每办理人一条任务
|
||||
List<FlwTask> tasks = taskRepository.findByInstanceIdAndStatus(instance.getId(), 0);
|
||||
assertEquals(2, tasks.size());
|
||||
assertEquals("n1", instance.getCurrentNodes());
|
||||
|
||||
// 第一人同意:节点未清零,仍在途
|
||||
taskService.handle(SCH, tasks.get(0).getId(), true, "同意", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instance.getId()).get().getStatus());
|
||||
// 第二人同意:全部清零 → 推进 END → 办件完成
|
||||
taskService.handle(SCH, tasks.get(1).getId(), true, null, 102);
|
||||
FlwInstance after = instanceRepository.findById(instance.getId()).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), after.getStatus());
|
||||
assertNotNull(after.getFinishTime());
|
||||
assertTrue(after.getVersion() >= 2, "完成时乐观锁版本应递增");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anyReject_endsRejected_andInvalidatesOthers() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("ybb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t2", "会签拒绝", linearDef("ybb", "联合审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-2", 201, "张三");
|
||||
List<FlwTask> tasks = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0);
|
||||
|
||||
taskService.handle(SCH, tasks.get(0).getId(), false, "材料不全", 101);
|
||||
FlwInstance instance = instanceRepository.findById(outcome.getInstanceId()).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(), instance.getStatus());
|
||||
assertNotNull(instance.getFinishTime());
|
||||
// 同批另一待办已失效
|
||||
List<FlwTask> all = taskRepository.findByInstanceIdOrderByIdAsc(instance.getId());
|
||||
assertEquals(FlwEnums.TaskStatus.REJECTED.getCode(), all.get(0).getStatus());
|
||||
assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), all.get(1).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_requiresOpinion() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t3", "意见必填", linearDef("ybb", "审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-3", 201, "张三");
|
||||
FlwTask task = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0).get(0);
|
||||
assertThrows(BizException.class, () -> taskService.handle(SCH, task.getId(), false, " ", 101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdraw_beforeAnyHandle_ok_afterHandle_forbidden() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t4", "撤回", linearDef("ybb", "审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-4", 201, "张三");
|
||||
instanceService.withdraw(SCH, outcome.getInstanceId());
|
||||
assertEquals(FlwEnums.InstanceStatus.WITHDRAWN.getCode(),
|
||||
instanceRepository.findById(outcome.getInstanceId()).get().getStatus());
|
||||
|
||||
// 重提重启后再办理,随后撤回必须被拒(已有任务被办理)
|
||||
FlowInstanceService.ApplyOutcome reapplied = apply(def.getFlowGuid(), "biz-4", 201, "张三");
|
||||
FlwTask task = taskRepository.findByInstanceIdAndStatus(reapplied.getInstanceId(), 0).get(0);
|
||||
taskService.handle(SCH, task.getId(), true, "ok", 101);
|
||||
assertThrows(BizException.class, () -> instanceService.withdraw(SCH, reapplied.getInstanceId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reapply_inFlight_duplicated_noNewInstance() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t5", "在途幂等", linearDef("ybb", "审核"));
|
||||
FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-5", 201, "张三");
|
||||
FlowInstanceService.ApplyOutcome second = apply(def.getFlowGuid(), "biz-5", 201, "张三");
|
||||
assertTrue(second.isDuplicated());
|
||||
assertEquals(first.getInstanceId(), second.getInstanceId());
|
||||
// 在途命中只返回不重建,任务不翻倍
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reapply_afterReject_restartsSameInstance_withHistoryKept() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t6", "R4 重启", linearDef("ybb", "审核"));
|
||||
FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-6", 201, "张三");
|
||||
FlwInstance before = instanceRepository.findById(first.getInstanceId()).get();
|
||||
int versionBefore = before.getVersion();
|
||||
|
||||
// 拒绝 → 终态
|
||||
FlwTask task = taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).get(0);
|
||||
taskService.handle(SCH, task.getId(), false, "不合格", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(),
|
||||
instanceRepository.findById(first.getInstanceId()).get().getStatus());
|
||||
|
||||
// R4:重提复用同一办件行重启推进
|
||||
FlowInstanceService.ApplyOutcome second = apply(def.getFlowGuid(), "biz-6", 201, "张三");
|
||||
assertTrue(second.isDuplicated());
|
||||
assertEquals(first.getInstanceId(), second.getInstanceId());
|
||||
FlwInstance after = instanceRepository.findById(first.getInstanceId()).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), after.getStatus());
|
||||
assertTrue(after.getVersion() > versionBefore, "重启后 instanceVersion 必须单调递增");
|
||||
// 轨迹连续:历史拒绝任务保留 + 新一轮任务待办
|
||||
List<FlwTask> all = taskRepository.findByInstanceIdOrderByIdAsc(first.getInstanceId());
|
||||
assertEquals(FlwEnums.TaskStatus.REJECTED.getCode(), all.get(0).getStatus());
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size());
|
||||
assertNull(after.getFinishTime());
|
||||
// 二轮通过 → 完成
|
||||
FlwTask newTask = taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).get(0);
|
||||
taskService.handle(SCH, newTask.getId(), true, "通过", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(first.getInstanceId()).get().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelSplitJoin_bothBranchesArrive_thenComplete() {
|
||||
mapDept("ka", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t7", "并行", parallelDef("ka", "kb"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-7", 201, "张三");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
// split 激活两条分支
|
||||
List<FlwBranch> branches = instanceRepository.findById(instanceId)
|
||||
.map(i -> i.getId()).map(id -> branchRows(id)).orElse(null);
|
||||
assertNotNull(branches);
|
||||
assertEquals(2, branches.size());
|
||||
// 甲科先过:分支一到达汇聚点,办件仍在途
|
||||
FlwTask taskA = tasksOf(instanceId, 101).get(0);
|
||||
taskService.handle(SCH, taskA.getId(), true, "同意", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
// 乙科再过:全部到达 → JOIN 放行 → END → 完成
|
||||
FlwTask taskB = tasksOf(instanceId, 102).get(0);
|
||||
taskService.handle(SCH, taskB.getId(), true, "同意", 102);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reject_inParallelBranch_failsWholeInstance() {
|
||||
mapDept("ka", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t8", "并行拒绝", parallelDef("ka", "kb"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-8", 201, "张三");
|
||||
FlwTask taskB = tasksOf(outcome.getInstanceId(), 102).get(0);
|
||||
taskService.handle(SCH, taskB.getId(), false, "乙科不同意", 102);
|
||||
FlwInstance instance = instanceRepository.findById(outcome.getInstanceId()).get();
|
||||
assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(), instance.getStatus());
|
||||
// 另一分支的待办被失效(失效后不在待办集合,按全量任务查找)
|
||||
FlwTask taskA = allTasks(outcome.getInstanceId()).stream()
|
||||
.filter(t -> t.getAssigneeId().equals(101)).findFirst().get();
|
||||
assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(),
|
||||
taskRepository.findById(taskA.getId()).get().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentApprove_sameNode_onlyOnePathAdvances() throws Exception {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("ybb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t9", "并发会签", linearDef("ybb", "会签审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-9", 201, "张三");
|
||||
List<FlwTask> tasks = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0);
|
||||
// 两线程同时办理各自任务(推进互斥由乐观锁保证)
|
||||
Thread t1 = new Thread(() -> taskService.handle(SCH, tasks.get(0).getId(), true, "ok", 101));
|
||||
Thread t2 = new Thread(() -> taskService.handle(SCH, tasks.get(1).getId(), true, "ok", 102));
|
||||
t1.start();
|
||||
t2.start();
|
||||
t1.join(10_000);
|
||||
t2.join(10_000);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(outcome.getInstanceId()).get().getStatus());
|
||||
// 只应有一轮 END 完成(无重复推进导致的重复任务创建:总任务数=2)
|
||||
assertEquals(2, taskRepository.findByInstanceIdOrderByIdAsc(outcome.getInstanceId()).size());
|
||||
}
|
||||
|
||||
private List<FlwBranch> branchRows(Long instanceId) {
|
||||
return branchRepository.findByInstanceId(instanceId);
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.flowcenter.repository.FlwBranchRepository branchRepository;
|
||||
|
||||
private List<FlwTask> allTasks(Long instanceId) {
|
||||
return taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
|
||||
}
|
||||
|
||||
private List<FlwTask> tasksOf(Long instanceId, Integer assigneeId) {
|
||||
List<FlwTask> out = new java.util.ArrayList<>();
|
||||
for (FlwTask t : taskRepository.findByInstanceIdAndStatus(instanceId, 0)) {
|
||||
if (assigneeId.equals(t.getAssigneeId())) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.yunhai.flowcenter.oapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.support.FlowTestSupport;
|
||||
import com.yunhai.flowcenter.support.OapiSignedClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* /oapi/flow/** 契约 IT(RANDOM_PORT + 手工签名三件套,逐字对齐 svc-hall FlowCenterClient 出站形态):
|
||||
* 签名强制 401 / list|get / apply 幂等 / instance 对账出参 / callback reg 探活与 secret 下发。
|
||||
* 注意:本类必须单类独跑(平台 IT 判据)。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
class OapiFlowContractIT extends FlowTestSupport {
|
||||
|
||||
private static final String APP_KEY = "100018";
|
||||
private static final String APP_SECRET = "svc-hall-dev-secret-100018";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private OapiSignedClient client;
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cleanDb();
|
||||
client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oapi_requiresSignature_401() {
|
||||
assertEquals(401, client.getRaw("/oapi/flow/list?schId=1").getStatusCodeValue());
|
||||
assertEquals(401, client.getWithBadSecret("/oapi/flow/list?schId=1").getStatusCodeValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAndGetFlow_contractShape() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t10", "契约流程", linearDef("ybb", "联审"));
|
||||
ResponseEntity<JsonNode> listResp = client.get("/oapi/flow/list?schId=1", JsonNode.class);
|
||||
assertEquals(200, listResp.getStatusCodeValue());
|
||||
JsonNode data = unwrap(listResp.getBody());
|
||||
assertTrue(data.isArray() && data.size() >= 1);
|
||||
JsonNode row = data.get(0);
|
||||
assertEquals(def.getFlowGuid(), row.get("flowGuid").asText());
|
||||
assertEquals("契约流程", row.get("name").asText());
|
||||
assertEquals(1, row.get("version").asInt());
|
||||
assertTrue(row.get("nodeSummary").asText().contains("联审"));
|
||||
|
||||
ResponseEntity<JsonNode> getResp = client.get(
|
||||
"/oapi/flow/" + def.getFlowGuid() + "?schId=1", JsonNode.class);
|
||||
assertEquals(def.getFlowGuid(), unwrap(getResp.getBody()).get("flowGuid").asText());
|
||||
|
||||
// 未发布 guid → data=null(消费方降级)
|
||||
ResponseEntity<JsonNode> miss = client.get("/oapi/flow/notexist000000?schId=1", JsonNode.class);
|
||||
assertTrue(unwrap(miss.getBody()).isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_contract_andBizUkIdempotency() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t11", "代发起", linearDef("ybb", "审核"));
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-1\",\"bizTitle\":\"张三的XX事项\","
|
||||
+ "\"bizUrl\":\"http://front/#/apply-detail/1\","
|
||||
+ "\"bizSnapshot\":{\"itemTitle\":\"XX事项\",\"summary\":\"摘要\"},"
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
ResponseEntity<JsonNode> resp1 = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
assertEquals(200, resp1.getStatusCodeValue());
|
||||
JsonNode data1 = unwrap(resp1.getBody());
|
||||
assertNotNull(data1.get("instanceId"));
|
||||
assertFalse(data1.get("duplicated").asBoolean());
|
||||
|
||||
// 重复代发起(同 bizType+bizId):HTTP 200 + duplicated:true + 同一办件
|
||||
ResponseEntity<JsonNode> resp2 = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
JsonNode data2 = unwrap(resp2.getBody());
|
||||
assertTrue(data2.get("duplicated").asBoolean());
|
||||
assertEquals(data1.get("instanceId").asLong(), data2.get("instanceId").asLong());
|
||||
|
||||
// 办件与任务确实建好(托管代发起与原生同一条引擎路径)
|
||||
Long instanceId = data1.get("instanceId").asLong();
|
||||
assertEquals(10, instanceRepository.findById(instanceId).get().getStatus());
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_crossTenantSubmitter_rejected() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t12", "跨租户校验", linearDef("ybb", "审核"));
|
||||
// stub 用户 school=1;传 schId=2 触发"提交人不属于该学校"
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-x\",\"bizTitle\":\"t\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
ResponseEntity<JsonNode> resp = client.post("/oapi/flow/apply?schId=2", body, JsonNode.class);
|
||||
assertEquals(200, resp.getStatusCodeValue());
|
||||
JsonNode respBody = resp.getBody();
|
||||
assertNotEquals(0, respBody.get("code").asInt(), "跨租户发起应被业务码拒绝");
|
||||
}
|
||||
|
||||
@Test
|
||||
void instanceByBiz_contract_traceWithAssignee() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t13", "对账", linearDef("ybb", "审核"));
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-9\",\"bizTitle\":\"张三的YY\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", body, JsonNode.class)
|
||||
.getBody()).get("instanceId").asLong();
|
||||
|
||||
ResponseEntity<JsonNode> resp = client.get(
|
||||
"/oapi/flow/instance?bizType=svc_item&bizId=apply-9&schId=1", JsonNode.class);
|
||||
JsonNode data = unwrap(resp.getBody());
|
||||
assertEquals(instanceId, data.get("instanceId").asLong());
|
||||
assertEquals(10, data.get("status").asInt());
|
||||
assertNotNull(data.get("version"));
|
||||
assertTrue(data.get("nodeSummary").asText().contains("审核"));
|
||||
assertTrue(data.get("traceJson").asText().contains("assigneeId"));
|
||||
assertTrue(data.get("traceJson").asText().contains("101"), "轨迹须含 assignee_id(拍板项#3)");
|
||||
assertEquals(101, data.get("assigneeIds").get(0).asInt());
|
||||
|
||||
// 查不到 → data=null(对账方按漂移清理处理)
|
||||
ResponseEntity<JsonNode> miss = client.get(
|
||||
"/oapi/flow/instance?bizType=svc_item&bizId=none&schId=1", JsonNode.class);
|
||||
assertTrue(unwrap(miss.getBody()).isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void callbackReg_returnsSecret() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t14", "回调注册", linearDef("ybb", "审核"));
|
||||
// 本地无回调接收端 → 探活失败应拒绝注册(不可达拦截)
|
||||
String bad = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"callbackUrl\":\"http://localhost:1/nonexistent-callback\"}";
|
||||
ResponseEntity<JsonNode> badResp = client.post(
|
||||
"/oapi/flow/callback/reg?schId=1", bad, JsonNode.class);
|
||||
assertNotEquals(0, badResp.getBody().get("code").asInt(), "不可达回调地址应被探活拦截");
|
||||
}
|
||||
|
||||
private JsonNode unwrap(JsonNode body) {
|
||||
return body.get("data");
|
||||
}
|
||||
|
||||
private static void assertNotEquals(int expected, int actual, String message) {
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(Integer.valueOf(expected), Integer.valueOf(actual), message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 用户面办件可见性守卫口径锁定(三种守卫必须同口径:svc-hall ApplyViewGuard /
|
||||
* Notice NoticeFlowGuard / 本类)。原先只认 type==1 超管,导致"能列办件、点详情 403"。
|
||||
*/
|
||||
class FlowAccessGuardTest {
|
||||
|
||||
private FlwTaskRepository taskRepository;
|
||||
private FlowAccessGuard guard;
|
||||
private FlwInstance instance;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
guard = new FlowAccessGuard(taskRepository);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(anyLong()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
|
||||
instance = new FlwInstance();
|
||||
ReflectionTestUtils.setField(instance, "id", 100L);
|
||||
instance.setSchId(990001L);
|
||||
instance.setSubmitterId(11);
|
||||
}
|
||||
|
||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||
BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
|
||||
when(user.getId()).thenReturn(id);
|
||||
when(user.getType()).thenReturn(type);
|
||||
when(user.getRoleIds()).thenReturn(roleIds.length == 0
|
||||
? null : new java.util.ArrayList<>(Arrays.asList(roleIds)));
|
||||
when(user.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitterCanViewOwnInstance() {
|
||||
assertDoesNotThrow(() -> guard.requireInstanceVisible(instance, user(11, 2, 990001, 9)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assigneeCanViewInstance() {
|
||||
FlwTask task = new FlwTask();
|
||||
task.setAssigneeId(22);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(100L)).thenReturn(Collections.singletonList(task));
|
||||
assertDoesNotThrow(() -> guard.requireInstanceVisible(instance, user(22, 2, 990001, 9)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameSchoolAdminByTypeCanView() {
|
||||
assertDoesNotThrow(() -> guard.requireInstanceVisible(instance, user(33, 1, 990001)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameSchoolAdminByRoleIdCanView() {
|
||||
// 校级管理员 roleId∈{2,5,7}:FlowAdminGuard 放行其管理端,可见性守卫必须同口径
|
||||
assertDoesNotThrow(() -> guard.requireInstanceVisible(instance, user(34, 2, 990001, 5)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossSchoolAdminIsDenied() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> guard.requireInstanceVisible(instance, user(35, 1, 888888)));
|
||||
assertEquals("仅提交人/办理人/本校管理员可查看该办件", e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinarySameSchoolUserIsDenied() {
|
||||
assertThrows(BizException.class,
|
||||
() -> guard.requireInstanceVisible(instance, user(36, 2, 990001, 9)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userWithoutCurrentRoleIsDenied() {
|
||||
assertThrows(BizException.class,
|
||||
() -> guard.requireInstanceVisible(instance, user(37, 1, null)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.controller.api.FlowAdminController;
|
||||
import com.yunhai.flowcenter.controller.api.FlowUserController;
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.engine.AssigneeResolver;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
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.openapi.client.OpenApiUserClient;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 流程示意图出图(方案 §3.2 标签解析规则 + §5 TDD C1-C6):
|
||||
* C1/CC3 视图分支与兜底链、C2 UserBase 补名降级、C4 flowGuid 取版、C5/C6 控制器守卫接线。
|
||||
* 科室视图不泄人员名单(USERS 只显"指定办理人"且不调 UserBase)是硬口径。
|
||||
*/
|
||||
class FlowDiagramServiceTest {
|
||||
|
||||
private static final Long SCH = 990001L;
|
||||
|
||||
/** 并行链:start→sp1→n1(DEPT_KEY rs)→j1→end(教师入职缩样) */
|
||||
private static final String DEPT_DEF_JSON = "{"
|
||||
+ "\"code\":\"teacher_in\",\"name\":\"教师入职\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"sp1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"人事处审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"rs\"},\"countersign\":\"ALL\",\"deadlineHours\":48},"
|
||||
+ "{\"id\":\"j1\",\"type\":\"PARALLEL_JOIN\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"sp1\"},{\"from\":\"sp1\",\"to\":\"n1\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"j1\"},{\"from\":\"j1\",\"to\":\"end\"}]}";
|
||||
|
||||
/** ANY 会签变体(无时限) */
|
||||
private static final String ANY_DEF_JSON = "{"
|
||||
+ "\"code\":\"c2\",\"name\":\"任一通过\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"甲审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"rs\"},\"countersign\":\"ANY\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
|
||||
/** USERS 直配变体(Notice 先审后发缩样) */
|
||||
private static final String USERS_DEF_JSON = "{"
|
||||
+ "\"code\":\"notice_audit\",\"name\":\"Notice先审后发\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"甲乙会签\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[11,12,13]}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n2\"},{\"from\":\"n2\",\"to\":\"end\"}]}";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private FlwAssigneeMapRepository mapRepository;
|
||||
private FlwTaskRepository taskRepository;
|
||||
private OpenApiUserClient userClient;
|
||||
private FlowDiagramService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
mapRepository = mock(FlwAssigneeMapRepository.class);
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
userClient = mock(OpenApiUserClient.class);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(anyLong()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
// 真 FlowEngine(真实 JSON 解析通道)+ mock 依赖
|
||||
FlowEngine engine = new FlowEngine(new ObjectMapper(),
|
||||
taskRepository, mock(FlwBranchRepository.class), mock(AssigneeResolver.class),
|
||||
mock(ApplicationEventPublisher.class));
|
||||
service = new FlowDiagramService(defRepository, mapRepository, engine, userClient);
|
||||
}
|
||||
|
||||
private FlwDef def(String json) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId(12L);
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid("g1");
|
||||
def.setCode("c1");
|
||||
def.setName("教师入职");
|
||||
def.setVersion(2);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setContentJson(json);
|
||||
return def;
|
||||
}
|
||||
|
||||
private FlwAssigneeMap mapRow(String deptName, int userId, String userName) {
|
||||
FlwAssigneeMap m = new FlwAssigneeMap();
|
||||
m.setSchId(SCH);
|
||||
m.setDeptKey("rs");
|
||||
m.setDeptName(deptName);
|
||||
m.setUserId(userId);
|
||||
m.setUserName(userName);
|
||||
m.setStatus(0);
|
||||
return m;
|
||||
}
|
||||
|
||||
private BoaUser boaUser(String name) {
|
||||
BoaUser u = mock(BoaUser.class);
|
||||
when(u.getName()).thenReturn(name);
|
||||
return u;
|
||||
}
|
||||
|
||||
private FlowModels.NodeVo node(FlowModels.DiagramVo vo, String id) {
|
||||
return vo.getNodes().stream().filter(n -> id.equals(n.getId())).findFirst()
|
||||
.orElseThrow(() -> new AssertionError("节点缺失:" + id));
|
||||
}
|
||||
|
||||
// ==================== C1:DEPT_KEY 节点科室视图 ====================
|
||||
|
||||
@Test
|
||||
void c1_deptView_deptKeyNode_labelDeptName_subLabelCountAndDeadline() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四")));
|
||||
FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, "dept");
|
||||
FlowModels.NodeVo n1 = node(vo, "n1");
|
||||
assertEquals("人事处", n1.getLabel());
|
||||
assertEquals("科室 · 2人 · 时限48h", n1.getSubLabel());
|
||||
assertEquals("rs", n1.getDeptKey());
|
||||
assertEquals("DEPT_KEY", n1.getAssigneeType());
|
||||
assertEquals("ALL", n1.getCountersign());
|
||||
assertEquals(Integer.valueOf(48), n1.getDeadlineHours());
|
||||
// 科室视图不出现映射成员姓名
|
||||
assertFalse(n1.getSubLabel().contains("张三"));
|
||||
assertFalse(n1.getSubLabel().contains("李四"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_deptView_fallbackChain_deptKeyThenNodeName() {
|
||||
// 映射行全无名 → deptKey 兜底
|
||||
when(mapRepository.findEnabled(SCH, "rs"))
|
||||
.thenReturn(Collections.singletonList(mapRow("", 11, "张三")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("rs", n1.getLabel());
|
||||
assertEquals("科室 · 1人 · 时限48h", n1.getSubLabel());
|
||||
// 无映射行 → 节点名兜底(人数照实为 0)
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Collections.emptyList());
|
||||
n1 = node(service.build(def(DEPT_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("人事处审批", n1.getLabel());
|
||||
assertEquals("科室 · 0人 · 时限48h", n1.getSubLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_anyCountersign_appendsSuffix() {
|
||||
when(mapRepository.findEnabled(SCH, "rs"))
|
||||
.thenReturn(Collections.singletonList(mapRow("人事处", 11, "张三")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(ANY_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("科室 · 1人 · 任一人通过", n1.getSubLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_fixedLabels_andEdgesCarried() {
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, "dept");
|
||||
assertEquals("开始", node(vo, "start").getLabel());
|
||||
assertEquals("并行分发", node(vo, "sp1").getLabel());
|
||||
assertEquals("汇聚等待", node(vo, "j1").getLabel());
|
||||
assertEquals("结束", node(vo, "end").getLabel());
|
||||
assertNull(node(vo, "start").getSubLabel());
|
||||
assertNull(node(vo, "end").getSubLabel());
|
||||
assertEquals(4, vo.getEdges().size());
|
||||
assertEquals("start", vo.getEdges().get(0).getFrom());
|
||||
assertEquals("sp1", vo.getEdges().get(0).getTo());
|
||||
// 出图元信息(前端"按流程定义 vN 生成"与工具条 Tag 数据源)
|
||||
assertEquals("g1", vo.getFlowGuid());
|
||||
assertEquals(Long.valueOf(12L), vo.getDefId());
|
||||
assertEquals("教师入职", vo.getName());
|
||||
assertEquals(Integer.valueOf(2), vo.getVersion());
|
||||
assertEquals(Integer.valueOf(1), vo.getStatus());
|
||||
assertEquals("dept", vo.getMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_autoView_deptKeyNode_rendersDeptStyle() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, null), "n1");
|
||||
assertEquals("人事处", n1.getLabel());
|
||||
assertEquals("科室 · 2人 · 时限48h", n1.getSubLabel());
|
||||
assertEquals("auto", service.build(def(DEPT_DEF_JSON), null, null).getMode());
|
||||
}
|
||||
|
||||
// ==================== C2:USERS 节点人员视图(UserBase 补名降级) ====================
|
||||
|
||||
@Test
|
||||
void c2_userView_usersNode_namesFromUserBase_degradeToId() {
|
||||
BoaUser jia = boaUser("甲老师");
|
||||
BoaUser yi = boaUser("乙老师");
|
||||
when(userClient.getUserById("11")).thenReturn(jia);
|
||||
when(userClient.getUserById("12")).thenReturn(yi);
|
||||
when(userClient.getUserById("13")).thenThrow(new RuntimeException("UserBase 不可达"));
|
||||
FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "user"), "n2");
|
||||
assertEquals("甲乙会签", n2.getLabel());
|
||||
assertEquals("甲老师、乙老师、#13", n2.getSubLabel());
|
||||
assertEquals("USERS", n2.getAssigneeType());
|
||||
assertNull(n2.getDeptKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c2_userBaseReturnsNullUser_degradesToId() {
|
||||
when(userClient.getUserById(anyString())).thenReturn(null);
|
||||
FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "user"), "n2");
|
||||
assertEquals("#11、#12、#13", n2.getSubLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c2_autoView_usersNode_rendersUserStyle() {
|
||||
BoaUser jia = boaUser("甲老师");
|
||||
BoaUser yi = boaUser("乙老师");
|
||||
when(userClient.getUserById("11")).thenReturn(jia);
|
||||
when(userClient.getUserById("12")).thenReturn(yi);
|
||||
when(userClient.getUserById("13")).thenReturn(null);
|
||||
FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "auto"), "n2");
|
||||
assertEquals("甲老师、乙老师、#13", n2.getSubLabel());
|
||||
}
|
||||
|
||||
// ==================== C3:视图分支防泄名单 / DEPT_KEY 人员视图 ====================
|
||||
|
||||
@Test
|
||||
void c3_deptView_usersNode_subLabelOnlyAssigneeHint_neverCallsUserBase() {
|
||||
BoaUser jia = boaUser("甲老师");
|
||||
BoaUser yi = boaUser("乙老师");
|
||||
when(userClient.getUserById("11")).thenReturn(jia);
|
||||
when(userClient.getUserById("12")).thenReturn(yi);
|
||||
FlowModels.NodeVo n2 = node(service.build(def(USERS_DEF_JSON), null, "dept"), "n2");
|
||||
assertEquals("甲乙会签", n2.getLabel());
|
||||
assertEquals("指定办理人", n2.getSubLabel());
|
||||
assertFalse(n2.getSubLabel().contains("甲老师"));
|
||||
assertFalse(n2.getSubLabel().contains("乙老师"));
|
||||
// 防泄名单硬保证:科室视图连 UserBase 都不查
|
||||
verify(userClient, never()).getUserById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c3_userView_deptKeyNode_listsMemberNames_blankNameDegradesToId() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, null)));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "user"), "n1");
|
||||
// 人员视图:label=节点名,subLabel=映射成员名(空名降级 #id)+ 会签/时限后缀
|
||||
assertEquals("人事处审批", n1.getLabel());
|
||||
assertEquals("张三、#12 · 时限48h", n1.getSubLabel());
|
||||
}
|
||||
|
||||
// ==================== C4:flowGuid 分支取版与参数校验 ====================
|
||||
|
||||
@Test
|
||||
void c4_byGuid_returnsHighestPublishedDef() {
|
||||
FlwDef def = def(DEPT_DEF_JSON);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
SCH, "g1", FlwEnums.DefStatus.PUBLISHED.getCode()))
|
||||
.thenReturn(Optional.of(def));
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
FlowModels.DiagramVo vo = service.byGuid(SCH, "g1", null);
|
||||
assertEquals("教师入职", vo.getName());
|
||||
assertEquals(Integer.valueOf(2), vo.getVersion());
|
||||
assertEquals("auto", vo.getMode());
|
||||
assertNull(vo.getInstanceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c4_byGuid_noPublished_throwsBizException() {
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
anyLong(), anyString(), anyInt()))
|
||||
.thenReturn(Optional.empty());
|
||||
BizException e = assertThrows(BizException.class, () -> service.byGuid(SCH, "g1", "dept"));
|
||||
assertEquals("流程不存在或未发布", e.getMessage());
|
||||
assertEquals(ErrorCode.PARAM_ERROR, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c4_byGuid_crossSchoolDef_forbidden() {
|
||||
FlwDef other = def(DEPT_DEF_JSON);
|
||||
other.setSchId(888888L);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
anyLong(), anyString(), anyInt()))
|
||||
.thenReturn(Optional.of(other));
|
||||
BizException e = assertThrows(BizException.class, () -> service.byGuid(SCH, "g1", "dept"));
|
||||
assertEquals("跨校不可查看", e.getMessage());
|
||||
assertEquals(ErrorCode.FORBIDDEN, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c4_invalidMode_rejected() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.build(def(DEPT_DEF_JSON), null, "xxx"));
|
||||
assertEquals("mode 仅支持 dept/user", e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c4_byInstance_usesDefSnapshotOfInstance() {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(100L);
|
||||
instance.setSchId(SCH);
|
||||
instance.setDefId(12L);
|
||||
when(defRepository.findById(12L)).thenReturn(Optional.of(def(DEPT_DEF_JSON)));
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
FlowModels.DiagramVo vo = service.byInstance(instance, "dept");
|
||||
assertEquals(Long.valueOf(100L), vo.getInstanceId());
|
||||
assertEquals(Long.valueOf(12L), vo.getDefId());
|
||||
assertEquals(Integer.valueOf(2), vo.getVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c6_build_draftDef_ok() {
|
||||
FlwDef draft = def(DEPT_DEF_JSON);
|
||||
draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
FlowModels.DiagramVo vo = service.build(draft, null, "auto");
|
||||
assertEquals(Integer.valueOf(0), vo.getStatus());
|
||||
assertEquals(5, vo.getNodes().size());
|
||||
}
|
||||
|
||||
// ==================== C5:用户面 /api/flow/diagram 控制器守卫 ====================
|
||||
|
||||
@Nested
|
||||
class UserDiagramEndpoint {
|
||||
|
||||
private FlowQueryService queryService;
|
||||
private FlowTaskService taskService;
|
||||
private FlowInstanceService instanceService;
|
||||
private FlowUserController controller;
|
||||
private FlowDiagramService diagramService;
|
||||
|
||||
@BeforeEach
|
||||
void setUpController() {
|
||||
queryService = mock(FlowQueryService.class);
|
||||
taskService = mock(FlowTaskService.class);
|
||||
instanceService = mock(FlowInstanceService.class);
|
||||
diagramService = mock(FlowDiagramService.class);
|
||||
// 真守卫(同 FlowAccessGuardTest 口径):提交人/办理人/本校管理员
|
||||
FlowAccessGuard accessGuard = new FlowAccessGuard(taskRepository);
|
||||
controller = new FlowUserController(queryService, taskService, instanceService,
|
||||
accessGuard, diagramService);
|
||||
}
|
||||
|
||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||
BoaUser u = mock(BoaUser.class, RETURNS_DEEP_STUBS);
|
||||
when(u.getId()).thenReturn(id);
|
||||
when(u.getType()).thenReturn(type);
|
||||
when(u.getRoleIds()).thenReturn(roleIds.length == 0
|
||||
? null : new ArrayList<>(Arrays.asList(roleIds)));
|
||||
when(u.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
return u;
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_flowGuid_sameSchoolUser_ok() {
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
when(diagramService.byGuid(SCH, "g1", "dept")).thenReturn(vo);
|
||||
assertSame(vo, controller.diagram(user(11, 2, SCH.intValue(), 9), "g1", null, "dept"));
|
||||
verify(diagramService).byGuid(SCH, "g1", "dept");
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_flowGuid_notLoggedIn_forbidden() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> controller.diagram(null, "g1", null, null));
|
||||
assertEquals("未登录", e.getMessage());
|
||||
assertEquals(ErrorCode.FORBIDDEN, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_noParam_paramError() {
|
||||
assertThrows(BizException.class,
|
||||
() -> controller.diagram(user(11, 2, SCH.intValue(), 9), null, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_instance_submitter_ok() {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(100L);
|
||||
instance.setSchId(SCH);
|
||||
instance.setSubmitterId(11);
|
||||
when(instanceService.requireInstance(SCH, 100L)).thenReturn(instance);
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
when(diagramService.byInstance(instance, "dept")).thenReturn(vo);
|
||||
assertSame(vo, controller.diagram(user(11, 2, SCH.intValue(), 9), null, 100L, "dept"));
|
||||
verify(diagramService).byInstance(instance, "dept");
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_instance_assignee_ok() {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(100L);
|
||||
instance.setSchId(SCH);
|
||||
instance.setSubmitterId(11);
|
||||
FlwTask task = new FlwTask();
|
||||
task.setAssigneeId(22);
|
||||
when(instanceService.requireInstance(SCH, 100L)).thenReturn(instance);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(100L))
|
||||
.thenReturn(Collections.singletonList(task));
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
when(diagramService.byInstance(instance, "dept")).thenReturn(vo);
|
||||
assertSame(vo, controller.diagram(user(22, 2, SCH.intValue(), 9), null, 100L, "dept"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void c5_instance_unrelatedUser_forbidden() {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(100L);
|
||||
instance.setSchId(SCH);
|
||||
instance.setSubmitterId(11);
|
||||
when(instanceService.requireInstance(SCH, 100L)).thenReturn(instance);
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> controller.diagram(user(36, 2, SCH.intValue(), 9), null, 100L, "dept"));
|
||||
assertEquals("仅提交人/办理人/本校管理员可查看该办件", e.getMessage());
|
||||
verify(diagramService, never()).byInstance(any(), any());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== C6:管理面 /api/flow/admin/def/diagram ====================
|
||||
|
||||
@Nested
|
||||
class AdminDefDiagramEndpoint {
|
||||
|
||||
private FlowAdminController controller;
|
||||
private FlowDefAdminService defAdminService;
|
||||
private FlowDiagramService diagramService;
|
||||
|
||||
@BeforeEach
|
||||
void setUpController() {
|
||||
defAdminService = mock(FlowDefAdminService.class);
|
||||
diagramService = mock(FlowDiagramService.class);
|
||||
// 真管理守卫(纯逻辑无依赖)+ 其余依赖 mock
|
||||
controller = new FlowAdminController(new FlowAdminGuard(), defAdminService,
|
||||
mock(FlowMapAdminService.class), mock(FlowCallbackAdminService.class),
|
||||
mock(FlwInstanceRepository.class), diagramService);
|
||||
}
|
||||
|
||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||
BoaUser u = mock(BoaUser.class, RETURNS_DEEP_STUBS);
|
||||
when(u.getId()).thenReturn(id);
|
||||
when(u.getType()).thenReturn(type);
|
||||
when(u.getRoleIds()).thenReturn(roleIds.length == 0
|
||||
? null : new ArrayList<>(Arrays.asList(roleIds)));
|
||||
when(u.getCurrentRole().getSchId()).thenReturn(schId);
|
||||
return u;
|
||||
}
|
||||
|
||||
@Test
|
||||
void c6_admin_draftDef_ok() {
|
||||
FlwDef draft = def(DEPT_DEF_JSON);
|
||||
draft.setId(55L);
|
||||
draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
when(defAdminService.get(SCH, 55L)).thenReturn(draft);
|
||||
FlowModels.DiagramVo vo = new FlowModels.DiagramVo();
|
||||
when(diagramService.build(draft, null, "auto")).thenReturn(vo);
|
||||
assertSame(vo, controller.defDiagram(user(1, 1, SCH.intValue()), 55L, "auto"));
|
||||
verify(diagramService).build(draft, null, "auto");
|
||||
}
|
||||
|
||||
@Test
|
||||
void c6_nonAdmin_forbidden() {
|
||||
BoaUser ordinary = user(77, 2, SCH.intValue(), 9);
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> controller.defDiagram(ordinary, 55L, "auto"));
|
||||
assertEquals("无流程管理权限", e.getMessage());
|
||||
verify(defAdminService, never()).get(any(), anyLong());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.yunhai.flowcenter.stub;
|
||||
|
||||
import com.yunhai.platform.openapi.client.OpenApiUserClient;
|
||||
import com.yunhai.platform.openapi.model.SysUserGroupDeleteItem;
|
||||
import com.yunhai.platform.openapi.model.SysUserGroupStateRequest;
|
||||
import com.yunhai.platform.openapi.model.SysUserMoveRequest;
|
||||
import com.yunhai.platform.openapi.model.SysUserRequest;
|
||||
import com.yunhai.platform.openapi.model.SysUserTransferSchoolRequest;
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaModule;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* UserBase 客户端测试 stub:getUserById/getUserInfo 返回 school=1(测试学校)的用户,
|
||||
* 供 OapiFlowService submitterId 本校校验(测试统一用 schId=1);其余端点不参与 M1 测试。
|
||||
*/
|
||||
public class StubUserClient implements OpenApiUserClient {
|
||||
|
||||
public static final long TEST_SCH_ID = 1L;
|
||||
|
||||
@Override
|
||||
public BoaUser getUserById(String userId) {
|
||||
return buildUser(Integer.valueOf(userId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getUserInfo(int userId) {
|
||||
return buildUser(userId);
|
||||
}
|
||||
|
||||
private static BoaUser buildUser(int userId) {
|
||||
BoaUser user = new BoaUser(userId, "user" + userId, "用户" + userId);
|
||||
user.setSchool(new BoaGroup((int) TEST_SCH_ID, "测试学校"));
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getUserByLoginName(String loginName) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getUserByPhone(String phone) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getUserInfo(String loginName) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BoaModule> getUserModule(int userId, Integer schoolId) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser addUser(SysUserRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser editUser(int id, SysUserRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteUser(int id, int groupId, int schoolId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteUsers(List<SysUserGroupDeleteItem> items) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BoaUser> batchAddUsers(List<SysUserRequest> requests) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser moveUserToGroup(SysUserMoveRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BoaRoleInfo> getUserRoleList(int userId) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaRoleInfo getCurrentIdentity(int userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaRoleInfo switchIdentity(int userId, int roleId, int schoolId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUserPassword(int userId, String oldPassword, String newPassword) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String updateUserProfilePicture(int userId, byte[] fileBytes, String filename) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUserGroupState(SysUserGroupStateRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getUserGroupState(int userId, int groupId, int schoolId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser transferUserToSchool(SysUserTransferSchoolRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changeUserStatus(int userId, boolean disabled) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void batchChangeUserStatus(List<Integer> userIds, boolean disabled) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.yunhai.flowcenter.support;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
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 org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* IT 公共基类:专用测试库 bsch_flowcenter_test,每例清表(引擎测试不能开测试事务——
|
||||
* AFTER_COMMIT 推进监听器依赖真实提交);提供定义落库/发布、映射注册的快捷方法。
|
||||
*/
|
||||
public abstract class FlowTestSupport {
|
||||
|
||||
public static final long SCH = 1L;
|
||||
|
||||
@Autowired
|
||||
protected FlwDefRepository defRepository;
|
||||
@Autowired
|
||||
protected FlwInstanceRepository instanceRepository;
|
||||
@Autowired
|
||||
protected FlwTaskRepository taskRepository;
|
||||
@Autowired
|
||||
protected FlwAssigneeMapRepository mapRepository;
|
||||
@Autowired
|
||||
protected FlwCallbackRegRepository regRepository;
|
||||
@Autowired
|
||||
protected FlwCallbackLogRepository logRepository;
|
||||
|
||||
protected void cleanDb() {
|
||||
logRepository.deleteAll();
|
||||
regRepository.deleteAll();
|
||||
taskRepository.deleteAll();
|
||||
instanceRepository.deleteAll();
|
||||
defRepository.deleteAll();
|
||||
mapRepository.deleteAll();
|
||||
}
|
||||
|
||||
protected FlwAssigneeMap mapDept(String deptKey, Integer userId, String userName) {
|
||||
FlwAssigneeMap map = new FlwAssigneeMap();
|
||||
map.setSchId(SCH);
|
||||
map.setDeptKey(deptKey);
|
||||
map.setDeptName(deptKey + "科");
|
||||
map.setUserId(userId);
|
||||
map.setUserName(userName);
|
||||
return mapRepository.save(map);
|
||||
}
|
||||
|
||||
protected FlwDef saveAndPublishDef(String code, String name, String contentJson) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase());
|
||||
def.setCode(code);
|
||||
def.setName(name);
|
||||
def.setVersion(1);
|
||||
def.setStatus(1);
|
||||
def.setPublishTime(java.time.LocalDateTime.now());
|
||||
def.setContentJson(contentJson);
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
/** 线性流程:START → deptA 审批(ALL 会签)→ END */
|
||||
protected String linearDef(String deptKey, String nodeName) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"线性测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"" + nodeName + "\","
|
||||
+ "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptKey + "\"},\"countersign\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/** 并行流程:START → split → 两分支(deptA/deptB)→ join → END */
|
||||
protected String parallelDef(String deptA, String deptB) {
|
||||
return "{"
|
||||
+ "\"code\":\"" + codeOf() + "\",\"name\":\"并行测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
|
||||
+ "{\"id\":\"t_a\",\"type\":\"APPROVAL\",\"name\":\"甲科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
|
||||
+ "{\"id\":\"t_b\",\"type\":\"APPROVAL\",\"name\":\"乙科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
|
||||
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
|
||||
+ "{\"from\":\"split1\",\"to\":\"t_a\"},{\"from\":\"split1\",\"to\":\"t_b\"},"
|
||||
+ "{\"from\":\"t_a\",\"to\":\"join1\"},{\"from\":\"t_b\",\"to\":\"join1\"},"
|
||||
+ "{\"from\":\"join1\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
private static String codeOf() {
|
||||
return "tc_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.yunhai.flowcenter.support;
|
||||
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
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.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* oapi 契约测试客户端:手工三件套签名(X-App-Key/X-Timestamp/X-Signature,
|
||||
* SignUtil.signRequest 请求级 canonical——与服务端 AppAuthFilter 验签同款)。
|
||||
* 故意不用共享 IDHttp:测试要能看到 401 原始响应。
|
||||
*/
|
||||
public class OapiSignedClient {
|
||||
|
||||
private final String baseUrl;
|
||||
private final String appKey;
|
||||
private final String appSecret;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public OapiSignedClient(String baseUrl, String appKey, String appSecret) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
this.appKey = appKey;
|
||||
this.appSecret = appSecret;
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(3000);
|
||||
factory.setReadTimeout(5000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
// 4xx/5xx 不抛异常,返回原始响应供断言(401/500 契约用例需要拿到状态码)
|
||||
this.restTemplate.setErrorHandler(new org.springframework.web.client.ResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public <T> ResponseEntity<T> get(String pathAndQuery, Class<T> type) {
|
||||
return exchange(HttpMethod.GET, pathAndQuery, null, type);
|
||||
}
|
||||
|
||||
public <T> ResponseEntity<T> post(String pathAndQuery, Object body, Class<T> type) {
|
||||
return exchange(HttpMethod.POST, pathAndQuery, body, type);
|
||||
}
|
||||
|
||||
private <T> ResponseEntity<T> exchange(HttpMethod method, String pathAndQuery, Object body, Class<T> type) {
|
||||
String target = signTarget(pathAndQuery);
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest(method.name(), target, ts, appSecret);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", sign);
|
||||
if (body != null) {
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, method,
|
||||
body == null ? new HttpEntity<>(headers) : new HttpEntity<>(body, headers), type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与服务端对齐的签名 target:path + "?" + 排序后的 query(SignCanonical.sortRawQuery 语义,
|
||||
* 单参数场景天然有序;多参数时按 key 字典序拼接)。
|
||||
*/
|
||||
private static String signTarget(String pathAndQuery) {
|
||||
int q = pathAndQuery.indexOf('?');
|
||||
if (q < 0) {
|
||||
return pathAndQuery;
|
||||
}
|
||||
String path = pathAndQuery.substring(0, q);
|
||||
String[] pairs = pathAndQuery.substring(q + 1).split("&");
|
||||
java.util.Arrays.sort(pairs);
|
||||
StringBuilder sb = new StringBuilder(path);
|
||||
for (String pair : pairs) {
|
||||
if (sb.indexOf("?") < 0) {
|
||||
sb.append("?");
|
||||
} else if (sb.charAt(sb.length() - 1) != '?') {
|
||||
sb.append("&");
|
||||
}
|
||||
sb.append(pair);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 无签名调用(401 用例) */
|
||||
public ResponseEntity<String> getRaw(String pathAndQuery) {
|
||||
return restTemplate.getForEntity(baseUrl + pathAndQuery, String.class);
|
||||
}
|
||||
|
||||
/** 带错误密钥的调用(401 用例) */
|
||||
public ResponseEntity<String> getWithBadSecret(String pathAndQuery) {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String sign = SignUtil.signRequest("GET", signTarget(pathAndQuery), ts, "bad-secret-000000000000");
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", sign);
|
||||
return restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.GET,
|
||||
new HttpEntity<>(headers), String.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# 测试环境覆盖(@ActiveProfiles("test")):
|
||||
# 库指向 bsch_flowcenter_test(ddl-auto=update 建表);UserMsg 通知关闭(无需 UserMsg 在跑)
|
||||
spring:
|
||||
jpa:
|
||||
show-sql: true
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/bsch_flowcenter_test?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
|
||||
# IT 保持确定性:不开中央密钥链(trusted-apps 白名单即够),不依赖 Redis
|
||||
yh:
|
||||
app-auth:
|
||||
trust-registry: false
|
||||
|
||||
flow-center:
|
||||
usermsg:
|
||||
enabled: false
|
||||
cors:
|
||||
allowed-origins: ""
|
||||
callback:
|
||||
# 挂起/死信告警接收人(IT 用计数版 @Primary UserMsgClient 截获,不真正外发)
|
||||
alert-user-ids: "999"
|
||||
Reference in New Issue
Block a user