From c1103aeb29396d09ec038b718e5e314c728994c3 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 15 Sep 2026 10:42:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=90=8E=E7=AB=AF=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring Boot 流程引擎服务:流程定义/实例/任务、审批人解析、 回调分发、oapi 接口及单元测试。 保留远程 .gitea/workflows/build.yml 与 docker/Dockerfile, 新增 .gitignore 排除 target/ 构建产物。 --- .gitignore | 19 + pom.xml | 110 ++++ .../flowcenter/FlowCenterApplication.java | 20 + .../callback/CallbackDispatcher.java | 165 ++++++ .../flowcenter/callback/CallbackLogStore.java | 27 + .../callback/CallbackProperties.java | 38 ++ .../callback/CallbackPushService.java | 243 +++++++++ .../common/FlowCenterAuditListener.java | 41 ++ .../config/FlowCorsConfiguration.java | 46 ++ .../controller/api/FlowAdminController.java | 201 +++++++ .../controller/api/FlowBaseController.java | 49 ++ .../controller/api/FlowUserController.java | 146 ++++++ .../controller/oapi/OapiFlowController.java | 64 +++ .../yunhai/flowcenter/engine/AssigneeRef.java | 12 + .../flowcenter/engine/AssigneeResolver.java | 64 +++ .../flowcenter/engine/DefValidator.java | 184 +++++++ .../flowcenter/engine/FlowAdvanceService.java | 62 +++ .../flowcenter/engine/FlowDefModel.java | 138 +++++ .../yunhai/flowcenter/engine/FlowEngine.java | 252 +++++++++ .../yunhai/flowcenter/engine/FlowEvent.java | 68 +++ .../engine/FlowNodeAdvanceListener.java | 45 ++ .../yunhai/flowcenter/entity/BaseEntity.java | 44 ++ .../flowcenter/entity/FlwAssigneeMap.java | 48 ++ .../flowcenter/entity/FlwAttachment.java | 50 ++ .../yunhai/flowcenter/entity/FlwBranch.java | 35 ++ .../flowcenter/entity/FlwCallbackLog.java | 82 +++ .../flowcenter/entity/FlwCallbackReg.java | 52 ++ .../com/yunhai/flowcenter/entity/FlwDef.java | 67 +++ .../yunhai/flowcenter/entity/FlwInstance.java | 111 ++++ .../com/yunhai/flowcenter/entity/FlwTask.java | 71 +++ .../com/yunhai/flowcenter/enums/FlwEnums.java | 85 +++ .../yunhai/flowcenter/model/FlowModels.java | 115 ++++ .../yunhai/flowcenter/model/OapiModels.java | 84 +++ .../flowcenter/notify/UserMsgClient.java | 115 ++++ .../flowcenter/notify/UserMsgNotifier.java | 142 +++++ .../repository/FlwAssigneeMapRepository.java | 22 + .../repository/FlwAttachmentRepository.java | 11 + .../repository/FlwBranchRepository.java | 27 + .../repository/FlwCallbackLogRepository.java | 38 ++ .../repository/FlwCallbackRegRepository.java | 17 + .../repository/FlwDefRepository.java | 43 ++ .../repository/FlwInstanceRepository.java | 32 ++ .../repository/FlwTaskRepository.java | 51 ++ .../flowcenter/service/FlowAccessGuard.java | 68 +++ .../flowcenter/service/FlowAdminGuard.java | 54 ++ .../service/FlowCallbackAdminService.java | 146 ++++++ .../service/FlowDefAdminService.java | 161 ++++++ .../service/FlowDiagramService.java | 242 +++++++++ .../service/FlowInstanceService.java | 224 ++++++++ .../service/FlowMapAdminService.java | 85 +++ .../flowcenter/service/FlowQueryService.java | 213 ++++++++ .../flowcenter/service/FlowTaskService.java | 145 +++++ .../flowcenter/service/OapiFlowService.java | 288 ++++++++++ src/main/resources/application.yml | 120 +++++ .../callback/CallbackDispatchIT.java | 449 ++++++++++++++++ .../callback/CallbackPropertiesTest.java | 24 + .../flowcenter/engine/DefValidatorTest.java | 152 ++++++ .../flowcenter/engine/FlowEngineIT.java | 266 ++++++++++ .../flowcenter/oapi/OapiFlowContractIT.java | 179 +++++++ .../service/FlowAccessGuardTest.java | 97 ++++ .../service/FlowDiagramServiceTest.java | 495 ++++++++++++++++++ .../flowcenter/stub/StubUserClient.java | 140 +++++ .../flowcenter/support/FlowTestSupport.java | 106 ++++ .../flowcenter/support/OapiSignedClient.java | 108 ++++ src/test/resources/application-test.yml | 21 + 65 files changed, 7119 insertions(+) create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/java/com/yunhai/flowcenter/FlowCenterApplication.java create mode 100644 src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java create mode 100644 src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java create mode 100644 src/main/java/com/yunhai/flowcenter/callback/CallbackProperties.java create mode 100644 src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java create mode 100644 src/main/java/com/yunhai/flowcenter/common/FlowCenterAuditListener.java create mode 100644 src/main/java/com/yunhai/flowcenter/config/FlowCorsConfiguration.java create mode 100644 src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java create mode 100644 src/main/java/com/yunhai/flowcenter/controller/api/FlowBaseController.java create mode 100644 src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java create mode 100644 src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/AssigneeRef.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/DefValidator.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java create mode 100644 src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/BaseEntity.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwAssigneeMap.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwAttachment.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwBranch.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwDef.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java create mode 100644 src/main/java/com/yunhai/flowcenter/entity/FlwTask.java create mode 100644 src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java create mode 100644 src/main/java/com/yunhai/flowcenter/model/FlowModels.java create mode 100644 src/main/java/com/yunhai/flowcenter/model/OapiModels.java create mode 100644 src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java create mode 100644 src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwAssigneeMapRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwAttachmentRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwBranchRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwCallbackRegRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowAccessGuard.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowAdminGuard.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java create mode 100644 src/test/java/com/yunhai/flowcenter/callback/CallbackPropertiesTest.java create mode 100644 src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java create mode 100644 src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java create mode 100644 src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java create mode 100644 src/test/java/com/yunhai/flowcenter/service/FlowAccessGuardTest.java create mode 100644 src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java create mode 100644 src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java create mode 100644 src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java create mode 100644 src/test/java/com/yunhai/flowcenter/support/OapiSignedClient.java create mode 100644 src/test/resources/application-test.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fb1f62 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8c13f95 --- /dev/null +++ b/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + flow-center + 0.1 + flow-center + 流程中心(纯流程引擎:定义/引擎/任务/办件/回调分发;表单归办事大厅,本应用托管模式接入) + + 1.8 + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + flow-center + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.flowcenter.FlowCenterApplication + + + + + repackage + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Test.java + **/*IT.java + + + + + + diff --git a/src/main/java/com/yunhai/flowcenter/FlowCenterApplication.java b/src/main/java/com/yunhai/flowcenter/FlowCenterApplication.java new file mode 100644 index 0000000..06f5b80 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/FlowCenterApplication.java @@ -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); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java new file mode 100644 index 0000000..d57fe52 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java @@ -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 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 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 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 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; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java new file mode 100644 index 0000000..2ac94df --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java @@ -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(); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackProperties.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackProperties.java new file mode 100644 index 0000000..295401a --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackProperties.java @@ -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 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 = ""; +} diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java new file mode 100644 index 0000000..b5f30e2 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java @@ -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 + * 持续重试不终止(T1.1:消费方只承诺"瞬时失败回 5xx",恢复后自动追平,消费方免对账); + * 挂起(retryCount 首达 hang-alert-retries)时 UserMsg 告警恰一次; + * 死信(status=2)仅两个入口——注册停用、管理端手工标记,重试次数永不自动触发。 + * 推送一律在新事务里落库(AFTER_COMMIT 监听器/调度线程均无外层事务)。 + * 推送本身与落库解耦:payload_json 是完整快照,可重放。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CallbackPushService { + + private static final Set 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 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()} + * 封顶继续排期,绝不自动死信;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 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 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 parseAlertUserIds() { + java.util.List 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 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; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/common/FlowCenterAuditListener.java b/src/main/java/com/yunhai/flowcenter/common/FlowCenterAuditListener.java new file mode 100644 index 0000000..0a4ef3e --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/common/FlowCenterAuditListener.java @@ -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(); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/config/FlowCorsConfiguration.java b/src/main/java/com/yunhai/flowcenter/config/FlowCorsConfiguration.java new file mode 100644 index 0000000..d36b5fa --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/config/FlowCorsConfiguration.java @@ -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 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 bean = new FilterRegistrationBean<>(new CorsFilter(source)); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return bean; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java new file mode 100644 index 0000000..c414625 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -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 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 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 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 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 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 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 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 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 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 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 logIds) { + callbackAdminService.rePush(adminGuard.requireAdmin(user, null), logIds); + } + + // ==================== 办件查询 ==================== + + @GetMapping("/instance/page") + public BasePageResult 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 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()); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowBaseController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowBaseController.java new file mode 100644 index 0000000..3c9d473 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowBaseController.java @@ -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> getUserMod() { + List> modList = new ArrayList<>(); + for (String[] m : MENUS) { + Map 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; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java new file mode 100644 index 0000000..880e828 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java @@ -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 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 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 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 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 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()); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java new file mode 100644 index 0000000..32542ce --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java @@ -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 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); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/AssigneeRef.java b/src/main/java/com/yunhai/flowcenter/engine/AssigneeRef.java new file mode 100644 index 0000000..cf6bb5d --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/AssigneeRef.java @@ -0,0 +1,12 @@ +package com.yunhai.flowcenter.engine; + +import lombok.Value; + +/** + * 办理人解析结果(id+姓名快照),任务创建与通知共用。 + */ +@Value +public class AssigneeRef { + Integer userId; + String userName; +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java new file mode 100644 index 0000000..da54648 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java @@ -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 resolve(Long schId, FlowDefModel.Node node) { + FlowDefModel.Assignee a = node.assignee; + if (a == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少办理人配置:" + node.id); + } + List refs = new ArrayList<>(); + if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { + List 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 ""; + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java new file mode 100644 index 0000000..9e3bf54 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java @@ -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 enabledDeptKeys) { + List problems = new ArrayList<>(); + + if (model.nodes == null || model.nodes.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点"); + } + // 节点 id 唯一且非空 + Set 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 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 visited = new HashSet<>(); + Set 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 enabledDeptKeys, List 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 visited, + Set inStack, List 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) + "]"; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java b/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java new file mode 100644 index 0000000..83a14e3 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java @@ -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; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java new file mode 100644 index 0000000..55b32c1 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java @@ -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 nodes; + public List 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 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 outEdges(String nodeId) { + java.util.List out = new java.util.ArrayList<>(); + for (Edge e : edges) { + if (nodeId != null && nodeId.equals(e.from)) { + out.add(e); + } + } + return out; + } + + public List inEdges(String nodeId) { + java.util.List in = new java.util.ArrayList<>(); + for (Edge e : edges) { + if (nodeId != null && nodeId.equals(e.to)) { + in.add(e); + } + } + return in; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java new file mode 100644 index 0000000..eeb615a --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java @@ -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 解耦通知/回调),不感知任何业务方。 + * + *

推进算法(递归激活,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 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 visited = new HashSet<>(); + Deque 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 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 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 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 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 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); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java new file mode 100644 index 0000000..8898ec5 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java @@ -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 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 data) { + return new FlowEvent(type, instanceId, taskId, data == null ? new HashMap<>() : data); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java new file mode 100644 index 0000000..6efb90f --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java @@ -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); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/BaseEntity.java b/src/main/java/com/yunhai/flowcenter/entity/BaseEntity.java new file mode 100644 index 0000000..7953d3f --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/BaseEntity.java @@ -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; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwAssigneeMap.java b/src/main/java/com/yunhai/flowcenter/entity/FlwAssigneeMap.java new file mode 100644 index 0000000..cd75141 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwAssigneeMap.java @@ -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; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwAttachment.java b/src/main/java/com/yunhai/flowcenter/entity/FlwAttachment.java new file mode 100644 index 0000000..e220fbb --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwAttachment.java @@ -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; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwBranch.java b/src/main/java/com/yunhai/flowcenter/entity/FlwBranch.java new file mode 100644 index 0000000..f36c694 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwBranch.java @@ -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(); +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java new file mode 100644 index 0000000..0b89667 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java @@ -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; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java new file mode 100644 index 0000000..76a18b0 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java @@ -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; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java new file mode 100644 index 0000000..871fc5c --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java @@ -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; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java new file mode 100644 index 0000000..5542d37 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java @@ -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 重启语义)。 + * + *

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; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java b/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java new file mode 100644 index 0000000..6012684 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwTask.java @@ -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; +} diff --git a/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java new file mode 100644 index 0000000..1f52ef7 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java @@ -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; + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/model/FlowModels.java b/src/main/java/com/yunhai/flowcenter/model/FlowModels.java new file mode 100644 index 0000000..0a93751 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/model/FlowModels.java @@ -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> trace; + private List> branches; + /** 当前用户可否办理(存在本人的待办任务) */ + private boolean myPendingTask; + private List 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 nodes; + private List 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; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java new file mode 100644 index 0000000..c7c1dab --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -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 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 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; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java b/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java new file mode 100644 index 0000000..b301ca3 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/notify/UserMsgClient.java @@ -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 手工三件套路线的直达版): + * 经共享 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 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 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 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 userIds; + private Integer count; + private Integer schId; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java new file mode 100644 index 0000000..8c0df6d --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java @@ -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; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwAssigneeMapRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwAssigneeMapRepository.java new file mode 100644 index 0000000..70c663e --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwAssigneeMapRepository.java @@ -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 { + + /** DEPT_KEY 解析:启用映射的办理人列表(概览 §六) */ + @Query("select m from FlwAssigneeMap m where m.schId = :schId and m.deptKey = :deptKey and m.status = 0") + List findEnabled(@Param("schId") Long schId, @Param("deptKey") String deptKey); + + /** 发布校验用:本校全部启用映射 */ + List findEnabledBySchId(Long schId); + + List findBySchIdAndDeptKeyOrderByUserIdAsc(Long schId, String deptKey); + + FlwAssigneeMap findBySchIdAndDeptKeyAndUserId(Long schId, String deptKey, Integer userId); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwAttachmentRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwAttachmentRepository.java new file mode 100644 index 0000000..8fb37ae --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwAttachmentRepository.java @@ -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 { + + List findByInstanceIdOrderByIdAsc(Long instanceId); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwBranchRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwBranchRepository.java new file mode 100644 index 0000000..eff5fb7 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwBranchRepository.java @@ -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 { + + List findByInstanceId(Long instanceId); + + Optional 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); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java new file mode 100644 index 0000000..f442421 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java @@ -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 { + + /** 重推扫描主路径:待推/重试中且到点 */ + List 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 pageLogs(@Param("schId") Long schId, @Param("status") Integer status, + @Param("event") String event, @Param("instanceId") Long instanceId, + Pageable pageable); + + /** 死信列表 */ + Page 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 pageHung(@Param("schId") Long schId, @Param("minRetry") int minRetry, Pageable pageable); + + List findByIdInAndSchId(Collection ids, Long schId); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackRegRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackRegRepository.java new file mode 100644 index 0000000..dda097a --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackRegRepository.java @@ -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 { + + Optional findBySchIdAndAppKeyAndFlowGuid(Long schId, String appKey, String flowGuid); + + /** CallbackDispatcher 匹配:该流程下所有启用注册(event 订阅在内存过滤,量小) */ + List findBySchIdAndFlowGuidAndStatus(Long schId, String flowGuid, Integer status); + + List findBySchIdOrderByUpdateTimeDesc(Long schId); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java new file mode 100644 index 0000000..01b8633 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java @@ -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 { + + Optional findBySchIdAndFlowGuidAndStatusAndVersionOrderByIdDesc(Long schId, String flowGuid, Integer status, Integer version); + + /** flow_guid 解析规则:该 (sch_id, flow_guid) 下已发布中 version 最大者(概览 §4.1) */ + Optional findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(Long schId, String flowGuid, Integer status); + + /** 任意状态(回调注册只要求流程线存在) */ + Optional findFirstBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid); + + /** 同名草稿(保存新建时复用,避免 uk 冲突) */ + Optional 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 findFirstBySchIdAndCodeOrderByVersionDesc(Long schId, String code); + + List findBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid); + + List findBySchIdAndStatusOrderByUpdateTimeDesc(Long schId, Integer status); + + List 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 findPublishedLatestPerGuid(@Param("schId") Long schId); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java new file mode 100644 index 0000000..c3a1a51 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java @@ -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 { + + /** biz uk 幂等主键 */ + Optional findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId); + + List 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 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 pageAdmin(@Param("schId") Long schId, @Param("status") Integer status, + @Param("keyword") String keyword, Pageable pageable); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java new file mode 100644 index 0000000..412ae35 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java @@ -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 { + + List findByInstanceIdOrderByIdAsc(Long instanceId); + + List findByInstanceIdAndStatus(Long instanceId, Integer status); + + List findByInstanceIdAndStatusIn(Long instanceId, List 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 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 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 findOverdue(@Param("now") LocalDateTime now, @Param("maxTimes") int maxTimes); +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowAccessGuard.java b/src/main/java/com/yunhai/flowcenter/service/FlowAccessGuard.java new file mode 100644 index 0000000..df522b7 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowAccessGuard.java @@ -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 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()); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowAdminGuard.java b/src/main/java/com/yunhai/flowcenter/service/FlowAdminGuard.java new file mode 100644 index 0000000..f47945c --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowAdminGuard.java @@ -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 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()); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java new file mode 100644 index 0000000..0c844cb --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java @@ -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 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 pageLogs(Long schId, Integer status, String event, + Long instanceId, Integer page, Integer size) { + Page 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 pageDead(Long schId, Integer page, Integer size) { + Page 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 pageHung(Long schId, Integer page, Integer size) { + Page 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 logIds) { + if (logIds == null || logIds.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择要重推的日志"); + } + List 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; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java new file mode 100644 index 0000000..74295dc --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -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 list(Long schId, Integer status) { + if (status != null) { + return defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status); + } + return defRepository.findBySchIdOrderByUpdateTimeDesc(schId); + } + + /** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */ + public List 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 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 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; + } + +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java new file mode 100644 index 0000000..45a504d --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDiagramService.java @@ -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 nodes = new ArrayList<>(); + for (FlowDefModel.Node n : model.nodes) { + nodes.add(toNodeVo(def.getSchId(), n, m)); + } + vo.setNodes(nodes); + List 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 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 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 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 names, List suffixes) { + List 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 suffixes(FlowDefModel.Node n) { + List 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 memberNames(List maps) { + List names = new ArrayList<>(); + for (FlwAssigneeMap m : maps) { + names.add(blankToId(m.getUserName(), m.getUserId())); + } + return names; + } + + /** USERS 定义直配人名(复用 OpenApiUserClient.getUserById 通道,失败/空名降级 #id 不抛错) */ + private List userNames(List userIds) { + List 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; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java new file mode 100644 index 0000000..53a01dd --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java @@ -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 重启重提)、撤回。 + * + *

R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿):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 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 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 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 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; + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java new file mode 100644 index 0000000..c7376df --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java @@ -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 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 ""; + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java b/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java new file mode 100644 index 0000000..a26258c --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowQueryService.java @@ -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 pageMy(Long schId, Integer submitterId, + Integer status, Integer page, Integer size) { + Page 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 pageTodo(Long schId, Integer assigneeId, + Integer page, Integer size) { + Page p = taskRepository.pageTodo(schId, assigneeId, + PageRequest.of(normPage(page), normSize(size))); + List items = p.getContent().stream() + .map(this::toTaskItem).collect(Collectors.toList()); + fillInstanceInfo(items); + return toResult(p.getTotalElements(), items, page, size); + } + + public BasePageResult pageDone(Long schId, Integer assigneeId, + Integer page, Integer size) { + Page p = taskRepository.pageDone(schId, assigneeId, + PageRequest.of(normPage(page), normSize(size))); + List 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 tasks = taskRepository.findByInstanceIdOrderByIdAsc(instanceId); + List> trace = new ArrayList<>(); + for (FlwTask t : tasks) { + Map 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> branches = new ArrayList<>(); + for (FlwBranch b : branchRepository.findByInstanceId(instanceId)) { + Map row = new HashMap<>(); + row.put("branchKey", b.getBranchKey()); + row.put("status", b.getStatus()); + branches.add(row); + } + detail.setBranches(branches); + List 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 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 BasePageResult toResult(long total, List 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); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java new file mode 100644 index 0000000..ca64cd7 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java @@ -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 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 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 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()); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java new file mode 100644 index 0000000..f463c16 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -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 listFlows(Long schId) { + List 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 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 tasks = + taskRepository.findByInstanceIdOrderByIdAsc(instance.getId()); + List> trace = new ArrayList<>(); + Set assigneeIds = new LinkedHashSet<>(); + for (com.yunhai.flowcenter.entity.FlwTask t : tasks) { + assigneeIds.add(t.getAssigneeId()); + Map 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 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(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..f6f97fa --- /dev/null +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java new file mode 100644 index 0000000..2a8346f --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java @@ -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 = 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 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 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 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); + } + } +} diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackPropertiesTest.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackPropertiesTest.java new file mode 100644 index 0000000..cd5011f --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackPropertiesTest.java @@ -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()); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java new file mode 100644 index 0000000..d45b646 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java @@ -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"))); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java new file mode 100644 index 0000000..49d8a1b --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java @@ -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 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 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 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 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 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 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 branchRows(Long instanceId) { + return branchRepository.findByInstanceId(instanceId); + } + + @Autowired + private com.yunhai.flowcenter.repository.FlwBranchRepository branchRepository; + + private List allTasks(Long instanceId) { + return taskRepository.findByInstanceIdOrderByIdAsc(instanceId); + } + + private List tasksOf(Long instanceId, Integer assigneeId) { + List out = new java.util.ArrayList<>(); + for (FlwTask t : taskRepository.findByInstanceIdAndStatus(instanceId, 0)) { + if (assigneeId.equals(t.getAssigneeId())) { + out.add(t); + } + } + return out; + } +} diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java new file mode 100644 index 0000000..9c26876 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java @@ -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 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 getResp = client.get( + "/oapi/flow/" + def.getFlowGuid() + "?schId=1", JsonNode.class); + assertEquals(def.getFlowGuid(), unwrap(getResp.getBody()).get("flowGuid").asText()); + + // 未发布 guid → data=null(消费方降级) + ResponseEntity 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 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 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 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 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 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 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); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowAccessGuardTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowAccessGuardTest.java new file mode 100644 index 0000000..52ed3ba --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlowAccessGuardTest.java @@ -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))); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java new file mode 100644 index 0000000..934d8ee --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDiagramServiceTest.java @@ -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()); + } + } +} diff --git a/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java b/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java new file mode 100644 index 0000000..82cb420 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java @@ -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 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 items) { + throw new UnsupportedOperationException(); + } + + @Override + public List batchAddUsers(List requests) { + throw new UnsupportedOperationException(); + } + + @Override + public BoaUser moveUserToGroup(SysUserMoveRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public List 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 userIds, boolean disabled) { + throw new UnsupportedOperationException(); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java new file mode 100644 index 0000000..4dfb61f --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java @@ -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); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/support/OapiSignedClient.java b/src/test/java/com/yunhai/flowcenter/support/OapiSignedClient.java new file mode 100644 index 0000000..efbf8a4 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/support/OapiSignedClient.java @@ -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 ResponseEntity get(String pathAndQuery, Class type) { + return exchange(HttpMethod.GET, pathAndQuery, null, type); + } + + public ResponseEntity post(String pathAndQuery, Object body, Class type) { + return exchange(HttpMethod.POST, pathAndQuery, body, type); + } + + private ResponseEntity exchange(HttpMethod method, String pathAndQuery, Object body, Class 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 getRaw(String pathAndQuery) { + return restTemplate.getForEntity(baseUrl + pathAndQuery, String.class); + } + + /** 带错误密钥的调用(401 用例) */ + public ResponseEntity 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); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..ac07ccc --- /dev/null +++ b/src/test/resources/application-test.yml @@ -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"