Files
flow-center-service/docs/evidence/core-fix-20261027/baseline-tracked.patch
T
chenxing 302216a07c
build / build-push (push) Successful in 2m0s
fix(flow): 修正线级停发/恢复守卫取反缺陷 + 真实 Service 定向回归
requireLine() 存在性判断取反,导致已存在的流程线被拒绝、不存在的线反被放行,
线级停发/恢复线上 100% 失败。改为查询为空时拒绝。

- FlowDefAdminService.java:302 补 ! 号(唯一生产代码改动)
- FlDefLineOfflineTest 改用真实 FlowDefAdminService(删除业务副本 spy),补非法 GUID/空值/空白/错误学校/trim/在途隔离断言
- FlNewInstancePathTest 新增新实例路径定向测试
2026-10-28 03:18:43 +08:00

5236 lines
316 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index 62261d1..e529d58 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -16,6 +16,10 @@ on:
description: '构建前清空 maven m2 缓存'
required: false
default: 'false'
+ tests_only:
+ description: '只验证测试和 JAR,不构建推送镜像或通知建档'
+ required: false
+ default: 'true'
jobs:
build-push:
@@ -36,6 +40,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
+ git -C /tmp/src fetch --depth 1 origin "$GITHUB_SHA"
+ git -C /tmp/src checkout --detach FETCH_HEAD
cp -a /tmp/src/. .
rm -rf /tmp/src
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
@@ -64,6 +70,7 @@ jobs:
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
+ trap 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true' EXIT
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \
@@ -71,13 +78,33 @@ jobs:
-w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
- docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
+ # 为真实 MySQL 测试创建本次 job 独占的临时数据库;不映射宿主端口、不连接业务库。
+ DB_CONTAINER="${BUILD_CONTAINER}-db"
+ trap 'docker rm -f "$BUILD_CONTAINER" "$DB_CONTAINER" >/dev/null 2>&1 || true' EXIT
+ export MYSQL_ROOT_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
+ export MYSQL_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
+ docker create --name "$DB_CONTAINER" --network infra-infra-net \
+ -e MYSQL_DATABASE=flowcenter_ci -e MYSQL_USER=flow_ci \
+ -e MYSQL_PASSWORD -e MYSQL_ROOT_PASSWORD mysql:8.0 --skip-log-bin >/dev/null
+ docker cp "${PWD}/ci/flow-test-schema.sql" "$DB_CONTAINER":/docker-entrypoint-initdb.d/001-schema.sql
+ docker start "$DB_CONTAINER" >/dev/null
+ ready=0
+ for n in $(seq 1 60); do
+ if docker exec "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -h127.0.0.1 -u"$MYSQL_USER" "$MYSQL_DATABASE" -Nse "SELECT 1" >/dev/null 2>&1'; then ready=1; break; fi
+ sleep 2
+ done
+ [ "$ready" -eq 1 ] || { echo 'Isolated CI MySQL did not become ready' >&2; exit 1; }
+ export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
+ export CI_TEST_DB_USER=flow_ci CI_TEST_DB_PASSWORD="$MYSQL_PASSWORD"
+ docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \
+ "$BUILD_CONTAINER" sh -c 'cd /build && sh ci/test-gate.sh clean package'
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target"
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像
+ if: ${{ inputs.tests_only != 'true' }}
run: |
set -e
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
@@ -88,6 +115,7 @@ jobs:
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
- name: 通知 projman 建档(部署由运维手动触发)
+ if: ${{ inputs.tests_only != 'true' }}
run: |
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \
diff --git a/pom.xml b/pom.xml
index 8647587..1c95daf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -100,6 +100,14 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
+ <!-- R2(Codex D04):正常/恢复投递信封与真实接收端 FlowCallbackReceiver 的契约测试用。
+ 仅 test 域——生产件不引入(FL 是回调生产方不是消费方);测试上下文显式关装配
+ (application-test.yml yh.flow-callback.enabled=false),接收端由测试手工构造。 -->
+ <dependency>
+ <groupId>com.yunhai.platform</groupId>
+ <artifactId>yh-flow-callback-spring-boot-starter</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java
index bec6659..e9a8cd1 100644
--- a/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java
+++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java
@@ -13,6 +13,7 @@ import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
@@ -28,10 +29,22 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
- * 回调分发器(概览 §8.4 可靠性设计):引擎 AFTER_COMMIT 事件 → 匹配 flw_callback_reg
- * (flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
+ * 回调分发器(概览 §8.4 可靠性设计;2026-10-25 R2 返修):引擎 AFTER_COMMIT 事件 →
+ * 匹配 flw_callback_reg(flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
* 落库成功即视为主业务完成,推送失败不影响审批(异步解耦是特性)。
* @Scheduled(fixedDelay=30s) 扫 (status=0, next_retry_time<=now) 重推;死信留管理端手工重推。
+ *
+ * <p>R2(Codex D04)投递可靠性口径:
+ * <ul>
+ * <li><b>统一冻结信封</b>——事实行在状态事务内冻结完整投递体(含 eventId/eventSeq/flowGuid);
+ * 正常分发优先复用冻结体,崩溃恢复分发原样复用同一冻结体,两条路径消息体同构;恢复体
+ * 对 R2 前存量事实行按列值幂等补齐 eventId/eventSeq/flowGuid 后投出。</li>
+ * <li><b>未可靠建行不记账</b>——任一匹配订阅方的投递行建行失败(含持久化异常)时,事实行
+ * 不标记 dispatched:正常链不标记(恢复扫描补建),恢复链保持 dispatched=0 等下轮重扫;
+ * 异步首推的提交失败不回退建行结果(行已在,重试扫描接手)。</li>
+ * <li><b>并发幂等靠数据库唯一键</b>——(event_id, reg_id) 复合唯一约束兜底 exists→insert 竞态,
+ * DuplicateKeyException 一律视为"已建行"幂等成功。</li>
+ * </ul>
*/
@Slf4j
@Component
@@ -48,6 +61,17 @@ public class CallbackDispatcher {
private final CallbackPushService pushService;
private final CallbackProperties properties;
private final ObjectMapper objectMapper;
+ private final com.yunhai.flowcenter.repository.FlwStateEventRepository stateEventRepository;
+ private final CallbackFactStore factStore;
+
+ @org.springframework.beans.factory.annotation.Autowired
+ private CallbackSourcePolicy sourcePolicy;
+
+ private boolean self(FlwCallbackReg reg, FlwInstance instance) {
+ if (instance.getSourcePrincipalId() == null) return true; // legacy cutover is a separate migration
+ if (sourcePolicy == null) throw new IllegalStateException("Missing formal callback source policy");
+ return sourcePolicy.matches(reg, instance);
+ }
private volatile ExecutorService pushExecutor;
@@ -70,14 +94,129 @@ public class CallbackDispatcher {
}
try {
dispatch(event);
+ markFactDispatched(event);
} catch (Exception e) {
- // 分发失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程
- log.error("[flow-center] 回调分发异常:instance={} event={} {}",
+ // 分发/建行失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程;
+ // R2:不标记 dispatched——恢复扫描稍后按事实行补建缺失的投递行
+ log.error("[flow-center] 回调分发异常(事实保持待补分发):instance={} event={} {}",
event.getInstanceId(), event.getType().getCode(), e.toString(), e);
}
}
- private void dispatch(FlowEvent event) {
+ /** T05:事实行分发完成记账(崩溃窗口恢复扫描据此区分"已分发"与"待补分发") */
+ private void markFactDispatched(FlowEvent event) {
+ if (event.getStableEventId() == null) {
+ return; // 无事实行(存量发布路径/记录器缺席),维持旧行为
+ }
+ try {
+ // Cross the separate bean's proxy: the completed business transaction cannot commit this write.
+ factStore.markDispatched(event.getStableEventId());
+ } catch (Exception e) {
+ log.warn("[flow-center] 事件事实分发记账失败(重扫会幂等补):eventId={} {}",
+ event.getStableEventId(), e.toString());
+ }
+ }
+
+ /**
+ * T05 崩溃窗口恢复扫描:状态事务已提交但分发未及执行(进程被杀/分发异常)的事实行,
+ * 过缓冲期后按存量事实补分发——幂等(同 eventId 已建投递行则跳过),不重放旧轮身份
+ * (载荷即事实快照)。R2:任一订阅方建行失败则保持 dispatched=0,下轮重扫补齐。
+ */
+ @Scheduled(fixedDelay = 60_000)
+ public void sweepUndispatchedFacts() {
+ try {
+ java.util.List<com.yunhai.flowcenter.entity.FlwStateEvent> due =
+ stateEventRepository.findTop50ByDispatchedAndCreateTimeLessThanEqualOrderByCreateTimeAsc(
+ 0, java.time.LocalDateTime.now().minusSeconds(60));
+ for (com.yunhai.flowcenter.entity.FlwStateEvent fact : due) {
+ executor().submit(() -> dispatchFact(fact));
+ }
+ } catch (Exception e) {
+ log.warn("[flow-center] 事件事实补分发扫描异常:{}", e.toString());
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void dispatchFact(com.yunhai.flowcenter.entity.FlwStateEvent fact) {
+ try {
+ FlwInstance instance = instanceRepository.findById(fact.getInstanceId()).orElse(null);
+ if (instance == null) {
+ return;
+ }
+ FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
+ if (def == null) {
+ return;
+ }
+ Map<String, Object> stored = objectMapper.readValue(fact.getPayloadJson(), Map.class);
+ java.util.List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
+ instance.getSchId(), def.getFlowGuid(), 0);
+ boolean allDelivered = true;
+ for (FlwCallbackReg reg : regs) {
+ if (!self(reg, instance)) continue;
+ if (!subscribed(reg.getEventTypes(), fact.getEventType())) {
+ continue;
+ }
+ if (logRepository.existsByEventIdAndRegId(fact.getEventId(), reg.getId())) {
+ continue; // 幂等:崩溃前已建的投递行不重建
+ }
+ try {
+ persistFactDelivery(reg, instance, def, fact, stored);
+ } catch (DuplicateKeyException dup) {
+ // uk(event_id, reg_id) 并发兜底:另一路径已建行,视为已投递
+ } catch (Exception e) {
+ allDelivered = false;
+ log.error("[flow-center] 事件事实补分发建行失败(保留待重扫):eventId={} regId={} {}",
+ fact.getEventId(), reg.getId(), e.toString());
+ }
+ }
+ if (!allDelivered) {
+ return; // R2:未全部可靠建行,不标记 dispatched
+ }
+ fact.setDispatched(1);
+ stateEventRepository.save(fact);
+ } catch (Exception e) {
+ log.warn("[flow-center] 事件事实补分发失败(下轮重扫):eventId={} {}", fact.getEventId(), e.toString());
+ }
+ }
+
+ /**
+ * 补分发建行:载荷以事实冻结体为准(R2 冻结信封原样复用,与正常链同构);
+ * R2 前存量事实行缺 eventId/eventSeq/flowGuid 时按列值幂等补齐。异步首推同正常链。
+ */
+ private void persistFactDelivery(FlwCallbackReg reg, FlwInstance instance, FlwDef def,
+ com.yunhai.flowcenter.entity.FlwStateEvent fact,
+ Map<String, Object> stored) throws Exception {
+ String payloadJson;
+ if (stored.containsKey("eventId") && stored.containsKey("flowGuid")) {
+ // R2 冻结体:完整信封已含全部投递字段,原样复用(不可变)
+ payloadJson = fact.getPayloadJson();
+ } else {
+ // R2 前存量事实行:信封字段缺失,按事实列值+现查 flowGuid 幂等修复
+ Map<String, Object> repaired = new HashMap<>(stored);
+ repaired.put("eventId", fact.getEventId());
+ if (fact.getEventSeq() != null) {
+ repaired.put("eventSeq", fact.getEventSeq());
+ }
+ repaired.put("flowGuid", def.getFlowGuid());
+ payloadJson = objectMapper.writeValueAsString(repaired);
+ }
+ FlwCallbackLog callbackLog = new FlwCallbackLog();
+ callbackLog.setSchId(instance.getSchId());
+ callbackLog.setRegId(reg.getId());
+ callbackLog.setEventId(fact.getEventId());
+ callbackLog.setEvent(fact.getEventType());
+ 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());
+ Long logId = logStore.persist(callbackLog);
+ submitPush(logId);
+ }
+
+ private void dispatch(FlowEvent event) throws Exception {
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null);
if (instance == null) {
return;
@@ -90,6 +229,7 @@ public class CallbackDispatcher {
List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
instance.getSchId(), def.getFlowGuid(), 0);
for (FlwCallbackReg reg : regs) {
+ if (!self(reg, instance)) continue;
if (!subscribed(reg.getEventTypes(), eventCode)) {
continue;
}
@@ -97,52 +237,85 @@ public class CallbackDispatcher {
}
}
- private void persistAndPush(FlwCallbackReg reg, FlwInstance instance, FlwDef def, FlowEvent event) {
+ private void persistAndPush(FlwCallbackReg reg, FlwInstance instance, FlwDef def, FlowEvent event)
+ throws Exception {
+ // T05:eventId 用事实行稳定身份(全部订阅方/重试复用);事实行缺席(存量路径)才现场生成
+ String eventId = event.getStableEventId() != null ? event.getStableEventId()
+ : UUID.randomUUID().toString().replace("-", "").toLowerCase();
+ if (logRepository.existsByEventIdAndRegId(eventId, reg.getId())) {
+ return; // 幂等:补分发与正常分发竞争时不重复建投递行
+ }
+ // R2:优先复用事实冻结信封(与崩溃恢复链同构、消息体恒含 eventId/eventSeq);
+ // 冻结体缺席(存量路径/记录器缺席)才现场构建
+ String payloadJson = event.getStablePayloadJson() != null
+ ? event.getStablePayloadJson()
+ : objectMapper.writeValueAsString(legacyEnvelope(event, instance, def, eventId));
+ 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 会挂空事务);
+ // R2:建行失败(含并发撞 uk)向外传播——调用方据此不标记事实已分发/保持待重扫
+ Long logId;
+ try {
+ logId = logStore.persist(callbackLog);
+ } catch (DuplicateKeyException dup) {
+ return; // uk(event_id, reg_id) 并发兜底:另一路径已建行,幂等成功
+ }
+ submitPush(logId);
+ }
+
+ /** 异步首推提交:失败不回退建行结果(行已在,重试扫描接手) */
+ private void submitPush(Long logId) {
try {
- String eventId = UUID.randomUUID().toString().replace("-", "").toLowerCase();
- Map<String, Object> payload = new HashMap<>();
- payload.put("eventId", eventId);
- payload.put("event", event.getType().getCode());
- payload.put("schId", instance.getSchId());
- payload.put("flowGuid", def.getFlowGuid());
- payload.put("instanceId", instance.getId());
- payload.put("instanceVersion", instance.getVersion());
- payload.put("bizType", instance.getBizType());
- payload.put("bizId", instance.getBizId());
- payload.put("bizTitle", instance.getBizTitle());
- payload.put("occurredTime", LocalDateTime.now().format(TIME));
- // 2026-09-20 信封升级:任务定位与操作人(均可空,办件级事件/历史重放体为 null——
- // 契约见《子应用接入流程中心规范.md》§6.3/§6.4)
- payload.put("taskId", event.getTaskId());
- payload.put("operatorUserId", event.getOperatorId());
- payload.put("operatorName", event.getOperatorName());
- Map<String, Object> data = new HashMap<>();
- if (event.getData() != null) {
- data.putAll(event.getData());
- }
- payload.put("data", data);
- String payloadJson = objectMapper.writeValueAsString(payload);
-
- FlwCallbackLog callbackLog = new FlwCallbackLog();
- callbackLog.setSchId(instance.getSchId());
- callbackLog.setRegId(reg.getId());
- callbackLog.setEventId(eventId);
- callbackLog.setEvent(event.getType().getCode());
- callbackLog.setInstanceId(instance.getId());
- callbackLog.setBizType(instance.getBizType());
- callbackLog.setBizId(instance.getBizId());
- callbackLog.setPayloadJson(payloadJson);
- callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
- callbackLog.setRetryCount(0);
- callbackLog.setNextRetryTime(LocalDateTime.now());
- // REQUIRES_NEW 落库(afterCommit 阶段原事务已完结,REQUIRED 会挂空事务)
- Long logId = logStore.persist(callbackLog);
- // 异步首推:失败由退避重试接手(1m 起)
executor().submit(() -> pushService.pushAsync(logId));
} catch (Exception e) {
- log.error("[flow-center] 回调日志落库失败(对账兜底会追平):instance={} event={} {}",
- instance.getId(), event.getType().getCode(), e.toString(), e);
+ log.warn("[flow-center] 异步首推提交失败(重试扫描接手):logId={} {}", logId, e.toString());
+ }
+ }
+
+ /**
+ * 存量现场信封(事实冻结体缺席的兜底路径):字段口径与 FlwStateEventRecorder 冻结体一致。
+ */
+ private Map<String, Object> legacyEnvelope(FlowEvent event, FlwInstance instance, FlwDef def,
+ String eventId) {
+ Map<String, Object> payload = new HashMap<>();
+ payload.put("eventId", eventId);
+ if (event.getStableEventSeq() != null) {
+ payload.put("eventSeq", event.getStableEventSeq());
+ }
+ 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));
+ // 2026-09-20 信封升级:任务定位与操作人(均可空,办件级事件/历史重放体为 null——
+ // 契约见《子应用接入流程中心规范.md》§6.3/§6.4)
+ payload.put("taskId", event.getTaskId());
+ payload.put("operatorUserId", event.getOperatorId());
+ payload.put("operatorName", event.getOperatorName());
+ Map<String, Object> data = new HashMap<>();
+ if (event.getData() != null) {
+ data.putAll(event.getData());
+ }
+ // 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。
+ if (event.getRoundIdentity() != null) {
+ data.putAll(event.getRoundIdentity());
}
+ payload.put("data", data);
+ return payload;
}
/** 重推扫描(fixedDelay 串行防重入;推拉结合里 Webhook 永远只是加速器) */
diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java
index 2ac94df..25c7707 100644
--- a/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java
+++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java
@@ -2,21 +2,45 @@ package com.yunhai.flowcenter.callback;
import com.yunhai.flowcenter.entity.FlwCallbackLog;
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
-import lombok.RequiredArgsConstructor;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
+import javax.sql.DataSource;
+import java.sql.*;
+import java.time.LocalDateTime;
+import java.util.*;
+
/**
* 回调日志落库(独立 bean 保证 REQUIRES_NEW 生效):AFTER_COMMIT 监听阶段原事务已提交
* 但事务资源仍挂在线程上,REQUIRED 会"加入"已完结事务并抛 no transaction is in progress——
* 必须显式开新事务写日志。落库成功即视为主业务完成,推送失败不影响审批。
*/
@Service
-@RequiredArgsConstructor
+@Slf4j
public class CallbackLogStore {
private final FlwCallbackLogRepository logRepository;
+ private final DataSource dataSource;
+ private final int claimIdleSeconds;
+
+ @Autowired
+ public CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource) {
+ this(logRepository, dataSource, 60);
+ }
+
+ // 窄范围故障测试可缩短会话租期;生产固定 60s,大于正常 HTTP 5s 连接 + 10s 读取超时。
+ CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource, int claimIdleSeconds) {
+ if (claimIdleSeconds < 1) throw new IllegalArgumentException("claimIdleSeconds must be positive");
+ this.logRepository = logRepository;
+ this.dataSource = dataSource;
+ this.claimIdleSeconds = claimIdleSeconds;
+ }
/** @return 落库后的日志行 id */
@Transactional(propagation = Propagation.REQUIRES_NEW)
@@ -24,4 +48,198 @@ public class CallbackLogStore {
FlwCallbackLog saved = logRepository.saveAndFlush(callbackLog);
return saved.getId();
}
+
+ /**
+ * MySQL 会话锁不是事务/行锁。专用借出连接始终 autocommit,HTTP 不占用数据库事务。
+ * 连接空闲 60s 后由数据库关闭并释放锁;所有结果必须在原连接上验证锁所有权,禁止换连接补写。
+ */
+ public Claim tryClaim(Long id) {
+ Claim claim = openSession();
+ try {
+ if (!claim.acquire(id)) {
+ claim.close();
+ return null;
+ }
+ claim.row = claim.read(id);
+ if (claim.row == null) {
+ claim.close();
+ return null;
+ }
+ return claim;
+ } catch (RuntimeException | SQLException failure) {
+ claim.close();
+ throw new IllegalStateException("回调认领失败", failure);
+ }
+ }
+
+ private Claim openSession() {
+ Connection connection = null;
+ try {
+ // 不用 DataSourceUtils:绝不借用调用方已绑定的业务事务连接。
+ connection = dataSource.getConnection();
+ if (!connection.getAutoCommit()) throw new SQLException("callback claim requires autocommit");
+ long previous;
+ try (Statement st = connection.createStatement(); ResultSet rs = st.executeQuery("SELECT @@session.wait_timeout")) {
+ rs.next(); previous = rs.getLong(1);
+ }
+ try (Statement st = connection.createStatement()) {
+ st.execute("SET SESSION wait_timeout = " + claimIdleSeconds);
+ }
+ return new Claim(connection, previous);
+ } catch (SQLException failure) {
+ if (connection != null) {
+ try { connection.abort(Runnable::run); } catch (SQLException ignored) { }
+ try { connection.close(); } catch (SQLException ignored) { }
+ }
+ throw new IllegalStateException("回调认领连接不可用", failure);
+ }
+ }
+
+ /** 人工重推先完成整批短事务,再交回推送层发 HTTP;成功行不能被旧管理快照复活。 */
+ public List<Long> resetForReplay(Long school, Collection<Long> ids) {
+ try (Claim claim = openSession()) {
+ List<Long> sorted = new ArrayList<>(new TreeSet<>(ids));
+ for (Long id : sorted) {
+ if (!claim.acquire(id)) throw new BizException(ErrorCode.CUSTOM, "回调正在派发,请稍后重推:" + id);
+ FlwCallbackLog row = claim.read(id);
+ if (row == null || !school.equals(row.getSchId())) throw new BizException(ErrorCode.FORBIDDEN, "回调日志不存在或不属于该校");
+ }
+ claim.connection.setAutoCommit(false);
+ List<Long> reset = new ArrayList<>();
+ try {
+ for (Long id : sorted) {
+ // 也检查所有权,旧 MySQL 若不支持同会话多锁会 fail closed,而不会无锁更新。
+ if (!claim.owns(id)) throw new BizException(ErrorCode.CUSTOM, "回调派发权已失效,请重试");
+ int changed = claim.update(id, "status=0, retry_count=0, next_retry_time=?, update_time=?, update_by=?",
+ "status in (0,2)", Timestamp.valueOf(LocalDateTime.now()), Timestamp.valueOf(LocalDateTime.now()), actor());
+ if (changed == 1) reset.add(id);
+ }
+ claim.connection.commit();
+ return reset;
+ } catch (RuntimeException | SQLException failure) {
+ claim.connection.rollback();
+ throw failure;
+ } finally { claim.connection.setAutoCommit(true); }
+ } catch (SQLException failure) {
+ throw new IllegalStateException("回调重推状态提交失败", failure);
+ }
+ }
+
+ private static String actor() {
+ com.yunhai.platform.auth.LoginUser user = com.yunhai.platform.auth.UserContextHolder.get();
+ return user == null ? null : user.getLoginName();
+ }
+
+ /** 令牌即拥有命名锁的原始数据库会话;状态更新只能在该连接上进行。 */
+ public static final class Claim implements AutoCloseable {
+ private final Connection connection;
+ private final long previousWaitTimeout;
+ private final Map<Long, String> keys = new LinkedHashMap<>();
+ private FlwCallbackLog row;
+ private boolean closed;
+ private boolean settled;
+
+ private Claim(Connection connection, long previousWaitTimeout) {
+ this.connection = connection;
+ this.previousWaitTimeout = previousWaitTimeout;
+ }
+
+ public FlwCallbackLog row() { return row; }
+
+ public boolean active() {
+ try { return !closed && row != null && owns(row.getId()); }
+ catch (SQLException expired) { return false; }
+ }
+
+ private boolean acquire(Long id) throws SQLException {
+ String key;
+ // 64 字符锁名:库名隔离,同 (eventId,regId) 的全部执行者用同一身份。
+ try (PreparedStatement st = connection.prepareStatement(
+ "SELECT SHA2(CONCAT(LOWER(DATABASE()), ':flw_callback:', event_id, ':', reg_id),256) FROM flw_callback_log WHERE id=?")) {
+ st.setLong(1, id);
+ try (ResultSet rs = st.executeQuery()) { if (!rs.next()) return false; key = rs.getString(1); }
+ }
+ try (PreparedStatement st = connection.prepareStatement("SELECT GET_LOCK(?,0)")) {
+ st.setString(1, key);
+ try (ResultSet rs = st.executeQuery()) {
+ rs.next();
+ if (rs.getInt(1) != 1 || rs.wasNull()) return false;
+ }
+ }
+ keys.put(id, key);
+ return true;
+ }
+
+ private FlwCallbackLog read(Long id) throws SQLException {
+ try (PreparedStatement st = connection.prepareStatement("SELECT * FROM flw_callback_log WHERE id=?")) {
+ st.setLong(1, id);
+ try (ResultSet rs = st.executeQuery()) {
+ return rs.next() ? new BeanPropertyRowMapper<>(FlwCallbackLog.class).mapRow(rs, 0) : null;
+ }
+ }
+ }
+
+ private boolean owns(Long id) throws SQLException {
+ try (PreparedStatement st = connection.prepareStatement("SELECT IS_USED_LOCK(?)=CONNECTION_ID()")) {
+ st.setString(1, keys.get(id));
+ try (ResultSet rs = st.executeQuery()) { rs.next(); return rs.getBoolean(1); }
+ }
+ }
+
+ private int update(Long id, String assignments, String condition, Object... values) throws SQLException {
+ try (PreparedStatement st = connection.prepareStatement("UPDATE flw_callback_log SET " + assignments
+ + " WHERE id=? AND " + condition + " AND IS_USED_LOCK(?)=CONNECTION_ID()")) {
+ int i=1;
+ for (Object value : values) st.setObject(i++, value);
+ st.setLong(i++, id); st.setString(i, keys.get(id));
+ return st.executeUpdate();
+ }
+ }
+
+ public boolean success(int httpStatus) { return finish("status=1, last_http_status=?, last_error=NULL, push_time=?, next_retry_time=NULL",
+ String.valueOf(httpStatus), Timestamp.valueOf(LocalDateTime.now())); }
+
+ public boolean failure(String error, String httpStatus, long backoffMinutes) {
+ return finish("retry_count=COALESCE(retry_count,0)+1, last_http_status=?, last_error=?, next_retry_time=?",
+ httpStatus, error, Timestamp.valueOf(LocalDateTime.now().plusMinutes(backoffMinutes)));
+ }
+
+ public boolean dead(String error) { return finish("status=2, last_error=?", error); }
+
+ private boolean finish(String assignments, Object... values) {
+ if (closed || settled || row == null) return false;
+ Object[] audited = Arrays.copyOf(values, values.length+2);
+ audited[values.length] = Timestamp.valueOf(LocalDateTime.now()); audited[values.length+1] = actor();
+ try {
+ // count 是附加 CAS;不是把旧实体全量 merge 回库。会话失权/成功/人工处理后均不能覆盖。
+ settled = update(row.getId(), assignments + ", update_time=?, update_by=?",
+ "status=0 AND retry_count=" + row.getRetryCount(), audited) == 1;
+ if (!settled) log.debug("[flow-center] 回调旧结果已忽略:logId={}", row.getId());
+ return settled;
+ } catch (SQLException failure) {
+ log.warn("[flow-center] 回调结果未记账(会话失效或数据库不可用,留待重试):logId={} {}", row.getId(), failure.toString());
+ return false;
+ }
+ }
+
+ @Override public void close() {
+ if (closed) return;
+ closed = true;
+ try {
+ for (String key : keys.values()) {
+ try (PreparedStatement st = connection.prepareStatement("SELECT RELEASE_LOCK(?)")) {
+ st.setString(1, key); st.execute();
+ }
+ }
+ try (Statement st = connection.createStatement()) {
+ st.execute("SET SESSION wait_timeout = " + previousWaitTimeout);
+ }
+ } catch (SQLException failedRelease) {
+ // 不能把可能残留会话锁的物理连接还给池;显式中止,让数据库释放全部锁。
+ try { connection.abort(Runnable::run); } catch (SQLException ignored) { }
+ } finally {
+ try { connection.close(); } catch (SQLException ignored) { }
+ }
+ }
+ }
}
diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java
index e29c0dd..4bad120 100644
--- a/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java
+++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java
@@ -21,8 +21,8 @@ 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.HashMap;
+import java.util.Map;
import java.util.UUID;
/**
@@ -32,7 +32,7 @@ import java.util.UUID;
* <b>持续重试不终止</b>(T1.1:消费方只承诺"瞬时失败回 5xx",恢复后自动追平,消费方免对账);
* 挂起(retryCount 首达 hang-alert-retries)时 UserMsg 告警恰一次;
* 死信(status=2)仅两个入口——注册停用、管理端手工标记,重试次数永不自动触发。
- * 推送一律在新事务里落库(AFTER_COMMIT 监听器/调度线程均无外层事务)。
+ * HTTP 在业务事务之外执行,结果在认领会话上以自动提交的条件 UPDATE 落定。
* 推送本身与落库解耦:payload_json 是完整快照,可重放。
*/
@Slf4j
@@ -40,43 +40,72 @@ import java.util.UUID;
@RequiredArgsConstructor
public class CallbackPushService {
- private static final Set<Long> IN_FLIGHT = new HashSet<>();
+ /** 仅观测,不阻断数据库租期后的恢复;每个执行者有独立本地状态。 */
+ private final Map<Long, Integer> IN_FLIGHT = new HashMap<>();
+
+ /** 行是否有本地执行者在途(仅观测;互斥由数据库认领负责)。 */
+ boolean inFlight(Long logId) {
+ synchronized (IN_FLIGHT) {
+ return IN_FLIGHT.containsKey(logId);
+ }
+ }
private final FlwCallbackLogRepository logRepository;
private final FlwCallbackRegRepository regRepository;
private final CallbackProperties properties;
+ private final CallbackLogStore logStore;
private final UserMsgClient userMsgClient;
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
appIdentityProvider;
+ @org.springframework.beans.factory.annotation.Autowired
+ private CallbackSourcePolicy sourcePolicy;
+
/** 推送指定日志行(幂等入口:成功/终态/在途跳过) */
+ @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
public void pushAsync(Long logId) {
synchronized (IN_FLIGHT) {
- if (!IN_FLIGHT.add(logId)) {
+ IN_FLIGHT.merge(logId, 1, Integer::sum);
+ }
+ try (CallbackLogStore.Claim claim = logStore.tryClaim(logId)) {
+ if (claim == null) {
+ log.debug("[flow-center] 回调数据库派发权未取得:logId={}", logId);
return;
}
- }
- try {
- doPush(logId);
+ doPush(claim);
} finally {
synchronized (IN_FLIGHT) {
- IN_FLIGHT.remove(logId);
+ IN_FLIGHT.computeIfPresent(logId, (id, count) -> count == 1 ? null : count - 1);
}
}
}
- private void doPush(Long logId) {
- FlwCallbackLog callbackLog = logRepository.findById(logId).orElse(null);
+ private void doPush(CallbackLogStore.Claim claim) {
+ FlwCallbackLog callbackLog = claim.row();
if (callbackLog == null
|| callbackLog.getStatus() != FlwEnums.CallbackLogStatus.PENDING.getCode()) {
+ log.debug("[flow-center] 回调推送早退(行缺席或非待推):logId={} status={}",
+ callbackLog == null ? null : callbackLog.getId(), callbackLog == null ? null : callbackLog.getStatus());
return;
}
FlwCallbackReg reg = regRepository.findById(callbackLog.getRegId()).orElse(null);
if (reg == null || reg.getStatus() != 0) {
// 注册已停用:保留日志待查,不再推送(置死信便于管理端一次性清理)
- markDead(callbackLog, "registration disabled");
+ markDead(claim, "registration disabled");
return;
}
+ if (sourcePolicy != null) {
+ try {
+ if (!sourcePolicy.permits(reg, callbackLog)) {
+ markDead(claim, "SELF source or command identity mismatch");
+ return;
+ }
+ } catch (RuntimeException unavailable) {
+ markRetryOrFail(claim, "source authentication unavailable", null);
+ return;
+ }
+ }
+ if (!claim.active()) return; // 权威查询迟到/会话租期已失效:不再发送。
String body = callbackLog.getPayloadJson();
String ts = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
@@ -94,41 +123,34 @@ public class CallbackPushService {
new HttpEntity<>(body, headers), String.class);
int status = resp.getStatusCodeValue();
if (status >= 200 && status < 300) {
- markSuccess(callbackLog, status);
+ markSuccess(claim, status);
} else {
- markRetryOrFail(callbackLog, "HTTP " + status, String.valueOf(status));
+ markRetryOrFail(claim, "HTTP " + status, String.valueOf(status));
}
} catch (Exception e) {
- markRetryOrFail(callbackLog, rootMessage(e), null);
+ markRetryOrFail(claim, 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);
+ private void markSuccess(CallbackLogStore.Claim claim, int httpStatus) {
+ if (claim.success(httpStatus)) {
+ log.debug("[flow-center] 回调推送成功:eventId={} http={}", claim.row().getEventId(), httpStatus);
+ }
}
/**
* 失败落定(持续重试语义):越过退避表后按 {@link CallbackProperties#getCapBackoffMinutes()}
* 封顶继续排期,<b>绝不自动死信</b>;retryCount 首次等于挂起阈值时告警恰一次。
*/
- private void markRetryOrFail(FlwCallbackLog callbackLog, String error, String httpStatus) {
+ private void markRetryOrFail(CallbackLogStore.Claim claim, String error, String httpStatus) {
+ FlwCallbackLog callbackLog = claim.row();
int retryCount = (callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount()) + 1;
- callbackLog.setRetryCount(retryCount);
- callbackLog.setLastHttpStatus(httpStatus);
- callbackLog.setLastError(truncate(error));
java.util.List<Long> backoff = properties.getRetryBackoffMinutes();
long backoffMinutes = retryCount <= backoff.size()
? backoff.get(retryCount - 1)
: Math.max(1, properties.getCapBackoffMinutes());
- callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
- callbackLog.setNextRetryTime(LocalDateTime.now().plusMinutes(backoffMinutes));
- saveQuietly(callbackLog);
+ if (!claim.failure(truncate(error), httpStatus, backoffMinutes)) return;
+ callbackLog.setRetryCount(retryCount); // 仅用于日志/告警;数据库已原子 +1,绝不 merge 此快照。
log.warn("[flow-center] 回调推送失败(第 {} 次重试,{} 分钟后):eventId={} {}",
retryCount, backoffMinutes, callbackLog.getEventId(), error);
// 挂起告警恰一次:retryCount 每失败 +1 单调递增,等于阈值的时机只有一次;
@@ -163,9 +185,20 @@ public class CallbackPushService {
* 仍被注册停用场景使用(doPush:注册已停用即无意义重试,置死信便于管理端清理)。
*/
public void markDead(FlwCallbackLog callbackLog, String error) {
- callbackLog.setStatus(FlwEnums.CallbackLogStatus.DEAD.getCode());
- callbackLog.setLastError(truncate(error));
- saveQuietly(callbackLog);
+ try (CallbackLogStore.Claim claim = logStore.tryClaim(callbackLog.getId())) {
+ if (claim == null) throw new com.yunhai.platform.apistd.BizException(
+ com.yunhai.platform.utils.exception.ErrorCode.CUSTOM, "回调正在派发,请稍后标记");
+ if (claim.row().getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
+ throw new com.yunhai.platform.apistd.BizException(
+ com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "已成功日志无需标记死信");
+ }
+ markDead(claim, error);
+ }
+ }
+
+ private void markDead(CallbackLogStore.Claim claim, String error) {
+ if (!claim.dead(truncate(error))) return;
+ FlwCallbackLog callbackLog = claim.row();
log.error("[flow-center] 回调推送进入死信:eventId={} biz={}/{} error={}",
callbackLog.getEventId(), callbackLog.getBizType(), callbackLog.getBizId(), error);
alertDead(callbackLog, error);
@@ -203,14 +236,6 @@ public class CallbackPushService {
}
}
- private void saveQuietly(FlwCallbackLog callbackLog) {
- try {
- logRepository.save(callbackLog);
- } catch (Exception e) {
- log.error("[flow-center] 回调日志落库失败:eventId={} {}", callbackLog.getEventId(), e.toString());
- }
- }
-
/**
* 回调探测(2026-10-11 管理端增强):以该注册的 secret 按现行信封签一条未知事件码 flow.ping,
* 走真实推送管道同步一发——接收方按规范 §6.4 对未知事件码"记日志后 200",不破坏业务数据。
diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
index 7da759d..cdbe98c 100644
--- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
+++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
@@ -6,6 +6,7 @@ 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.enums.FormBindingErrorCode;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.service.FlowAdminGuard;
import com.yunhai.flowcenter.service.FlowCallbackAdminService;
@@ -108,6 +109,46 @@ public class FlowAdminController {
return appAdminService.stats(adminGuard.requireAdmin(user, null));
}
+ // ==================== 应用使用授权(T01) ====================
+
+ /**
+ * 使用授权管理面(字段注入:构造被既有单测逐位钉住,同 formCenterCatalogClient 先例)。
+ * 授权对象是稳定应用主体(UserBase appId),非 appKey 凭据;撤权只拦新发起。
+ */
+ @org.springframework.beans.factory.annotation.Autowired
+ private com.yunhai.flowcenter.service.FlowUsageGrantAdminService usageGrantAdminService;
+
+ /** 某流程线的使用授权清单(含已撤销行,审计可见) */
+ @GetMapping("/usage-grant/list")
+ public List<com.yunhai.flowcenter.entity.FlwUsageGrant> usageGrantList(
+ BoaUser user, @RequestParam("flowGuid") String flowGuid) {
+ return usageGrantAdminService.listByFlow(adminGuard.requireAdmin(user, null), flowGuid);
+ }
+
+ /** 授予/重申使用授权(appKey 当场解析为 UserBase 稳定主体落库;enforce 开启时校学校开通) */
+ @PostMapping("/usage-grant/grant")
+ public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantGrant(
+ BoaUser user, @RequestBody Map<String, Object> body) {
+ Long schId = adminGuard.requireAdmin(user, null);
+ return usageGrantAdminService.grant(schId,
+ body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")),
+ body.get("appKey") == null ? null : String.valueOf(body.get("appKey")),
+ body.get("remark") == null ? null : String.valueOf(body.get("remark")),
+ user.getName());
+ }
+
+ /** 撤销使用授权(enabled=0 行保留;只拦新发起,在途与已受理命令不受影响) */
+ @PostMapping("/usage-grant/revoke")
+ public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantRevoke(
+ BoaUser user, @RequestBody Map<String, Object> body) {
+ Long schId = adminGuard.requireAdmin(user, null);
+ return usageGrantAdminService.revoke(schId,
+ body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")),
+ body.get("appId") == null ? null : Integer.valueOf(String.valueOf(body.get("appId"))),
+ body.get("remark") == null ? null : String.valueOf(body.get("remark")),
+ user.getName());
+ }
+
@GetMapping("/def/get")
public FlwDef defGet(BoaUser user, @RequestParam("id") Long id) {
return defAdminService.get(adminGuard.requireAdmin(user, null), id);
@@ -145,7 +186,16 @@ public class FlowAdminController {
(String) body.get("contentJson"), (String) body.get("baseGuid"),
(String) body.get("appKey"), (String) body.get("changeLog"),
(String) body.get("formUuid"),
- body.get("formVersion") == null ? null : Integer.valueOf(String.valueOf(body.get("formVersion"))));
+ parseFormVersion(body.get("formVersion")));
+ }
+
+ private static Integer parseFormVersion(Object value) {
+ if (value == null) return null;
+ try {
+ return Integer.valueOf(String.valueOf(value));
+ } catch (NumberFormatException e) {
+ throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "formVersion 必须是正整数", e);
+ }
}
/**
@@ -181,6 +231,24 @@ public class FlowAdminController {
return defAdminService.versions(adminGuard.requireAdmin(user, null), flowGuid);
}
+ // ==================== 线级停发/恢复(T03) ====================
+
+ /** 线级停发:整条 flowGuid 停止新发起/预览;在途实例按原版本跑完不受影响 */
+ @PostMapping("/def/offlineLine")
+ public int defOfflineLine(BoaUser user, @RequestBody Map<String, Object> body) {
+ Long schId = adminGuard.requireAdmin(user, null);
+ return defAdminService.offlineLine(schId,
+ body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
+ }
+
+ /** 线级恢复:只恢复线可用性,不改写任何版本状态或发布内容 */
+ @PostMapping("/def/enableLine")
+ public int defEnableLine(BoaUser user, @RequestBody Map<String, Object> body) {
+ Long schId = adminGuard.requireAdmin(user, null);
+ return defAdminService.enableLine(schId,
+ body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
+ }
+
/** 在途办件数(议题二④):flowGuids 逗号分隔,去重后最多 100;返回 flowGuid → 审批中办件数(无在途也返 0 键) */
@GetMapping("/def/inflight")
public Map<String, Long> defInflight(BoaUser user, @RequestParam("flowGuids") String flowGuids) {
diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java
index a3579ab..d414af4 100644
--- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java
+++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java
@@ -3,6 +3,7 @@ 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.model.OapiModels;
import com.yunhai.flowcenter.service.FlowAccessGuard;
import com.yunhai.flowcenter.service.FlowDiagramService;
import com.yunhai.flowcenter.service.FlowInstanceService;
@@ -41,6 +42,22 @@ public class FlowUserController {
private final FlowDiagramService diagramService;
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
+ /** oapi 预览服务复用(T07 托管选人:候选+版本锚同源,字段注入防构造钉) */
+ @org.springframework.beans.factory.annotation.Autowired
+ private com.yunhai.flowcenter.service.OapiFlowService oapiFlowService;
+
+ /**
+ * 发起时指派预览(2026-10-25 T07 托管选人 iframe 数据源,F6 预置件点亮):
+ * CAS 会话取学校+提交人,复用 oapi applyPreview(含 T03 线级停发守卫)——
+ * 返回 {flowGuid, defVersion, nodes:[{nodeId,nodeName,countersign,selection,candidates}]}。
+ * <b>只读预览</b>:不建实例、不建任务、不落任何流程事实;正式提交仍走 apply 终审
+ * (前端候选不是授权凭据)。
+ */
+ @GetMapping("/apply/preview")
+ public OapiModels.ApplyPreviewVo applyPreview(BoaUser user, @RequestParam("flowGuid") String flowGuid) {
+ return oapiFlowService.applyPreview(requireSchId(user), flowGuid, user.getId());
+ }
+
/** 我提交的办件 */
@GetMapping("/my")
public BasePageResult<FlowModels.InstanceItem> my(BoaUser user,
diff --git a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
index a9f54be..24fb747 100644
--- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
+++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
@@ -208,7 +208,7 @@ public class OapiFlowController {
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size,
@RequestAttribute(value = "yh.app.auth", required = false) String appKey) {
- return queryService.pageDone(schId, assigneeId, appKey, page, size);
+ return queryService.pageDone(schId, assigneeId, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(appKey), page, size);
}
/**
@@ -222,7 +222,7 @@ public class OapiFlowController {
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size,
@RequestAttribute(value = "yh.app.auth", required = false) String appKey) {
- return queryService.pageCc(schId, userId, appKey, page, size);
+ return queryService.pageCc(schId, userId, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(appKey), page, size);
}
/** 办件分页(oapi):bizType/status 可选过滤 */
diff --git a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java
index 09da03b..8c187b5 100644
--- a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java
+++ b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java
@@ -15,7 +15,8 @@ import java.util.List;
/**
* 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源,
- * 零跨服务依赖);USERS 定义直配,姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。
+ * 零跨服务依赖);USERS 定义直配。审批责任人统一走 D11 资格终审(存在+启用+校域);
+ * 仅 NOTIFY 抄送接收人保留宽容解析(姓名补全失败降级空名、不校验资格)。
*/
@Slf4j
@Service
@@ -32,13 +33,31 @@ public class AssigneeResolver {
}
List<AssigneeRef> refs = new ArrayList<>();
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
+ // D11:科室启用 ≠ 成员都可办理——逐人走同一资格终审;不合格成员按既有
+ // “只取启用成员”口径剔除(local status 已在 resolveEnabled 过滤),并留日志。
+ List<String> rejected = new ArrayList<>();
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
- refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
+ try {
+ requireEligibleAssignee(schId, m.getUserId());
+ refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
+ } catch (BizException e) {
+ if (e.getErrorCode() == ErrorCode.INTERNAL_SERVER_ERROR) {
+ throw e; // 权威源不可达≠成员不合格,不能降级成“无人可用”
+ }
+ rejected.add(m.getUserId() + "=" + e.getMessage());
+ log.warn("[flow-center] 科室成员资格校验不通过,跳过:schId={} deptKey={} userId={} reason={}",
+ schId, a.deptKey, m.getUserId(), e.getMessage());
+ }
+ }
+ if (refs.isEmpty() && !rejected.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "节点[" + node.id + "]无可用办理人:科室成员均不具备办理资格("
+ + String.join(";", rejected) + ")");
}
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
if (a.userIds != null) {
for (Integer uid : a.userIds) {
- refs.add(new AssigneeRef(uid, fetchName(uid)));
+ refs.add(new AssigneeRef(uid, requireEligibleAssigneeName(schId, uid)));
}
}
} else {
@@ -51,10 +70,30 @@ public class AssigneeResolver {
return refs;
}
+ /**
+ * D11 固定 USERS 整体预检:流程启动前逐节点核验全部固定审批人,任何一人不存在/停用/跨校
+ * 即整体拒绝——不允许“过滤掉非法人后继续”。运行时建任务仍复用 {@link #resolve} 兜底。
+ */
+ public void requireEligibleFixedAssignees(Long schId, FlowDefModel model) {
+ for (FlowDefModel.Node node : model.nodes) {
+ if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignee == null
+ || !FlowDefModel.ASSIGNEE_USERS.equals(node.assignee.type)) {
+ continue;
+ }
+ try {
+ resolve(schId, node);
+ } catch (BizException e) {
+ throw new BizException(e.getErrorCode(),
+ "节点[" + node.id + "]固定审批人配置非法:" + e.getMessage());
+ }
+ }
+ }
+
/**
* 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义),
* 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
- * 绝不能因抄送对象缺失卡住流程推进。与 {@link #resolve} 的强口径相反。
+ * 绝不能因抄送对象缺失卡住流程推进。D11 的资格终审只约束审批责任人,本方法刻意不校验
+ * 存在/启用/校域,直接复用既有姓名补全容错。
*/
public List<AssigneeRef> resolveTolerant(Long schId, FlowDefModel.Node node) {
FlowDefModel.Assignee a = node.receivers;
@@ -89,24 +128,52 @@ public class AssigneeResolver {
}
/**
- * 移交目标校验(严格口径,与 {@link #fetchName} 的降级容错相反):
- * 查不到就必须拒绝——否则会建出一条谁在待办里都看不到的僵尸任务。
- * UserBase 不可达与"人不存在"分别报错,便于排查。
+ * 审批责任人资格终审(D11 唯一权威入口):存在 + 启用(status=1,null fail-closed)
+ * + 属于当前学校。UserBase 不可达与“人不合格”分别报错,前者不可降级为过滤。
*/
- public String requireUserName(Integer userId) {
+ public BoaUser requireEligibleAssignee(Long schId, Integer userId) {
+ BoaUser user = requireValidUser(userId);
+ Long userSchId = user.getSchool() == null || user.getSchool().getId() == null
+ ? null : Long.valueOf(user.getSchool().getId());
+ if (schId == null || !schId.equals(userSchId)) {
+ log.warn("[flow-center] 办理人不属于本校,拒绝:userId={} userSchool={} schId={}",
+ userId, userSchId, schId);
+ throw new BizException(ErrorCode.FORBIDDEN,
+ "办理人不属于该学校,不能办理任务:" + userId
+ + "(所属学校 " + (userSchId == null ? "未知" : userSchId)
+ + ",要求 " + schId + ")");
+ }
+ return user;
+ }
+
+ /** 资格终审通过后返回姓名快照;姓名缺失保留空串(既有口径)。 */
+ public String requireEligibleAssigneeName(Long schId, Integer userId) {
+ BoaUser user = requireEligibleAssignee(schId, userId);
+ return user.getName() == null ? "" : user.getName();
+ }
+
+ /**
+ * 存在 + 启用子检查。只供本类资格终审复用;外部审批责任人必须走
+ * {@link #requireEligibleAssignee}(含校域),避免漏掉学校范围。
+ */
+ private BoaUser requireValidUser(Integer userId) {
if (userId == null) {
- throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象");
+ throw new BizException(ErrorCode.PARAM_ERROR, "缺少办理人");
}
BoaUser user;
try {
user = userClient.getUserById(String.valueOf(userId));
} catch (Exception e) {
- log.warn("[flow-center] 移交对象校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
- throw new BizException(ErrorCode.PARAM_ERROR, "移交对象校验失败,请稍后重试:" + userId);
+ log.warn("[flow-center] 办理人资格校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
+ throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "办理人资格校验失败,请稍后重试:" + userId);
}
if (user == null) {
- throw new BizException(ErrorCode.PARAM_ERROR, "移交对象不存在:" + userId);
+ throw new BizException(ErrorCode.PARAM_ERROR, "办理人不存在:" + userId);
}
- return user.getName() == null ? "" : user.getName();
+ if (!Integer.valueOf(1).equals(user.getStatus())) {
+ log.warn("[flow-center] 办理人为禁用/未知状态账号,拒绝:userId={} status={}", userId, user.getStatus());
+ throw new BizException(ErrorCode.PARAM_ERROR, "办理人为禁用账号,不能办理任务:" + userId);
+ }
+ return user;
}
}
diff --git a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java
index be2ec02..aab4031 100644
--- a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java
+++ b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java
@@ -29,6 +29,7 @@ public final class DefValidator {
*/
public static void validate(FlowDefModel model, Set<String> enabledDeptKeys, Set<String> enabledRouterKeys) {
List<String> problems = new ArrayList<>();
+ VariableContract.validateDefinition(model, false);
if (model.nodes == null || model.nodes.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点");
@@ -248,7 +249,7 @@ public final class DefValidator {
* (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
*
* <p>EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦
- * (表单变量是子应用运行时传的,发布期无从核对 schema,一律放行、缺失按 null 注入求值)。
+ * 新契约的变量引用/类型/值域由 VariableContract 统一检查;历史无声明版本保持兼容。
*/
private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set<String> enabledRouterKeys,
List<String> problems) {
diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java b/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java
index 08f14ca..ef0e950 100644
--- a/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java
+++ b/src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java
@@ -1,81 +1,201 @@
package com.yunhai.flowcenter.engine;
-import com.yunhai.flowcenter.entity.FlwDef;
-import com.yunhai.flowcenter.entity.FlwInstance;
-import com.yunhai.flowcenter.entity.FlwTask;
+import com.yunhai.flowcenter.entity.*;
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 com.yunhai.flowcenter.repository.*;
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;
+import org.springframework.transaction.annotation.*;
-/**
- * 节点放行推进(AFTER_COMMIT 监听器经代理调用本 bean,REQUIRES_NEW 生效——
- * 事务已提交后的同步监听阶段没有活动事务,@Modifying 批量语句必须有新事务承载):
- * 该节点待办清零且办件在途才推进;并发冲突由 flw_instance.version 乐观锁暴露,监听器层负责重试。
- */
-@Slf4j
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+import java.time.LocalDateTime;
+import java.util.*;
+
+/** D09: durable activation membership, serialized last-handler payload, and revision-scoped settlement. */
@Service
@RequiredArgsConstructor
public class FlowAdvanceService {
-
private final FlwInstanceRepository instanceRepository;
private final FlwDefRepository defRepository;
private final FlwTaskRepository taskRepository;
private final FlowEngine flowEngine;
+ private final FlwAdvanceIntentRepository intentRepository;
+ @PersistenceContext private EntityManager entityManager;
+
+ /** The listener groups the existing totalTasks TASK_CREATED events within their creation transaction. */
+ @Transactional(propagation = Propagation.MANDATORY)
+ public Long registerTask(FlowEvent event, Long activationIntentId) {
+ String nodeId = (String) event.getData().get("nodeId");
+ FlwAdvanceIntent intent;
+ if (activationIntentId == null) {
+ FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElseThrow(IllegalStateException::new);
+ intent = new FlwAdvanceIntent();
+ intent.setInstanceId(instance.getId());intent.setNodeId(nodeId);
+ intent.setActivationTaskId(event.getTaskId());
+ intent.setActivationDefId(instance.getDefId());intent.setActivationDefVersion(instance.getDefVersion());
+ intent.setActivationApplyRound(instance.getApplyRound());
+ intent.setStatus(FlwAdvanceIntent.STATE_DONE); // Registration only; no advance work before handling.
+ intent.setNextRetryAt(LocalDateTime.now());
+ } else {
+ intent = intentRepository.findById(activationIntentId).orElseThrow(IllegalStateException::new);
+ if (!Objects.equals(intent.getInstanceId(), event.getInstanceId()) || !Objects.equals(intent.getNodeId(), nodeId)) {
+ throw new IllegalStateException("Activation registration identity mismatch");
+ }
+ }
+ intent.addTask(event.getTaskId());
+ return intentRepository.save(intent).getId();
+ }
+
+ /** TRANSFERRED lacks the parent task ID. Accept only a uniquely proven parent activation, never guess by max ID. */
+ @Transactional(propagation = Propagation.MANDATORY)
+ public void registerTransfer(FlowEvent event) {
+ String nodeId = (String) event.getData().get("fromNodeId");
+ List<FlwAdvanceIntent> generations = intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId);
+ Set<Long> parents = new HashSet<>();
+ List<Long> candidates = taskRepository.findTransferParentCandidates(event.getTaskId(), event.getOperatorId());
+ for (Long taskId : candidates) {
+ FlwAdvanceIntent parent = memberOf(generations, taskId);
+ if (parent == null) throw new IllegalStateException("D09 transfer parent requires audited activation backfill");
+ parents.add(parent.getId());
+ }
+ if (parents.size() != 1) throw new IllegalStateException("D09 ambiguous transfer activation; parent identity is required");
+ FlwAdvanceIntent parent = lockIntent(parents.iterator().next());
+ parent.addTask(event.getTaskId());
+ intentRepository.save(parent);
+ }
+
+ /** Called synchronously in the task transaction, after claimAndFinish. The head lock orders commits, not clocks/IDs. */
+ @Transactional(propagation = Propagation.MANDATORY)
+ public void recordHandled(FlowEvent event) {
+ String nodeId = (String) event.getData().get("nodeId");
+ FlwAdvanceIntent candidate = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId), event.getTaskId());
+ if (candidate == null) throw new IllegalStateException("D09 task has no proven activation; audited backfill required");
+ FlwAdvanceIntent intent = lockIntent(candidate.getId());
+ if (intent.handledTask(event.getTaskId())) return; // Duplicate/late publication cannot rewind the last handler.
+ intent.addHandledTask(event.getTaskId());intent.setTriggerTaskId(event.getTaskId());
+ intent.setEdgeName((String) event.getData().get("edgeName"));
+ intent.setAssigneeId((Integer) event.getData().get("assigneeId"));
+ intent.setAssigneeName((String) event.getData().get("assigneeName"));
+ intent.setStatus(FlwAdvanceIntent.STATE_PENDING);intent.setAttempts(0);
+ intent.setNextRetryAt(LocalDateTime.now());intent.setLastError(null);
+ intentRepository.save(intent);
+ }
+
+ public static final class IntentRef {
+ public final Long id;
+ public final Long revision;
+ public IntentRef(Long id, Long revision) { this.id = id; this.revision = revision; }
+ }
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
+ public IntentRef pendingForTask(Long instanceId, String nodeId, Long taskId) {
+ FlwAdvanceIntent intent = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(instanceId, nodeId), taskId);
+ return intent == null || !FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) ? null
+ : new IntentRef(intent.getId(), intent.getRevision());
+ }
/**
- * @param nodeId 待放行节点
- * @param edgeName 多出口节点所选出口(办理事件携带);可空——兜底从该节点
- * 最近一条已同意任务的 edge_name 列重放(推进在 AFTER_COMMIT 新事务重查,
- * 事件传参只是快路径,落库的值才是权威)
- * @param operator 本节点办理人(TASK_HANDLED 事件 data 穿透;推进产生的
- * TASK_CREATED/COMPLETED 操作人信封字段来源),可空
- * @return 是否实际推进了
+ * One transaction owns validation, engine effects and settlement. RC sees the last committed task status;
+ * the head lock serializes against last-handler refresh, and instance @Version fences reactivation writes.
*/
- @Transactional(propagation = Propagation.REQUIRES_NEW)
- public boolean tryAdvance(Long instanceId, String nodeId, String edgeName, FlowOperator operator) {
- FlwInstance instance = instanceRepository.findById(instanceId).orElse(null);
- if (instance == null
- || instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
+ @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
+ public boolean tryAdvance(Long intentId, Long observedRevision) {
+ FlwAdvanceIntent intent = lockIntent(intentId);
+ if (!ownsRevision(intent, observedRevision)) return false;
+ FlwAdvanceIntent latest = intentRepository.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(
+ intent.getInstanceId(), intent.getNodeId()).orElse(null);
+ if (latest != null && !Objects.equals(latest.getId(), intent.getId())) {
+ done(intent, "D09_STALE_ACTIVATION: successor=" + latest.getId());
+ handoff(latest.getId());
return false;
}
- // 已推进过的节点不再放行(currentNodes 由引擎在放行时移除,乐观锁保证互斥)
- String current = instance.getCurrentNodes();
- boolean active = current != null && java.util.Arrays.stream(current.split(","))
- .anyMatch(nodeId::equals);
- if (!active) {
+ if (intent.getActivationTaskId() == null || !intent.containsTask(intent.getActivationTaskId())
+ || intent.getTriggerTaskId() == null || !intent.handledTask(intent.getTriggerTaskId())) {
+ hold(intent, "D09_UNANCHORED: audited cohort/last-handler backfill required; not executed");
return false;
}
- long pending = taskRepository.findByInstanceIdAndStatus(instanceId,
- FlwEnums.TaskStatus.PENDING.getCode()).stream()
- .filter(t -> nodeId.equals(t.getNodeId()))
- .count();
- if (pending > 0) {
- return false;
+ FlwInstance instance = instanceRepository.findById(intent.getInstanceId()).orElse(null);
+ if (instance == null || instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()
+ || !active(instance, intent.getNodeId())) {
+ done(intent, null);return false;
}
- FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
- if (def == null) {
- log.error("[flow-center] 办件定义缺失,推进中止:instance={} defId={}",
- instanceId, instance.getDefId());
- return false;
+ if (!Objects.equals(instance.getDefId(), intent.getActivationDefId())
+ || !Objects.equals(instance.getDefVersion(), intent.getActivationDefVersion())
+ || !Objects.equals(instance.getApplyRound(), intent.getActivationApplyRound())) {
+ hold(intent, "D09_IDENTITY_MISMATCH: no proven successor; audited backfill required");return false;
}
- String chosen = edgeName;
- if (chosen == null) {
- chosen = taskRepository.findByInstanceIdAndStatus(instanceId,
- FlwEnums.TaskStatus.APPROVED.getCode()).stream()
- .filter(t -> nodeId.equals(t.getNodeId()) && t.getEdgeName() != null)
- .max(java.util.Comparator.comparing(
- FlwTask::getHandleTime, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder())))
- .map(FlwTask::getEdgeName)
- .orElse(null);
+ boolean pending = taskRepository.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode())
+ .stream().anyMatch(t -> intent.getNodeId().equals(t.getNodeId()));
+ if (pending) {
+ // A future handler holds/obtains this same head lock and must re-arm it in its task transaction.
+ done(intent, null);return false;
}
- flowEngine.continueAdvance(instance, def, nodeId, chosen, operator);
- instanceRepository.save(instance);
+ FlwTask trigger = taskRepository.findById(intent.getTriggerTaskId()).orElse(null);
+ if (trigger == null || !intent.containsTask(trigger.getId())
+ || !Objects.equals(trigger.getInstanceId(), instance.getId())
+ || !Objects.equals(trigger.getNodeId(), intent.getNodeId())
+ || !Integer.valueOf(FlwEnums.TaskStatus.APPROVED.getCode()).equals(trigger.getStatus())) {
+ hold(intent, "D09_INVALID_LAST_HANDLER: not executed");return false;
+ }
+ FlwDef def = defRepository.findById(instance.getDefId()).orElseThrow(() -> new IllegalStateException("Missing activation definition"));
+ flowEngine.continueAdvance(instance, def, intent.getNodeId(), trigger.getEdgeName(),
+ FlowOperator.of(trigger.getAssigneeId(), trigger.getAssigneeName()));
+ if (active(instance, intent.getNodeId())) throw new IllegalStateException("Activation did not advance; retaining recoverable intent");
+ instanceRepository.saveAndFlush(instance); // An optimistic conflict rolls back engine writes AND settlement.
+ done(intent, null);
return true;
}
+
+ /** Never merge a worker's detached row. A refreshed revision owns its own retry budget. */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void defer(Long intentId, Long observedRevision, String error) {
+ FlwAdvanceIntent intent = lockIntent(intentId);
+ if (!ownsRevision(intent, observedRevision)) return;
+ intent.setAttempts(intent.getAttempts() + 1);intent.setLastError(truncate(error));
+ if (intent.getAttempts() >= 6) intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED);
+ else intent.setNextRetryAt(LocalDateTime.now().plusSeconds(Math.min(1800, 30L * (1L << (intent.getAttempts() - 1)))));
+ intentRepository.save(intent);
+ }
+
+ private FlwAdvanceIntent lockIntent(Long id) {
+ // Queries in a handling transaction may already have loaded a stale RR snapshot into the identity map.
+ // A lock upgrade on that managed row checks its obsolete @Version before refresh can run.
+ // Flush our own changes, detach ONLY this row, then load a new managed row with a locking current read.
+ entityManager.flush();
+ FlwAdvanceIntent cached = entityManager.find(FlwAdvanceIntent.class, id);
+ if (cached != null) entityManager.detach(cached);
+ return intentRepository.findByIdForUpdate(id).orElse(null);
+ }
+
+ private void handoff(Long successorId) {
+ FlwAdvanceIntent successor = lockIntent(successorId);
+ // A stale worker must not consume its successor. Re-arm a previously waiting head, retaining its own payload.
+ if (successor != null && successor.getTriggerTaskId() != null && FlwAdvanceIntent.STATE_DONE.equals(successor.getStatus())) {
+ successor.setStatus(FlwAdvanceIntent.STATE_PENDING);successor.setAttempts(0);
+ successor.setNextRetryAt(LocalDateTime.now());successor.setLastError(null);intentRepository.save(successor);
+ }
+ }
+
+ private static FlwAdvanceIntent memberOf(List<FlwAdvanceIntent> intents, Long taskId) {
+ FlwAdvanceIntent found = null;
+ for (FlwAdvanceIntent intent : intents) if (intent.containsTask(taskId)) {
+ if (found != null && !Objects.equals(found.getId(), intent.getId())) throw new IllegalStateException("Task belongs to multiple activations");
+ found = intent;
+ }
+ return found;
+ }
+ private static boolean ownsRevision(FlwAdvanceIntent intent, Long revision) {
+ return intent != null && FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) && Objects.equals(intent.getRevision(), revision);
+ }
+ private static boolean active(FlwInstance instance, String nodeId) {
+ return instance.getCurrentNodes() != null && Arrays.asList(instance.getCurrentNodes().split(",")).contains(nodeId);
+ }
+ private void done(FlwAdvanceIntent intent, String reason) {
+ intent.setStatus(FlwAdvanceIntent.STATE_DONE);intent.setLastError(reason);intentRepository.save(intent);
+ }
+ private void hold(FlwAdvanceIntent intent, String reason) {
+ intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED);intent.setLastError(reason);intentRepository.save(intent);
+ }
+ private static String truncate(String s) { return s == null || s.length() <= 255 ? s : s.substring(0,255); }
}
diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java
index db0076c..64c2f67 100644
--- a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java
+++ b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java
@@ -56,6 +56,18 @@ public class FlowDefModel {
public List<Node> nodes;
public List<Edge> edges;
public Settings settings;
+ /** null = 历史无契约版本;新发布必须显式声明(无业务变量时为 [])。 */
+ public List<Variable> variables;
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class Variable {
+ public String key;
+ /** string/number/boolean/date/datetime/money/stringArray/fileArray/userArray */
+ public String type;
+ public Boolean required;
+ @com.fasterxml.jackson.annotation.JsonProperty("enum")
+ public List<String> enumValues;
+ }
/**
* 画布坐标(nodeId → 左上角),仅前端设计器/查看器复用;引擎不消费。
* 缺失时前端自行做自动布局,故可选、可空。
diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
index 6c9bb7f..d1b7543 100644
--- a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
+++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
@@ -45,6 +45,9 @@ public class FlowEngine {
/** 发起后首推:从 START 激活(instance 已建好、status=10)。operator=null:TASK_CREATED/COMPLETED 的操作人回退发起人 */
public void startAdvance(FlwInstance instance, FlwDef def) {
FlowDefModel model = parseModel(def);
+ // D11:整份定义的固定 USERS 先整体预检,非法人不得等到后续节点才暴露(会留下卡单)。
+ // 校验失败抛业务异常,由发起事务整体回滚(零实例零任务)。
+ assigneeResolver.requireEligibleFixedAssignees(instance.getSchId(), model);
setCurrentNodes(instance, null);
advance(instance, model, model.start, null, null);
}
diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
index f500a59..84b7efc 100644
--- a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
+++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
@@ -69,6 +69,55 @@ public class FlowEvent {
private final String operatorName;
/** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */
private final Map<String, Object> data;
+ private Map<String, Object> roundIdentity;
+
+ // ===== T05 稳定事件身份(2026-10-25) =====
+ /** 事实行 event_id:发布线程同步监听器在状态事务内落事实行时一次性赋值,全订阅方/重试复用 */
+ private String stableEventId;
+ /** 事实行 event_seq(实例内单调序号) */
+ private Integer stableEventSeq;
+ /**
+ * R2(Codex D04)冻结信封:事实落库时一次性构建的完整投递载荷 JSON(含 eventId/eventSeq/
+ * flowGuid 等全部信封字段)。正常分发与崩溃恢复分发共用同一份冻结体——恢复消息体不再缺
+ * eventId/eventSeq,两条路径字节级同构。事实行缺席(存量路径)为 null,由分发器现场构建。
+ */
+ private String stablePayloadJson;
+
+ public void assignStableIdentity(String eventId, Integer eventSeq) {
+ if (this.stableEventId != null) {
+ return; // 只赋一次:幂等重入(同事件多监听器)不换身份
+ }
+ this.stableEventId = eventId;
+ this.stableEventSeq = eventSeq;
+ }
+
+ public String getStableEventId() {
+ return stableEventId;
+ }
+
+ public Integer getStableEventSeq() {
+ return stableEventSeq;
+ }
+
+ /** 随稳定身份一并冻结完整投递信封(只赋一次;身份与信封同源同事务) */
+ public void assignStablePayload(String payloadJson) {
+ if (this.stablePayloadJson != null) {
+ return;
+ }
+ this.stablePayloadJson = payloadJson;
+ }
+
+ public String getStablePayloadJson() {
+ return stablePayloadJson;
+ }
+
+ public void captureRoundIdentity(Integer round, String requestToken) {
+ if (roundIdentity != null) return;
+ Map<String, Object> identity = new HashMap<>();
+ identity.put("applyRound", round);
+ identity.put("requestToken", requestToken);
+ roundIdentity = java.util.Collections.unmodifiableMap(identity);
+ }
public static FlowEvent of(Type type, Long instanceId) {
return new FlowEvent(type, instanceId, null, null, null, new HashMap<>());
diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
index 1ac00c3..d0e8a93 100644
--- a/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
+++ b/src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
@@ -1,50 +1,102 @@
package com.yunhai.flowcenter.engine;
+import com.yunhai.flowcenter.entity.FlwAdvanceIntent;
+import com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
+import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
-/**
- * 节点放行推进监听器:TASK_HANDLED 事件 AFTER_COMMIT 后在新事务里重查——
- * 该节点待办是否清零、办件是否仍在途,全部成立才继续推进(下一节点/JOIN 合并/END)。
- * 会签并发办理时各方提交的快照互相看不到对方未提交的办理行,事务内判断
- * "节点是否全部办结"必假,提交后重查才是唯一可靠时机;推进串行化由办件乐观锁兜底
- * (重试循环在监听器侧,跨 bean 调用 {@link FlowAdvanceService#tryAdvance} 保证 REQUIRES_NEW 生效)。
- */
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+
+/** TASK_CREATED establishes membership; TASK_HANDLED refreshes that same activation in the handling transaction. */
@Slf4j
@Component
@RequiredArgsConstructor
public class FlowNodeAdvanceListener {
-
private final FlowAdvanceService advanceService;
+ private final FlwAdvanceIntentRepository intentRepository;
+
+ @EventListener
+ @Order(Ordered.HIGHEST_PRECEDENCE + 2)
+ public void persistIntent(FlowEvent event) {
+ if (event.getData() == null) return;
+ if (event.getType() == FlowEvent.Type.TASK_CREATED) {
+ ActivationBatch batch = batch();
+ if (!batch.tasks.add(event.getTaskId())) return;
+ String key = event.getInstanceId() + ":" + event.getData().get("nodeId");
+ int total = ((Number) event.getData().get("totalTasks")).intValue();
+ if (total <= 0) throw new IllegalStateException("Missing activation batch size");
+ Registration registration = batch.nodes.get(key);
+ if (registration == null) { registration = new Registration(total);batch.nodes.put(key, registration); }
+ if (registration.total != total) throw new IllegalStateException("Activation batch size changed");
+ registration.intentId = advanceService.registerTask(event, registration.intentId);
+ if (--registration.remaining == 0) batch.nodes.remove(key);
+ } else if (event.getType() == FlowEvent.Type.TRANSFERRED) {
+ advanceService.registerTransfer(event);
+ } else if (event.getType() == FlowEvent.Type.TASK_HANDLED && event.getData().get("nodeId") != null) {
+ advanceService.recordHandled(event);
+ }
+ }
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onTaskHandled(FlowEvent event) {
- if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) {
- return;
- }
+ if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) return;
String nodeId = (String) event.getData().get("nodeId");
- if (nodeId == null) {
- return;
- }
- String edgeName = (String) event.getData().get("edgeName");
- // 办理人随事件 data 穿透进推进链:新 TASK_CREATED/COMPLETED 的操作人信封字段(2026-09-20)
- FlowOperator operator = FlowOperator.of(
- (Integer) event.getData().get("assigneeId"),
- (String) event.getData().get("assigneeName"));
+ if (nodeId == null) return;
+ FlowAdvanceService.IntentRef ref = advanceService.pendingForTask(event.getInstanceId(), nodeId, event.getTaskId());
+ if (ref == null) return;
for (int attempt = 1; attempt <= 3; attempt++) {
- try {
- advanceService.tryAdvance(event.getInstanceId(), nodeId, edgeName, operator);
- return;
- } catch (ObjectOptimisticLockingFailureException e) {
- log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}",
- attempt, event.getInstanceId(), nodeId);
+ try { advanceService.tryAdvance(ref.id, ref.revision);return; }
+ catch (ObjectOptimisticLockingFailureException conflict) {
+ log.warn("[flow-center] D09 advance conflict {}/3: intent={} revision={}", attempt, ref.id, ref.revision);
+ } catch (Exception failure) {
+ advanceService.defer(ref.id, ref.revision, failure.toString());return;
}
}
- log.error("[flow-center] 推进重试耗尽(请人工检查):instance={} node={}",
- event.getInstanceId(), nodeId);
+ advanceService.defer(ref.id, ref.revision, "AFTER_COMMIT retries exhausted; sweep will resume");
+ }
+
+ @Scheduled(fixedDelay = 30_000)
+ public void sweepIntents() {
+ for (FlwAdvanceIntent detached : intentRepository.findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
+ FlwAdvanceIntent.STATE_PENDING, LocalDateTime.now())) {
+ Long id = detached.getId(), revision = detached.getRevision();
+ try { advanceService.tryAdvance(id, revision); }
+ catch (Exception failure) { advanceService.defer(id, revision, failure.toString()); }
+ }
+ }
+
+ /** Synchronizations are suspended with REQUIRES_NEW; an outer activation batch cannot leak into its child. */
+ private static ActivationBatch batch() {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) throw new IllegalStateException("Activation requires its creation transaction");
+ for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
+ if (sync instanceof ActivationBatch) return (ActivationBatch) sync;
+ }
+ ActivationBatch created = new ActivationBatch();
+ TransactionSynchronizationManager.registerSynchronization(created);return created;
+ }
+ private static final class ActivationBatch implements TransactionSynchronization {
+ final Map<String, Registration> nodes = new HashMap<>();
+ final java.util.Set<Long> tasks = new java.util.HashSet<>();
+ @Override public void beforeCommit(boolean readOnly) {
+ if (!nodes.isEmpty()) throw new IllegalStateException("Incomplete activation membership; creation transaction must roll back");
+ }
+ }
+ private static final class Registration {
+ final int total;
+ int remaining;
+ Long intentId;
+ Registration(int total) { this.total = total;this.remaining = total; }
}
}
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
index a2cf36c..85d38c0 100644
--- a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
@@ -12,14 +12,16 @@ import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
- * 回调推送日志 = 重试队列(概览 §4.9):eventId 唯一(重试不变,业务方按它幂等去重);
+ * 回调推送日志 = 重试队列(概览 §4.9;2026-10-25 R2 幂等键改复合):eventId 全订阅方复用
+ * (重试不变,业务方按它幂等去重),(event_id, reg_id) 复合唯一——同一事件对同一订阅方只建
+ * 一行,多订阅方各一行;并发 exists→insert 竞态由复合 uk 兜底(DuplicateKeyException=已建行)。
* payload_json 完整快照可重放;索引 (status, next_retry_time) 是扫描主路径。
*/
@Getter
@Setter
@Entity
@Table(name = "flw_callback_log",
- uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event", columnNames = "event_id"),
+ uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event_reg", columnNames = {"event_id", "reg_id"}),
indexes = {
@Index(name = "idx_flw_log_scan", columnList = "status, next_retry_time"),
@Index(name = "idx_flw_log_inst", columnList = "instance_id")})
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
index 5e04140..7f83a56 100644
--- a/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
@@ -21,6 +21,9 @@ import javax.persistence.UniqueConstraint;
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="source_principal_id",length=36)
+ private String sourcePrincipalId;
+
/** 租户键 */
@Column(name = "sch_id", nullable = false)
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
index edc7724..9204b9a 100644
--- a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
@@ -74,6 +74,15 @@ public class FlwDef extends BaseEntity {
@Column(name = "form_version")
private Integer formVersion;
+ /**
+ * 线级停发标志(2026-10-25 T03):1=该 flowGuid 整线停发——阻止新发起/预览,不影响在途实例
+ * (在途按实例固定 defId 原版推进)。与版本级 status(DISABLED) 正交:线级停发后 enable()
+ * 恢复的只是版本状态,线仍不可新发起;enableLine 才恢复线可用性,且不改写任何发布内容字节。
+ * 批量 UPDATE 全版本行同值,读任一行即读线状态。
+ */
+ @Column(name = "line_offline", nullable = false, columnDefinition = "INT NOT NULL DEFAULT 0")
+ private Integer lineOffline = 0;
+
/** §5.1 流程定义 JSON */
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
private String contentJson;
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
index 42be69b..b8e8143 100644
--- a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
@@ -14,8 +14,9 @@ import java.time.LocalDateTime;
/**
* 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存
- * biz_title/biz_url/biz_snapshot 快照与关联键;uk(sch_id, biz_type, biz_id) 幂等——
- * 同一业务对象一条办件,重复代发起返回原办件;驳回/撤回后重提复用同一行(R4 重启语义)。
+ * biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为
+ * (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。
+ * sourcePrincipalId=NULL / legacySlot=1 仅保留迁移前旧入口的一业务一行语义;正式模式关闭旧写入口。
*
* <p>version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件),
* 由 JPA @Version 在 flush 时自增。
@@ -24,12 +25,19 @@ import java.time.LocalDateTime;
@Setter
@Entity
@Table(name = "flw_instance",
- uniqueConstraints = @UniqueConstraint(name = "uk_flw_instance_biz",
- columnNames = {"sch_id", "biz_type", "biz_id"}),
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_flw_instance_legacy", columnNames = {"sch_id", "biz_type", "biz_id", "legacy_slot"}),
+ @UniqueConstraint(name = "uk_flw_instance_round", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "apply_round"})},
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="source_principal_id",length=36,updatable=false)
+ private String sourcePrincipalId;
+ /** 1 for legacy writers, NULL for formal commands. Migration must drop the old three-column UK. */
+ @Column(name="legacy_slot")
+ private Integer legacySlot = 1;
+
/** 租户键 */
@Column(name = "sch_id", nullable = false)
@@ -141,7 +149,7 @@ public class FlwInstance extends BaseEntity {
private String applyRequestToken;
/**
- * 当前发起轮次(1 起;随新一轮请求标识进位)。
+ * 当前发起轮次(1 起)。正式命令由服务端坐标锁内分配,不接受客户端 attempt 分轮。
*
* <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
* 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p>
@@ -149,6 +157,22 @@ public class FlwInstance extends BaseEntity {
@Column(name = "apply_round")
private Integer applyRound;
+ /**
+ * 受理时规范载荷指纹(2026-10-25 T04,SHA-256 十六进制):同 requestToken 必须同指纹——
+ * 同 ID 不同载荷是两份命令,撞在同一个 token 上按冲突拒绝,不伪装成幂等成功。
+ * 存量行(指纹列为 NULL)跳过该核对(守卫缺省是关,与 token/round 存量纪律一致)。
+ */
+ @Column(name = "apply_payload_hash", length = 64)
+ private String applyPayloadHash;
+
+ /**
+ * 事件序号计数器(2026-10-25 T05):实例内事件事实 eventSeq 的发号器。发号时在事务内
+ * 悲观锁住本行(findByIdForUpdate 当前读)再 +1——REPEATABLE READ 下对聚合做快照读会
+ * 拿到陈旧 max(FlowEngineIT 会签并发实证),行上计数器+行锁才是串行安全的。
+ */
+ @Column(name = "event_seq_counter")
+ private Integer eventSeqCounter;
+
/**
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
diff --git a/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java b/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
index 946caf3..d31fc8e 100644
--- a/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
+++ b/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
@@ -39,6 +39,11 @@ public final class FlowApplyRetryModels {
* 这样"网络重投"与"用户授权重提"在中心侧也是两个不同的事实。
*/
private Boolean retryOnly;
+ /**
+ * 期望的前一轮次(T04 服务端轮次,可选):显式重提必须声明基于第 N 轮;
+ * 服务端在坐标锁内核对并只分配 N+1,不信客户端 attempt。null=既有语义。
+ */
+ private Integer expectedPrevRound;
}
/** v2 响应:在旧 {instanceId,duplicated} 之上把"中心判出来的是哪一种命中"说清楚 */
@@ -60,6 +65,9 @@ public final class FlowApplyRetryModels {
private String branch;
/** 行上的当前轮次(消费方据此校正自己的命令表认知) */
private Integer applyRound;
+ /** 同一响应的真实状态/实例版本;分支判据保持不变。 */
+ private Integer status;
+ private Integer version;
}
/** 分支码常量(与 {@link ApplyResultV2#branch} 同值域) */
@@ -71,6 +79,11 @@ public final class FlowApplyRetryModels {
public static final String REPLAY_ONLY = "REPLAY_ONLY";
public static final String RESTARTED = "RESTARTED";
public static final String CONCURRENT_CREATED = "CONCURRENT_CREATED";
+ /**
+ * 冲突(2026-10-25 T04):同 token 异载荷、或带 token 撞上他人/其他轮次的在途与通过件——
+ * 调用方(OapiFlowService)把该分支翻译成业务异常,不伪装幂等成功。
+ */
+ public static final String CONFLICT = "CONFLICT";
private Branch() {
}
diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java
index 4760a6c..611203f 100644
--- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java
+++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java
@@ -107,6 +107,13 @@ public final class OapiModels {
@Data
public static class InstanceVo {
private Long instanceId;
+ private Integer applyRound;
+ private String requestToken;
+ /**
+ * 来源应用快照(2026-10-25 T04):UNKNOWN 恢复链按"稳定来源+requestToken"核验命令归属的
+ * 依据——查得实例但 applyAppKey 与本应用不符=他人办件,不得认领(P0-4 关闭点)。
+ */
+ private String applyAppKey;
private Integer status;
private Integer version;
private String nodeSummary;
diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java
index 03839d6..ee0a176 100644
--- a/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java
+++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java
@@ -13,6 +13,9 @@ import java.util.List;
public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog, Long> {
+ /** T05 投递幂等:同一事件事实对同一订阅方只建一行(补分发与正常分发竞争时去重) */
+ boolean existsByEventIdAndRegId(String eventId, Long regId);
+
/** 重推扫描主路径:待推/重试中且到点 */
List<FlwCallbackLog> findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc(
Integer status, LocalDateTime now);
diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java
index 72c2488..cf8552a 100644
--- a/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java
+++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java
@@ -14,7 +14,7 @@ public interface FlwCcRepository extends JpaRepository<FlwCc, Long> {
/** 抄送我的(用户面页签,走 user+sent_time 索引;appKey 可空=不按应用过滤,口径=定义归属 flw_def.app_key) */
@Query("select c from FlwCc c join FlwInstance i on i.id = c.instanceId join FlwDef d on d.id = i.defId "
+ "where c.schId = :schId and c.userId = :userId "
- + "and (:appKey is null or d.appKey = :appKey) order by c.sentTime desc")
+ + "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) order by c.sentTime desc")
Page<FlwCc> pageCc(@Param("schId") Long schId, @Param("userId") Integer userId,
@Param("appKey") String appKey, Pageable pageable);
diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java
index 83f11b7..c582182 100644
--- a/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java
+++ b/src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java
@@ -32,12 +32,22 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
List<FlwDef> findBySchIdOrderByUpdateTimeDesc(Long schId);
- /** 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本 */
- @Query("select d from FlwDef d where d.schId = :schId and d.status = 1 "
+ /**
+ * 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本。
+ * 2026-10-25 T03:外层追加 {@code lineOffline=0}——线级停发的整线不出现在新发起预览源,
+ * 且不存在"停发后回退旧版"的形态(内层仍是该线已发布最高版本,停发不改变版本事实)。
+ */
+ @Query("select d from FlwDef d where d.schId = :schId and d.status = 1 and d.lineOffline = 0 "
+ "and d.version = (select max(d2.version) from FlwDef d2 where d2.schId = d.schId "
+ "and d2.flowGuid = d.flowGuid and d2.status = 1) order by d.updateTime desc")
List<FlwDef> findPublishedLatestPerGuid(@Param("schId") Long schId);
+ /** 线级停发/恢复(T03):批量 UPDATE 全版本行同值;返回受影响行数 */
+ @org.springframework.data.jpa.repository.Modifying
+ @Query("update FlwDef d set d.lineOffline = :offline where d.schId = :schId and d.flowGuid = :flowGuid")
+ int updateLineOffline(@Param("schId") Long schId, @Param("flowGuid") String flowGuid,
+ @Param("offline") Integer offline);
+
/** 在途统计(议题二④):一次取回多条流程线的全部版本行 */
List<FlwDef> findBySchIdAndFlowGuidIn(Long schId, Collection<String> flowGuids);
}
diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java
index 1cc57d6..05e6cd0 100644
--- a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java
+++ b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java
@@ -12,8 +12,35 @@ import java.util.List;
import java.util.Optional;
public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long> {
+ @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE)
+ @Query("select i from FlwInstance i where i.id=:id")
+ Optional<FlwInstance> findFormalPreviousForUpdate(@Param("id") Long id);
+
+
+ /**
+ * T05 事件序号发号(2026-10-25 FlowEngineIT 会签并发实证三轮修正,终形态):
+ * ①FOR UPDATE 标量当前读发号(取行 X 锁,任何隔离级别都看到已提交最新值)→
+ * ②按读到的值回写计数器(值已在我锁内计算,无竞态)→③事实行按该值落库。
+ * 纯快照读 max+1 会撞 uk;实体 merge 会触发 @Version 冲突;自增 UPDATE 后普通 SELECT
+ * 的读回在并发窗口同样不可靠——FOR UPDATE 标量读是唯一两全形态。
+ */
+ @org.springframework.data.jpa.repository.Query(
+ value = "select coalesce(event_seq_counter, 0) + 1 from flw_instance where id = :id for update",
+ nativeQuery = true)
+ Integer nextEventSeqForUpdate(@Param("id") Long id);
+
+ @org.springframework.data.jpa.repository.Modifying
+ @org.springframework.data.jpa.repository.Query(
+ value = "update flw_instance set event_seq_counter = :v where id = :id", nativeQuery = true)
+ int setEventSeqCounter(@Param("id") Long id, @Param("v") Integer v);
+
+ Optional<FlwInstance> findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(Long school, String source, String type, String biz);
+
+ @Query("select i from FlwInstance i where i.schId=:school and i.sourcePrincipalId=:source and (:bizType is null or i.bizType=:bizType) and (:status is null or i.status=:status) order by i.id desc")
+ Page<FlwInstance> pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable);
/** biz uk 幂等主键 */
+ @Query("select i from FlwInstance i where i.schId=?1 and i.bizType=?2 and i.bizId=?3 and i.sourcePrincipalId is null")
Optional<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
List<FlwInstance> findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status);
diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java
index dc21dfc..f196414 100644
--- a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java
+++ b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java
@@ -13,6 +13,12 @@ import java.util.List;
public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
+ /** Additive D09 query: the real transfer parent is in this set. Ambiguous generations must be rejected. */
+ @Query(value="select t.id from flw_task t join flw_task n on n.id=:newTaskId "
+ + "where t.instance_id=n.instance_id and t.node_id=n.node_id and t.status=5 "
+ + "and t.assignee_id=:operatorId and t.handle_time=n.arrive_time", nativeQuery=true)
+ List<Long> findTransferParentCandidates(@Param("newTaskId") Long newTaskId, @Param("operatorId") Integer operatorId);
+
List<FlwTask> findByInstanceIdOrderByIdAsc(Long instanceId);
List<FlwTask> findByInstanceIdAndStatus(Long instanceId, Integer status);
@@ -52,10 +58,13 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
@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);
+ @Query("select t from FlwTask t join FlwInstance i on i.id=t.instanceId where t.schId=:school and i.sourcePrincipalId=:source and t.assigneeId=:user and t.status=0 and (:type is null or i.bizType=:type) order by t.id asc")
+ Page<FlwTask> pageSelfTodo(@Param("school") Long school,@Param("source") String source,@Param("user") Integer user,@Param("type") String type, Pageable pageable);
+
/** 我的待办(主路径,走 assignee 索引;appKey 可空=不按应用过滤,口径=办件定义归属应用 flw_def.app_key) */
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
+ "where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 "
- + "and (:appKey is null or d.appKey = :appKey) "
+ + "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) "
+ "order by t.arriveTime asc, t.id asc")
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
@Param("appKey") String appKey, Pageable pageable);
@@ -84,7 +93,7 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
+ "where t.schId = :schId and t.assigneeId = :assigneeId "
+ "and t.status in :statuses "
- + "and (:appKey is null or d.appKey = :appKey) "
+ + "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) "
+ "and t.id = (select max(t2.id) from FlwTask t2 where t2.instanceId = t.instanceId "
+ " and t2.assigneeId = :assigneeId and t2.status in :statuses) "
+ "order by t.handleTime desc, t.id desc")
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java
index e8d717d..1725612 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java
@@ -33,6 +33,7 @@ public class FlowCallbackAdminService {
private final FlwCallbackLogRepository logRepository;
private final CallbackPushService pushService;
private final CallbackProperties callbackProperties;
+ private final com.yunhai.flowcenter.callback.CallbackLogStore logStore;
public List<FlwCallbackReg> listRegs(Long schId) {
return regRepository.findBySchIdOrderByUpdateTimeDesc(schId);
@@ -104,7 +105,6 @@ public class FlowCallbackAdminService {
* 管理端手工标记死信(持续重试语义下死信的唯一人工入口:人工判定事件无意义时止血)。
* 标记后可经既有 rePush 复活。
*/
- @Transactional
public void markDead(Long schId, Long logId) {
FlwCallbackLog callbackLog = logRepository.findById(logId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "推送日志不存在:" + logId));
@@ -119,7 +119,6 @@ public class FlowCallbackAdminService {
}
/** 手工重推(死信/失败日志):置回待推并立即触发一次推送 */
- @Transactional
public void rePush(Long schId, List<Long> logIds) {
if (logIds == null || logIds.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要重推的日志");
@@ -128,20 +127,9 @@ public class FlowCallbackAdminService {
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());
- }
+ // 短事务提交并释放会话锁后才执行 HTTP;不能把管理端旧实体全量回写覆盖刚成功的状态。
+ for (Long id : logStore.resetForReplay(schId, logIds)) {
+ pushService.pushAsync(id);
}
}
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
index 001e9d1..da109e3 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
@@ -3,10 +3,12 @@ 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.VariableContract;
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.enums.FormBindingErrorCode;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.platform.apistd.BizException;
@@ -192,12 +194,13 @@ public class FlowDefAdminService {
boolean pairing = formUuid != null && !clearing && formVersion == null;
boolean versionOnly = formUuid == null && formVersion != null;
if (pairing || versionOnly) {
- throw new BizException(ErrorCode.PARAM_ERROR,
+ throw new BizException(formVersion == null ? FormBindingErrorCode.VERSION_REQUIRED
+ : FormBindingErrorCode.INVALID_COORDINATE,
"关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid="
+ (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion);
}
if (formUuid != null && !clearing) {
- checkedSchema(schId, formUuid.trim(), formVersion); // Validate before the first save.
+ checkedSchema(schId, formUuid.trim(), formVersion, "保存"); // Validate before the first save.
}
FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
if (formUuid != null) {
@@ -216,7 +219,7 @@ public class FlowDefAdminService {
* 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照
* 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验),
* 并做表达式<b>键查①</b>红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试);
- * 运行期对 form-center 零依赖。form_uuid 为空(存量线/Mode C)行为逐字不变。 */
+ * 运行期对 form-center 零依赖;新发布的无绑定版本必须显式声明 variables。 */
@Transactional
public FlwDef publish(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
@@ -227,86 +230,33 @@ public class FlowDefAdminService {
throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿");
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
- DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
- Map<String, Object> snapshot;
- try {
- // 管理链始终使用确切版本;缺版本须重新选择,不回落当前发布版。
- snapshot = checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion());
- } catch (FormCenterSchemaClient.SchemaUnavailableException e) {
- throw new BizException(ErrorCode.PARAM_ERROR,
- "关联表单快照拉取失败,发布已阻断(可重试):" + e.getMessage());
- } catch (FormCenterSchemaClient.SchemaContractViolationException e) {
- throw new BizException(ErrorCode.PARAM_ERROR,
- "关联表单版本核验失败,发布已阻断(重试无意义,请重新选择表单与版本):" + e.getMessage());
- }
- List<String> problems = new ArrayList<>();
- validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
- validateExpressionTypesAndDomains(model, snapshot, problems);
- if (!problems.isEmpty()) {
- throw new BizException(ErrorCode.PARAM_ERROR,
- "发布校验失败(表达式三查):\n" + String.join("\n", problems));
- }
- def.setContentJson(embedFormContract(def.getContentJson(), snapshot));
+ Map<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
+ def.getFormVersion(), "发布");
+ model.variables = VariableContract.fromSnapshot(snapshot);
+ VariableContract.validateDefinition(model, true);
+ DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
+ def.setContentJson(embedFormContract(def.getContentJson(), snapshot, model.variables));
+ } else if (def.getFormVersion() != null) {
+ throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
+ }
+ if (def.getFormUuid() == null || def.getFormUuid().trim().isEmpty()) {
+ VariableContract.validateDefinition(model, true);
+ DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
- /**
- * 表达式键查①(C1 三查之一,红牌):对每条 EXPRESSION 判断的出边表达式,
- * 变量集 ⊆ 快照字段 keys ∪ 内置变量({@link DefValidator#EXPRESSION_BUILTINS})。
- * 不执行表达式、不改沙箱(口径与 v2 方案 §4.7 一致)。
- */
- private void validateExpressionKeysAgainstSnapshot(FlowDefModel model, Map<String, Object> snapshot,
- List<String> problems) {
- @SuppressWarnings("unchecked")
- List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
- java.util.Set<String> fieldKeys = new java.util.HashSet<>();
- if (fields != null) {
- for (Map<String, Object> f : fields) {
- Object k = f.get("key");
- if (k != null) {
- fieldKeys.add(String.valueOf(k));
- }
- }
- }
- if (fieldKeys.isEmpty()) {
- problems.add("关联表单快照无字段(formContract.fields 为空):请检查表单是否已发布");
- return;
- }
- for (FlowDefModel.Node n : model.nodes) {
- if (n == null || !n.expressionJudge()) {
- continue;
- }
- for (FlowDefModel.Edge e : model.outEdges(n.id)) {
- if (e.condition == null || e.condition.trim().isEmpty()) {
- continue;
- }
- List<String> vars;
- try {
- vars = com.yunhai.flowcenter.engine.ExpressionEvaluator.variableNames(e.condition.trim());
- } catch (Exception ex) {
- // 语法错已由 DefValidator 拦下,这里理论上到不了;防御口径跳过
- continue;
- }
- for (String v : vars) {
- if (!fieldKeys.contains(v) && !DefValidator.EXPRESSION_BUILTINS.contains(v)) {
- problems.add("节点「" + n.name + "」出口「" + e.name + "」表达式变量 " + v
- + " 不在关联表单字段集内(快照 " + fieldKeys.size() + " 个字段)");
- }
- }
- }
- }
- }
-
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
- private String embedFormContract(String contentJson, Map<String, Object> snapshot) {
+ private String embedFormContract(String contentJson, Map<String, Object> snapshot,
+ List<FlowDefModel.Variable> variables) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.set("formContract", objectMapper.valueToTree(snapshot));
+ root.set("variables", objectMapper.valueToTree(variables));
return objectMapper.writeValueAsString(root);
} catch (BizException e) {
throw e;
@@ -315,107 +265,44 @@ public class FlowDefAdminService {
}
}
- // ===================== 三查②③(2026-10-20 C1 第一刀余量项) =====================
-
- /** 数值桶:可与数值字面量比大小的快照字段类型 */
- private static final java.util.Set<String> NUMERIC_TYPES =
- new java.util.HashSet<>(java.util.Arrays.asList("number"));
- /** 值域字面量型(enum 域查③适用):options 收进快照 enum 的类型 */
- private static final java.util.Set<String> ENUM_TYPES =
- new java.util.HashSet<>(java.util.Arrays.asList("select", "radio"));
-
- /** `字段 op '字面量'` / `字段 op 数值`(op 含两侧空格容忍) */
- private static final java.util.regex.Pattern FIELD_STR_EQ = java.util.regex.Pattern.compile(
- "([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=)\\s*'([^']*)'");
- private static final java.util.regex.Pattern FIELD_NUM_CMP = java.util.regex.Pattern.compile(
- "([A-Za-z_][A-Za-z0-9_]*)\\s*(>=|<=|>|<)\\s*(-?\\d+(\\.\\d+)?)");
- private static final java.util.regex.Pattern NUM_FIELD_CMP = java.util.regex.Pattern.compile(
- "(-?\\d+(\\.\\d+)?)\\s*(>=|<=|>|<)\\s*([A-Za-z_][A-Za-z0-9_]*)");
+ /** 停用:不可新发起,存量跑完 */
+ @Transactional
+ public void offline(Long schId, Long id) {
+ FlwDef def = requireDef(schId, id);
+ if (def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "草稿不能停用后启用,请先通过发布校验");
+ }
+ def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
+ defRepository.save(def);
+ }
/**
- * 类型桶查②红牌 + 域查③红牌(<b>启发式实现</b>:正则模式匹配字面量比较形态,非 AST——
- * v2 §4.7 保守边界:不执行表达式、不做隐式转换,只拦"数值比较 × 非数值字段"与
- * "枚举等值 × 字面量不在值域"两类高发错配;不命中的形态留给运行期既有挂起纪律)。
+ * 线级停发(2026-10-25 T03):整条 flowGuid 停止新发起/预览,在途实例按各自固定 defId 原版跑完。
+ * 与版本级 {@link #offline} 正交(可叠加);批量 UPDATE 全版本行,读任一行即读线状态。
+ * 不改写任何发布内容字节,恢复走 {@link #enableLine}。
*/
- @SuppressWarnings("unchecked")
- private void validateExpressionTypesAndDomains(FlowDefModel model, Map<String, Object> snapshot,
- List<String> problems) {
- List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
- if (fields == null || fields.isEmpty()) {
- return;
- }
- Map<String, Map<String, Object>> fieldByKey = new HashMap<>();
- for (Map<String, Object> f : fields) {
- Object k = f.get("key");
- if (k != null) {
- fieldByKey.put(String.valueOf(k), f);
- }
- }
- for (FlowDefModel.Node n : model.nodes) {
- if (n == null || !n.expressionJudge()) {
- continue;
- }
- for (FlowDefModel.Edge e : model.outEdges(n.id)) {
- if (e.condition == null || e.condition.trim().isEmpty()) {
- continue;
- }
- String where = "节点「" + n.name + "」出口「" + e.name + "」";
- String expr = e.condition.trim();
- // ② 类型桶查:数值比较算子作用于非数值字段 → 红牌
- java.util.regex.Matcher m = FIELD_NUM_CMP.matcher(expr);
- while (m.find()) {
- checkNumericBucket(where, m.group(1), m.group(2), fieldByKey, problems);
- }
- m = NUM_FIELD_CMP.matcher(expr);
- while (m.find()) {
- checkNumericBucket(where, m.group(4), m.group(3), fieldByKey, problems);
- }
- // ② 类型桶查(字符串侧):文本字面量等值比较数值字段 → 红牌
- // ③ 域查:枚举字段等值比较的字面量 ∉ 快照 enum 值域 → 红牌
- m = FIELD_STR_EQ.matcher(expr);
- while (m.find()) {
- String key = m.group(1);
- String op = m.group(2);
- String literal = m.group(3);
- Map<String, Object> field = fieldByKey.get(key);
- if (field == null) {
- continue; // 内置变量或未知键:①已拦/不属本查
- }
- String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
- if (NUMERIC_TYPES.contains(type)) {
- problems.add(where + ":字段 " + key + " 是 number 类型,不能与字符串字面量 '"
- + literal + "' 做 " + op + " 比较(类型桶查②)");
- } else if (ENUM_TYPES.contains(type) && "==".equals(op)) {
- List<String> enums = (List<String>) field.get("enum");
- if (enums != null && !enums.isEmpty() && !enums.contains(literal)) {
- problems.add(where + ":字段 " + key + " 的值域是 " + enums
- + ",字面量 '" + literal + "' 不在其中(域查③)");
- }
- }
- }
- }
- }
+ @Transactional
+ public int offlineLine(Long schId, String flowGuid) {
+ requireLine(schId, flowGuid);
+ return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
}
- private void checkNumericBucket(String where, String key, String op,
- Map<String, Map<String, Object>> fieldByKey, List<String> problems) {
- Map<String, Object> field = fieldByKey.get(key);
- if (field == null) {
- return;
- }
- String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
- if (type != null && !NUMERIC_TYPES.contains(type)) {
- problems.add(where + ":字段 " + key + " 是 " + type + " 类型,不能做数值比较 "
- + key + " " + op + " 数字(类型桶查②)");
- }
+ /**
+ * 线级恢复(T03):只恢复线的可用性——line_offline 归零;各版本 status/发布内容/版本指针
+ * 一律不动(停发期间发布新版不受影响,恢复后新发起即用当时最高已发布版)。
+ */
+ @Transactional
+ public int enableLine(Long schId, String flowGuid) {
+ requireLine(schId, flowGuid);
+ return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
}
- /** 停用:不可新发起,存量跑完 */
- @Transactional
- public void offline(Long schId, Long id) {
- FlwDef def = requireDef(schId, id);
- def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
- defRepository.save(def);
+ private void requireLine(Long schId, String flowGuid) {
+ if (flowGuid == null || flowGuid.trim().isEmpty()
+ || defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
+ .isPresent()) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
+ }
}
/**
@@ -435,22 +322,38 @@ public class FlowDefAdminService {
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
- checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion());
+ checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "启用");
+ } else if (def.getFormVersion() != null) {
+ throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
- private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version) {
- if (version == null || version < 1) {
- throw new BizException(ErrorCode.PARAM_ERROR, "关联表单必须选择确切的正整数版本,请重新选择");
+ private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version, String operation) {
+ if (version == null) {
+ throw new BizException(FormBindingErrorCode.VERSION_REQUIRED, "关联表单缺少明确版本,"
+ + operation + "已阻断,请重新选择表单与版本");
+ }
+ if (version < 1 || uuid == null || uuid.trim().isEmpty()) {
+ throw new BizException(FormBindingErrorCode.INVALID_COORDINATE,
+ "关联表单必须选择确切的正整数版本," + operation + "已阻断,请重新选择");
}
try {
return formCenterSchemaClient.fetchSchema(schId, uuid, version);
- } catch (FormCenterSchemaClient.SchemaUnavailableException
- | FormCenterSchemaClient.SchemaContractViolationException e) {
- throw new BizException(ErrorCode.PARAM_ERROR, "关联表单核验失败,操作已阻断:" + e.getMessage());
+ } catch (FormCenterSchemaClient.SchemaUnavailableException e) {
+ throw new BizException(FormBindingErrorCode.SCHEMA_UNAVAILABLE,
+ "关联表单快照拉取失败," + operation + "已阻断(可重试)", e);
+ } catch (FormCenterSchemaClient.SchemaContractViolationException e) {
+ throw new BizException(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION,
+ "关联表单版本核验失败," + operation + "已阻断(请重新选择表单与版本)", e);
+ } catch (FormCenterSchemaClient.SchemaResourceUnavailableException e) {
+ throw new BizException(FormBindingErrorCode.RESOURCE_UNAVAILABLE,
+ "关联表单或指定发布版本不存在或不可读," + operation + "已阻断", e);
+ } catch (FormCenterSchemaClient.SchemaAccessDeniedException e) {
+ throw new BizException(FormBindingErrorCode.ACCESS_DENIED,
+ "关联表单访问身份或授权被拒绝," + operation + "已阻断,请检查授权", e);
}
}
@@ -509,7 +412,12 @@ public class FlowDefAdminService {
}
def.setName(cleanName);
def.setAppKey(cleanAppKey);
- def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName));
+ // 发布封存(2026-10-25 T03):非草稿版本的执行文档不可变——行 name 照改(目录元数据),
+ // content_json 顶层 name 保持发布时字节,消除"已发布文档被原地改写"的旁路
+ //(审计 E02:updateMeta 曾同步改已发布 contentJson 顶层 name)。
+ if (def.getStatus() != null && def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
+ def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName));
+ }
try {
return defRepository.save(def);
} catch (DataIntegrityViolationException e) {
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
index 8f8cdcc..0f54436 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
@@ -24,7 +24,8 @@ import java.util.HashMap;
import java.util.Map;
/**
- * 办件生命周期:代发起(幂等 + R4 重启重提)、撤回。
+ * 引擎实例生命周期与迁移前代发起。正式命令通过 create/startFormalInstance 建立独立轮次实例,
+ * 命令受理、坐标锁和轮次分配归 FormalApplyCommandService;下述 R4 复用仅描述旧入口。
*
* <p><b>R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿)</b>:biz uk 命中已终态办件
* (30 已拒绝 / 40 已撤回)时复用同一办件行"重启推进"——不新建、不报错:定义刷新为当前
@@ -63,6 +64,13 @@ public class FlowInstanceService {
public final String applyAppCode;
/** 发起时指派快照 JSON(oapi apply 校验后渲染;null=无指派) */
public final String assignJson;
+
+ /**
+ * 受理载荷指纹(2026-10-25 T04,SHA-256 hex;<b>非构造参数</b>——13 参构造被
+ * {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值;
+ * null=存量调用方,指纹核对缺省关)。
+ */
+ public String payloadHash;
}
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
@@ -138,6 +146,10 @@ public class FlowInstanceService {
if (round != null) {
instance.setApplyRound(round);
}
+ if (cmd.payloadHash != null) {
+ // 新一轮命令重新取指纹(新轮=新命令,指纹跟随最新受理载荷)
+ instance.setApplyPayloadHash(cmd.payloadHash);
+ }
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instance.setFinishTime(null);
@@ -297,6 +309,16 @@ public class FlowInstanceService {
public final String requestToken;
public final Integer attempt;
public final boolean retryOnly;
+ /**
+ * 期望的前一轮次(2026-10-25 T04 服务端轮次收口,可选):显式申报"我基于第 N 轮重提"。
+ * 非空时:行轮次 ≠ N ⇒ STALE_ROUND(不重启);通过则服务端分配 round=N+1,
+ * <b>不信客户端 attempt</b>。null=既有客户端 attempt 语义(存量兼容)。
+ */
+ public final Integer expectedPrevRound;
+
+ public ApplyRetryPolicy(String requestToken, Integer attempt, boolean retryOnly) {
+ this(requestToken, attempt, retryOnly, null);
+ }
}
/** 守卫化结果:除 instanceId/duplicated 外,把"命中的是哪一种情形"落成稳定分支码 */
@@ -345,6 +367,15 @@ public class FlowInstanceService {
// 新一轮授权重提(attempt≥2)才走得动 R4——存量行的守卫缺省是**关**而不是开。
int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
if (token != null && token.equals(existing.getApplyRequestToken())) {
+ // T04(2026-10-25):同 token 必须同指纹——同 ID 不同载荷是两份命令撞同一 token,
+ // 按冲突拒绝,不伪装成幂等成功。存量行(指纹 NULL)或请求无指纹时跳过(守卫缺省关)。
+ if (existing.getApplyPayloadHash() != null && cmd.payloadHash != null
+ && !existing.getApplyPayloadHash().equals(cmd.payloadHash)) {
+ log.warn("[flow-center] 同请求标识但载荷指纹不一致(冲突拒绝):instance={} biz={}/{}",
+ existing.getId(), cmd.bizType, cmd.bizId);
+ return new GuardedOutcome(existing.getId(), true,
+ FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
+ }
log.info("[flow-center] 同请求标识重投命中,只回读现状不重启:instance={} biz={}/{} status={}",
existing.getId(), cmd.bizType, cmd.bizId, existing.getStatus());
return new GuardedOutcome(existing.getId(), true,
@@ -353,6 +384,15 @@ public class FlowInstanceService {
int status = existing.getStatus();
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
+ // T04:带 token 的请求撞上在途/通过件且 token 不符=另一份命令(审计 P0-2:
+ // "10/20 即使异命令也回 IDEMPOTENT"是把他人命令的成功当自己的)。无 token 的
+ // 存量调用方保持 IDEMPOTENT(守卫缺省关)。
+ if (token != null) {
+ log.warn("[flow-center] 在途/通过件命中且请求 token 不符(冲突拒绝):instance={} biz={}/{}",
+ existing.getId(), cmd.bizType, cmd.bizId);
+ return new GuardedOutcome(existing.getId(), true,
+ FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
+ }
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.IDEMPOTENT, existing.getApplyRound());
}
@@ -363,21 +403,52 @@ public class FlowInstanceService {
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.REPLAY_ONLY, existing.getApplyRound());
}
- int incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt;
- if (incomingRound <= currentRound) {
- log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}",
- existing.getId(), currentRound, incomingRound);
- return new GuardedOutcome(existing.getId(), true,
- FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
+ // T04 服务端轮次:显式 expectedPrevRound 时——行轮次必须恰为 N(否则陈旧),服务端只分配 N+1;
+ // 未显式时保持客户端 attempt 语义(存量兼容)。
+ Integer expectedPrev = policy == null ? null : policy.expectedPrevRound;
+ int incomingRound;
+ if (expectedPrev != null) {
+ if (expectedPrev != currentRound) {
+ log.warn("[flow-center] 重提前置轮次不符(不重启):instance={} 行轮次={} 期望前轮={}",
+ existing.getId(), currentRound, expectedPrev);
+ return new GuardedOutcome(existing.getId(), true,
+ FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
+ }
+ incomingRound = currentRound + 1;
+ } else {
+ incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt;
+ if (incomingRound <= currentRound) {
+ log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}",
+ existing.getId(), currentRound, incomingRound);
+ return new GuardedOutcome(existing.getId(), true,
+ FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
+ }
}
restart(existing, cmd, token, incomingRound);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.RESTARTED, incomingRound);
}
- /** 新建实例(与旧 apply 同一段代码形状:不复制第二份推进逻辑) */
+ /** Formal command caller must hold the coordinate lock in the same transaction. */
+ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
+ public FlwInstance createFormalInstance(ApplyCommand cmd, String token, Integer round, String source) {
+ return createInstance(cmd, token, round, source);
+ }
+
+ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
+ public void startFormalInstance(FlwInstance instance, ApplyCommand cmd) {
+ flowEngine.startAdvance(instance, cmd.def);
+ publishSubmitted(instance);
+ }
+
private void createInstance(ApplyCommand cmd, String token, Integer round) {
+ createInstance(cmd, token, round, null);
+ }
+
+ private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) {
FlwInstance instance = new FlwInstance();
+ instance.setSourcePrincipalId(source);
+ instance.setLegacySlot(source == null ? 1 : null);
instance.setSchId(cmd.schId);
instance.setDefId(cmd.def.getId());
instance.setDefCode(cmd.def.getCode());
@@ -393,6 +464,7 @@ public class FlowInstanceService {
instance.setApplyAppCode(cmd.applyAppCode);
instance.setApplyRequestToken(token);
instance.setApplyRound(round);
+ instance.setApplyPayloadHash(cmd.payloadHash);
instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName);
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
@@ -400,8 +472,11 @@ public class FlowInstanceService {
instanceRepository.saveAndFlush(instance);
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
- flowEngine.startAdvance(instance, cmd.def);
- publishSubmitted(instance);
+ if (source == null) {
+ flowEngine.startAdvance(instance, cmd.def);
+ publishSubmitted(instance);
+ }
+ return instance;
}
/**
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java
index 91c6b07..63e90d6 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java
@@ -162,15 +162,18 @@ public class FlowRouterAdminService {
}
/**
- * 重跑路由(F5-4 僵尸逃生门):useLatestDef=true 时 def 改取该 flowGuid 当前已发布最高版本,
- * 办件 defId/defCode/defVersion 快照同步刷新(与 R4 重启同语义,只刷定义三列,不动业务快照/
- * 状态/时间),trace 落一行留痕(保留节点 id=reroute-def,nodeName 载明 v旧→v新)。
- * 锚定 128/129 僵尸案例:办件挂在 v1 的 CONDITION 节点、修好的表达式已发布为 v2,按 def 快照
- * 重跑永远打转——此开关让管理员一键切到 v2 续走。在途 CONDITION 节点在新定义不存在时
- * 按人类可读业务错拒绝(不猜测节点映射)。useLatestDef=false 现行为逐字不变。
+ * 重跑路由(挂起恢复唯一入口,§4.4)。2026-10-25 T03 在途锁版收口:useLatestDef=true 的
+ * "切到最新发布版续走"旁路已按"在途实例固定原版本"原则停用——新发布版只影响新申报;
+ * 挂起件按原定义重跑(修复表达式后按<b>原版本</b>重新发布或由发起方重新申报)。
+ * 保留参数与显式拒绝(不静默忽略),旧调用方拿到人类可读业务错。
*/
@Transactional
public void reroute(Long schId, Long instanceId, boolean useLatestDef) {
+ if (useLatestDef) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "在途换版已停用(在途实例固定使用原版本,新版本只影响新申报);"
+ + "请修复表达式后按原版本重新发布,或让发起方终态后重新申报");
+ }
FlwInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
if (!instance.getSchId().equals(schId)) {
@@ -183,58 +186,15 @@ public class FlowRouterAdminService {
if (def == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失");
}
- if (useLatestDef) {
- def = refreshToLatestPublishedDef(instance, def);
- }
String conditionNode = findParkedCondition(instance, def);
if (conditionNode == null) {
- throw new BizException(ErrorCode.PARAM_ERROR, useLatestDef
- ? "在途条件节点在最新已发布定义中不存在(节点已被改版移除/改型),无法按新定义重跑"
- : "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
}
flowEngine.rerouteCondition(instance, def, conditionNode);
instanceRepository.save(instance);
}
- /** 取 flowGuid 当前已发布最高版本并刷新办件定义快照(仅三列;已是最新则原样返回不落留痕) */
- private FlwDef refreshToLatestPublishedDef(FlwInstance instance, FlwDef current) {
- FlwDef latest = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
- instance.getSchId(), current.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
- .orElse(null);
- if (latest == null) {
- throw new BizException(ErrorCode.PARAM_ERROR, "该流程当前无已发布版本,无法按最新定义重跑");
- }
- if (latest.getId().equals(instance.getDefId())) {
- return latest;
- }
- instance.setDefId(latest.getId());
- instance.setDefCode(latest.getCode());
- instance.setDefVersion(latest.getVersion());
- appendRerouteDefTrace(instance, current.getVersion(), latest.getVersion());
- return latest;
- }
-
- /**
- * 定义切换留痕行(同 FlowInstanceService.appendTraceEvent 形态;彼方法私有且该类冻结,不共用):
- * status=6 + 保留节点 id,nodeName 载明版本迁移;不参与引擎任何判断(同 submit/withdraw 行)。
- */
- private void appendRerouteDefTrace(FlwInstance instance, Integer fromVersion, Integer toVersion) {
- java.time.LocalDateTime now = java.time.LocalDateTime.now();
- com.yunhai.flowcenter.entity.FlwTask row = new com.yunhai.flowcenter.entity.FlwTask();
- row.setInstanceId(instance.getId());
- row.setSchId(instance.getSchId());
- row.setNodeId(FlwEnums.TaskStatus.NODE_REROUTE_DEF);
- row.setNodeName("按新版定义重跑(v" + fromVersion + "→v" + toVersion + ")");
- // assignee=0:管理端动作无自然办理人,落 0 不产生待办(同发起行脏数据兜底口径)
- row.setAssigneeId(0);
- row.setAssigneeName("管理端");
- row.setStatus(FlwEnums.TaskStatus.SUBMITTED.getCode());
- row.setArriveTime(now);
- row.setHandleTime(now);
- row.setUrgeCount(0);
- taskRepository.save(row);
- }
-
/** 在途节点里找 CONDITION(多个时取第一个;正常挂起只会有一个) */
private String findParkedCondition(FlwInstance instance, FlwDef def) {
String cur = instance.getCurrentNodes();
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java
index e397b50..ad9e74e 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java
@@ -246,7 +246,7 @@ public class FlowTaskService {
/**
* 移交(B4):办理权转给他人。当前任务置 TRANSFERRED 留痕,同节点新建一条待办给目标人
* (deadline 沿用原任务);推进监听器因新待办 pending>0 自然不放行,无需改推进逻辑。
- * 目标人存在性由 AssigneeResolver 严格校验(查不到必须拒绝,否则产生僵尸待办)。
+ * 目标人资格由 AssigneeResolver 终审(存在/启用/本校,D11)——查不到必须拒绝,否则产生僵尸待办。
*/
@Transactional
public void transfer(Long schId, Long taskId, Integer toUserId, String reason, Integer operatorId) {
@@ -259,7 +259,9 @@ public class FlowTaskService {
}
FlwInstance instance = instanceRepository.findById(task.getInstanceId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
- String toUserName = assigneeResolver.requireUserName(toUserId);
+ // D11 移交终审:目标必须存在/启用/属于本校;全部校验在抢占原任务之前完成,
+ // 失败不会先替换人员或改写任务状态。
+ String toUserName = assigneeResolver.requireEligibleAssigneeName(task.getSchId(), toUserId);
LocalDateTime now = LocalDateTime.now();
String fromLabel = task.getNodeName() == null ? task.getNodeId() : task.getNodeName();
diff --git a/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
index faa1f5f..dbb2fb6 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
@@ -3,6 +3,9 @@ package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
+import com.yunhai.platform.openapi.core.OpenApiException;
+import com.yunhai.platform.utils.exception.DException;
+import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -14,10 +17,10 @@ import java.util.Map;
/**
* form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。<br>
- * GET {目录解析 form-center}/oapi/form/schema?schId=&amp;uuid= ——按 form_uuid 拉当前已发布版 schema
- * (T11 建立的 by-uuid 形态;version 缺省=当前发布版)。调用形态对齐 {@link RouteJudgeClient}:
+ * GET {目录解析 form-center}/oapi/form/schema?schId=&amp;uuid=&amp;version= ——只取明确发布版本。
+ * 保存、发布与启用共用精确坐标契约;无 UUID-only 回退。调用形态对齐 YschOpenCall:
* 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。<br>
- * <b>只在发布期调用</b>(拉取失败=发布阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
+ * <b>只在管理期调用</b>(拉取失败=阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
* audience 解析:<b>契约 v2 C5/G3 起是字符串数组</b>(breaking,T13 切换)——本客户端按数组读取,
* 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。
*/
@@ -60,48 +63,28 @@ public class FormCenterSchemaClient {
}
}
- /**
- * 发布期快照:{formUuid, formVersion, fields:[{key,type,required,enum?}], audience(数组)}。
- * fields 由 groups 扁平化(分组只是展示结构,表达式键域与分组无关);
- * select/radio 类字段的 options 值域收进 enum(供域查③,本轮仅透传)。
- */
- public Map<String, Object> fetchSchema(Long schId, String formUuid) {
- RuntimeException last = null;
- for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
- try {
- Map<String, String> query = new HashMap<>();
- query.put("schId", String.valueOf(schId));
- query.put("uuid", formUuid);
- Map<String, Object> data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class);
- if (data == null) {
- throw new IllegalStateException("schema 返回空 data");
- }
- return toSnapshot(data);
- } catch (RuntimeException e) {
- last = e;
- log.warn("[flow-center] form-center schema 快照拉取失败(第 {}/{} 次):uuid={} {}",
- attempt, MAX_ATTEMPTS, formUuid, e.toString());
- } catch (Exception e) {
- last = new IllegalStateException(e.toString(), e);
- log.warn("[flow-center] form-center schema 快照拉取异常(第 {}/{} 次):uuid={} {}",
- attempt, MAX_ATTEMPTS, formUuid, e.toString());
- }
+ /** FC 将不存在、不可读、未发布的精确版本统一拒绝,不能靠文案区分资源存在性。 */
+ public static class SchemaResourceUnavailableException extends RuntimeException {
+ public SchemaResourceUnavailableException(Throwable cause) {
+ super("表单或指定发布版本不存在或不可读", cause);
+ }
+ }
+
+ public static class SchemaAccessDeniedException extends RuntimeException {
+ public SchemaAccessDeniedException(Throwable cause) {
+ super("表单访问身份或授权被拒绝", cause);
}
- throw new SchemaUnavailableException(
- "关联表单 schema 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last);
}
/**
* 发布期快照的<b>确切版本</b>形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=},
* 并把<b>返回坐标与请求坐标逐件核对</b>——不符即 {@link SchemaContractViolationException}(不重试)。
*
- * <p>刻意不与两参 {@link #fetchSchema(Long, String)} 共用循环体:那条是 C1 第一刀的既有行为
- * ({@code FormCenterSchemaClientTest} 逐条钉住它的重试与文案),改它等于同时动两条契约。
- * 新管理链(def 带 {@code formVersion})只走本方法。</p>
+ * fields 按分组扁平化,options 值域收进 enum;没有缺省当前版本的重载。
*/
public Map<String, Object> fetchSchema(Long schId, String formUuid, Integer version) {
- if (version == null) {
- throw new IllegalArgumentException("按确切版本取快照必须给 version(缺版本=取当前发布版,那是旧口径)");
+ if (version == null || version < 1 || formUuid == null || formUuid.trim().isEmpty()) {
+ throw new IllegalArgumentException("按确切版本取快照必须给 uuid 与正整数 version");
}
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
@@ -119,6 +102,20 @@ public class FormCenterSchemaClient {
return snapshot;
} catch (SchemaContractViolationException e) {
throw e;
+ } catch (DException e) {
+ int code = e.getCode() == null ? -1 : e.getCode().getCode();
+ if (code == ErrorCode.PARAM_ERROR.getCode()) {
+ throw new SchemaResourceUnavailableException(e);
+ }
+ if (code == ErrorCode.FORBIDDEN.getCode() || code == ErrorCode.UNAUTHORIZED.getCode()) {
+ throw new SchemaAccessDeniedException(e);
+ }
+ last = e;
+ } catch (OpenApiException e) {
+ if (e.getHttpStatus() == 404) {
+ throw new SchemaResourceUnavailableException(e);
+ }
+ last = e;
} catch (RuntimeException e) {
last = e;
log.warn("[flow-center] form-center schema 精确版本拉取失败(第 {}/{} 次):uuid={} v={} {}",
@@ -137,9 +134,15 @@ public class FormCenterSchemaClient {
private static void requireCoordinate(Map<String, Object> snapshot, String formUuid, Integer version) {
Object gotUuid = snapshot.get("formUuid");
Object gotVersion = snapshot.get("formVersion");
- if (gotUuid == null || gotVersion == null
+ Integer parsedVersion;
+ try {
+ parsedVersion = gotVersion == null ? null : Integer.valueOf(String.valueOf(gotVersion));
+ } catch (NumberFormatException e) {
+ throw new SchemaContractViolationException("表单中心返回的版本不是合法整数");
+ }
+ if (gotUuid == null || parsedVersion == null || parsedVersion < 1
|| !String.valueOf(gotUuid).equals(formUuid)
- || Integer.parseInt(String.valueOf(gotVersion)) != version) {
+ || !parsedVersion.equals(version)) {
throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 "
+ formUuid + " v" + version + ",返回 " + gotUuid + " v" + gotVersion + ")");
}
diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
index d399120..2f1d870 100644
--- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
@@ -106,15 +106,114 @@ public class OapiFlowService {
this.transactionTemplate = template;
}
+ /**
+ * T01 应用使用许可闸(2026-10-25):新发起必须持有 (school, flowGuid, appId) 生效许可。
+ * setter 注入(构造被单测逐位钉住,同 transactionTemplate 先例);未装配(老单测直接 new)
+ * 视为关闭,生产装配后由 {@code yh.flow.school-activation-enforce} 控制生效。
+ * enforce 开启时 UserBase 不可达 fail-closed(见 {@link FlowUsageGrantGuard})。
+ */
+ private FlowUsageGrantGuard usageGrantGuard;
+
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ public void setUsageGrantGuard(FlowUsageGrantGuard guard) {
+ this.usageGrantGuard = guard;
+ }
+
+ /**
+ * 发起期变量终审(字段注入:保持既有构造签名)。手工构造服务时也使用同一校验器,
+ * 所有拒绝均发生在实例创建之前。
+ */
+ private ApplyVariableValidator applyVariableValidator;
+
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ public void setApplyVariableValidator(ApplyVariableValidator validator) {
+ this.applyVariableValidator = validator;
+ }
+
+ /** R1 SDK 快捷审批门禁(字段注入同上):门禁无条件 fail-closed,装配与否均不放行 */
+ private FlowActorContextGate actorContextGate;
+
+ @org.springframework.beans.factory.annotation.Autowired(required = false)
+ public void setActorContextGate(FlowActorContextGate gate) {
+ this.actorContextGate = gate;
+ }
+
+ private void requireExpressionVariablesPresent(FlwDef def, String formDataJson) {
+ ApplyVariableValidator validator = applyVariableValidator == null
+ ? new ApplyVariableValidator(objectMapper) : applyVariableValidator;
+ validator.requireVariablesPresent(
+ FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
+ }
+
private FlowInstanceService.GuardedOutcome inTransaction(
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
}
+ @Value("${flow-center.formal-command.enabled:false}")
+ private boolean formalCommandEnabled;
+
+ @org.springframework.beans.factory.annotation.Autowired
+ private FlowSourcePrincipalResolver sourcePrincipalResolver;
+ @org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwApplyCommandRepository formalCommands;
+ @org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwUsageGrantRepository formalGrants;
+
+ private void requireLegacyWriter() {
+ if (formalCommandEnabled) throw new BizException(ErrorCode.FORBIDDEN,
+ "正式命令模式已启用,请迁移到 /oapi/flow/commands;旧发起接口已关闭");
+ }
+
+ /** New-command validation only. Never call this while recovering a settled receipt. */
+ FlowInstanceService.ApplyCommand prepareFormal(Long schId, FlowSourcePrincipalResolver.Principal source,
+ com.yunhai.flowcenter.model.FormalApplyModels.Request body) {
+ BoaUser user;
+ try { user = userClient.getUserById(String.valueOf(body.getSubmitterId())); }
+ catch (RuntimeException unavailable) { throw new IllegalStateException("提交人权威查询不可用", unavailable); }
+ if (user == null || !Integer.valueOf(1).equals(user.getStatus()) || user.getSchool() == null || user.getSchool().getId() == null
+ || !schId.equals(Long.valueOf(user.getSchool().getId()))) {
+ throw new BizException(ErrorCode.FORBIDDEN, "提交人不存在或不属于该学校");
+ }
+ if (body.getAssigns() != null && !body.getAssigns().isEmpty() && body.getExpectedDefVersion() == null) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "正式指派须携带预览定义版本锚");
+ }
+ FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
+ if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
+ throw new BizException(ErrorCode.PARAM_ERROR, "流程版本已改变,请刷新后使用新的请求 ID");
+ }
+ requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
+ // D11:固定 USERS 属审批责任人,正式命令在受理阶段整体终审——任一非法即 VALIDATION_REJECTED,
+ // 不落实例、不做“过滤后继续”。运行期建任务仍由 FlowEngine/AssigneeResolver 再兜底。
+ assigneeResolver.requireEligibleFixedAssignees(schId, FlowDefModel.parse(objectMapper, def.getContentJson()));
+ String assigns = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
+ return new FlowInstanceService.ApplyCommand(schId, def, body.getBizType(), body.getBizId(),
+ body.getBizTitle(), body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
+ body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()),
+ source.appKey, source.appCode, assigns);
+ }
+
+ private boolean canReadFormalDefinition(Long school, String flow) {
+ String source = FlowOapiSourceScope.current();
+ return formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, source, 1)
+ || formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(school, source, flow, "ACCEPTED");
+ }
+
+ private void requireFormalPreviewGrant(Long school, String flow) {
+ if (!formalCommandEnabled) return;
+ FlowSourcePrincipalResolver.Principal principal = sourcePrincipalResolver.resolve(
+ (String)((org.springframework.web.context.request.ServletRequestAttributes)org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes()).getRequest().getAttribute("yh.app.auth"));
+ if (!formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, principal.id, 1)
+ || !formalActivation.isAppActivatedStrict(principal.appId, school)) {
+ throw new BizException(ErrorCode.FORBIDDEN, "没有此流程线的发起预览权限");
+ }
+ }
+
+ @org.springframework.beans.factory.annotation.Autowired private UserBaseActivationClient formalActivation;
+
/** 已发布流程列表(每条流程线取最高已发布版本) */
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
for (FlwDef def : defRepository.findPublishedLatestPerGuid(schId)) {
+ if (formalCommandEnabled && !canReadFormalDefinition(schId, def.getFlowGuid())) continue;
out.add(toSummary(def));
}
return out;
@@ -122,6 +221,7 @@ public class OapiFlowService {
/** 流程详情回显(未发布/不存在返回 null,消费方按"未绑定流程"降级) */
public OapiModels.FlowSummaryVo getFlow(Long schId, String flowGuid) {
+ if (formalCommandEnabled && !canReadFormalDefinition(schId, flowGuid)) return null;
return defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
.map(this::toSummary).orElse(null);
@@ -131,16 +231,20 @@ public class OapiFlowService {
* 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */
@Transactional
public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) {
+ requireLegacyWriter();
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()));
+ String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
+ // T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报,
+ // 必须持有效使用许可。恢复感知的分支判定与 FlowInstanceService.apply 保持同键同序。
+ requireUsageGrantLegacyApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(), body.getBizId());
+ FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
+ // T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行
+ requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
// S2.7 版本竞态(可选、opt-in):调用方回带预览时的定义版本,改版即人类可读拒绝,
// 免得选好的 nodeId/候选人在新版本里对不上而报「指派节点不存在」。不传=现状逐字不变。
if (body.getExpectedDefVersion() != null
@@ -150,7 +254,6 @@ public class OapiFlowService {
+ ",当前已发布版本 " + def.getVersion() + ")");
}
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
- String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
@@ -184,23 +287,40 @@ public class OapiFlowService {
*/
public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey,
FlowApplyRetryModels.ApplyRequestV2 body) {
+ requireLegacyWriter();
if (schId == null || body == 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()));
- if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
+ String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
+ // T01 使用许可(v2 路径):恢复感知——SAME_REQUEST/REPLAY_ONLY/IDEMPOTENT/STALE_ROUND
+ // 四类回执分支不拦(已受理命令的回执不因许可撤销丢失);CREATED/RESTARTED(新申报/新轮)
+ // 必须持有效使用许可。分支判定与 FlowInstanceService.applyGuarded 同键同序;
+ // 完整命令协议(requestId/指纹/服务端轮次)统一归 T04 重排,此处不重构。
+ requireUsageGrantGuardedApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(),
+ body.getBizId(), body.getRequestToken(), body.getAttempt(),
+ Boolean.TRUE.equals(body.getRetryOnly()));
+ FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId(
+ schId, body.getBizType(), body.getBizId()).orElse(null);
+ boolean replay = existing != null && body.getRequestToken() != null
+ && !body.getRequestToken().trim().isEmpty()
+ && body.getRequestToken().trim().equals(existing.getApplyRequestToken());
+ // 同 token 用已受理版本构造原指纹;归属和异载荷冲突仍由 applyGuarded 原守卫裁决。
+ FlwDef def = replay ? defRepository.findById(existing.getDefId())
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "原请求的定义版本不存在"))
+ : requirePublishedDefForApply(schId, body.getFlowGuid());
+ if (replay && !body.getFlowGuid().equals(def.getFlowGuid())) {
+ throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件绑定的流程线不同");
+ }
+ if (!replay) requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
+ if (!replay && body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
+ ",当前已发布版本 " + def.getVersion() + ")");
}
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
- String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
@@ -208,8 +328,11 @@ public class OapiFlowService {
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
+ // T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝
+ cmd.payloadHash = payloadHash(cmd);
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
- body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()));
+ body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()),
+ body.getExpectedPrevRound());
FlowInstanceService.GuardedOutcome outcome;
try {
@@ -218,14 +341,155 @@ public class OapiFlowService {
// 事务边界外重读:另一个并发请求已用同一业务键建好办件
return fromConcurrent(schId, cmd, policy);
}
+ requireNotConflict(outcome);
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(outcome.instanceId);
vo.setDuplicated(outcome.duplicated);
vo.setBranch(outcome.branch);
vo.setApplyRound(outcome.applyRound);
+ instanceRepository.findById(outcome.instanceId).ifPresent(current -> {
+ int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound();
+ int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound;
+ if (currentRound == outcomeRound) {
+ vo.setStatus(current.getStatus());
+ vo.setVersion(current.getVersion());
+ } // 已被另一请求推进到新轮时,让消费方按实例查询恢复,不混装两轮证据。
+
+ });
return vo;
}
+ /**
+ * 新发起的当前有效发布版解析(2026-10-25 T03 收口):最高已发布版 + 线级停发守卫——
+ * 整线停发(line_offline=1)即拒绝新发起,且不回退到更旧的已发布版本(停发不是降级)。
+ * 在途实例不受影响(推进按实例固定 defId,不经本方法)。
+ */
+ private FlwDef requirePublishedDefForApply(Long schId, String flowGuid) {
+ FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
+ schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
+ .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
+ "流程不存在或未发布:" + flowGuid));
+ if (def.getLineOffline() != null && def.getLineOffline() == 1) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "流程线已停发,暂停新发起(在途办件不受影响):" + flowGuid);
+ }
+ return def;
+ }
+
+ /**
+ * T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠
+ * 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
+ * 重启即新一轮申报。guard 未装配或未 enforce 时零行为(灰度开关,见 {@link FlowUsageGrantGuard})。
+ */
+ private void requireUsageGrantLegacyApply(Long schId, String flowGuid, String appKey,
+ String bizType, String bizId) {
+ if (usageGrantGuard == null || !usageGrantGuard.isEnforce() || appKey == null) {
+ return;
+ }
+ FlwInstance existing = instanceRepository
+ .findBySchIdAndBizTypeAndBizId(schId, bizType, bizId).orElse(null);
+ if (!legacyApplyIsReceiptOnly(existing)) {
+ usageGrantGuard.requireNewApplyGrant(schId, flowGuid, appKey);
+ }
+ }
+
+ /**
+ * T01 使用许可闸·v2 路径:与 {@code FlowInstanceService.applyGuarded/settleExisting}
+ * 同键同序的分支预判(判定逻辑见 {@link #guardedApplyIsReceiptOnly});
+ * 新申报(CREATED/RESTARTED)必须过 {@link FlowUsageGrantGuard#requireNewApplyGrant}。
+ * 完整命令协议统一归 T04。
+ */
+ private void requireUsageGrantGuardedApply(Long schId, String flowGuid, String appKey,
+ String bizType, String bizId,
+ String requestToken, Integer attempt, boolean retryOnly) {
+ if (usageGrantGuard == null || !usageGrantGuard.isEnforce() || appKey == null) {
+ return;
+ }
+ FlwInstance existing = instanceRepository
+ .findBySchIdAndBizTypeAndBizId(schId, bizType, bizId).orElse(null);
+ if (!guardedApplyIsReceiptOnly(existing, requestToken, attempt, retryOnly)) {
+ usageGrantGuard.requireNewApplyGrant(schId, flowGuid, appKey);
+ }
+ }
+
+ /**
+ * v1 回执判定:true=该请求只会命中幂等回读(不建件不重启),false=会触发新申报或 R4 重启。
+ * 与 {@code FlowInstanceService.apply(ApplyCommand)} 的分支一一对应(包私有便于规则单测钉住)。
+ */
+ static boolean legacyApplyIsReceiptOnly(FlwInstance existing) {
+ return existing != null
+ && (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
+ || existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode());
+ }
+
+ /**
+ * v2 回执判定:与 {@code applyGuarded/settleExisting} 同键同序——
+ * SAME_REQUEST(同 token)→ REPLAY_ONLY(retryOnly)→ IDEMPOTENT(在途/通过)→
+ * STALE_ROUND(请求轮次≤行轮次,缺省按 1)四类回执返回 true;CREATED/RESTARTED 返回 false。
+ */
+ static boolean guardedApplyIsReceiptOnly(FlwInstance existing, String requestToken,
+ Integer attempt, boolean retryOnly) {
+ if (existing == null) {
+ return false;
+ }
+ String token = requestToken == null ? null : requestToken.trim();
+ if (token != null && !token.isEmpty() && token.equals(existing.getApplyRequestToken())) {
+ return true;
+ }
+ if (retryOnly) {
+ return true;
+ }
+ if (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
+ || existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
+ return true;
+ }
+ int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
+ int incomingRound = attempt == null ? 1 : attempt;
+ return incomingRound <= currentRound;
+ }
+
+ /**
+ * T04:CONFLICT 分支统一翻译成业务异常——冲突不是成功,调用方(SH 恢复链)收到 4xx
+ * 走 UNKNOWN → 按来源+token 复核,不得把他人/其他轮次的在途件当自己的受理结果。
+ */
+ private static void requireNotConflict(FlowInstanceService.GuardedOutcome outcome) {
+ if (outcome != null && FlowApplyRetryModels.Branch.CONFLICT.equals(outcome.branch)) {
+ throw new BizException(ErrorCode.CUSTOM,
+ "该业务键已有在途/终态申报且请求标识不符(另一命令或另一轮次),"
+ + "请按实例查询恢复,禁止跨命令认领;终态后重新申报请走显式重提");
+ }
+ }
+
+ /**
+ * 受理载荷指纹(T04):SHA-256(流序规范化) over flowGuid|bizType|bizId|submitterId|bizTitle|
+ * bizUrl|bizSnapshot|formData|assignJson。重发链载荷逐字节同源 → 同指纹;
+ * 任何业务字段改动 → 不同指纹 → 冲突。expectedDefVersion 刻意不参与(重试期间发布新版本
+ * 不应把同一次命令误判成两份)。
+ */
+ private String payloadHash(FlowInstanceService.ApplyCommand cmd) {
+ String canon = String.join("|",
+ nullSafe(cmd.def == null ? null : cmd.def.getFlowGuid()),
+ nullSafe(cmd.bizType), nullSafe(cmd.bizId),
+ cmd.submitterId == null ? "" : String.valueOf(cmd.submitterId),
+ nullSafe(cmd.bizTitle), nullSafe(cmd.bizUrl),
+ nullSafe(cmd.bizSnapshot), nullSafe(cmd.formDataJson), nullSafe(cmd.assignJson));
+ try {
+ byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
+ .digest(canon.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(64);
+ for (byte b : digest) {
+ sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
+ }
+ return sb.toString();
+ } catch (java.security.NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+
+ private static String nullSafe(String v) {
+ return v == null ? "" : v;
+ }
+
/**
* 并发撞键后的收口:重读既有实例 → <b>先核归属</b> → 同 token 记 {@code SAME_REQUEST},
* 否则按幂等命中回读。归属不符就拒绝,绝不返回别人的办件 id。
@@ -252,8 +516,18 @@ public class OapiFlowService {
vo.setInstanceId(existing.getId());
vo.setDuplicated(true);
vo.setApplyRound(existing.getApplyRound());
- vo.setBranch(token != null && token.equals(existing.getApplyRequestToken())
- ? FlowApplyRetryModels.Branch.SAME_REQUEST : FlowApplyRetryModels.Branch.CONCURRENT_CREATED);
+ vo.setStatus(existing.getStatus());
+ vo.setVersion(existing.getVersion());
+ if (token != null && !token.equals(existing.getApplyRequestToken())) {
+ // T04:并发撞键且 token 不符=两份不同命令抢同一业务键——后到者冲突,不伪装幂等成功
+ //(旧 CONCURRENT_CREATED 分支只保留给无 token 的存量调用方)。
+ log.warn("[flow-center] 并发建件 token 不符(冲突拒绝):instance={} biz={}/{}",
+ existing.getId(), cmd.bizType, cmd.bizId);
+ throw new BizException(ErrorCode.CUSTOM,
+ "该业务键已被另一并发命令创建(请求标识不符),请按实例查询恢复,不要换 ID 重发");
+ }
+ vo.setBranch(token != null ? FlowApplyRetryModels.Branch.SAME_REQUEST
+ : FlowApplyRetryModels.Branch.CONCURRENT_CREATED);
log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}",
existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch());
return vo;
@@ -287,12 +561,45 @@ public class OapiFlowService {
* apply 时指派严格校验(方案 §5.2 五条,不变量 I7)+ 快照渲染。
* assigns 空 → null(宽松回退 D3)。任何不过 → BizException(PARAM_ERROR) 拒绝发起。
*/
+ /**
+ * T06 required 覆盖判定(包私有纯函数,规则单测钉住):定义里全部开启且必选的指派节点中,
+ * 提交项未覆盖的 nodeId 列表。空 assigns + 存在必选节点 ⇒ 全部缺失(空 assigns 不绕过)。
+ */
+ static List<String> missingRequiredAssignNodes(FlowDefModel model, List<OapiModels.AssignItem> assigns) {
+ java.util.Set<String> submitted = new java.util.HashSet<>();
+ if (assigns != null) {
+ for (OapiModels.AssignItem item : assigns) {
+ if (item.getNodeId() != null) {
+ submitted.add(item.getNodeId());
+ }
+ }
+ }
+ List<String> missing = new ArrayList<>();
+ for (FlowDefModel.Node n : model.nodes) {
+ if (n == null || !FlowDefModel.TYPE_APPROVAL.equals(n.type) || n.assignPolicy == null
+ || !Boolean.TRUE.equals(n.assignPolicy.required)) {
+ continue;
+ }
+ if (!submitted.contains(n.id)) {
+ missing.add(n.id);
+ }
+ }
+ return missing;
+ }
+
private String renderAssignJson(Long schId, FlwDef def, List<OapiModels.AssignItem> assigns,
Integer submitterId) {
+ FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
+ // T06(2026-10-25)required 覆盖终审:遍历<b>定义里全部</b>开启且必选的指派节点——
+ // 不能只遍历请求里出现的项,更不能让空 assigns 绕过 required 检查(审计 §7.4 点名)。
+ List<String> missing = missingRequiredAssignNodes(model, assigns);
+ if (!missing.isEmpty()) {
+ throw new BizException(ErrorCode.PARAM_ERROR,
+ "必选指派节点未提交人选(node=" + String.join(", ", missing) + "),请重新选择后提交");
+ }
if (assigns == null || assigns.isEmpty()) {
return null;
}
- FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>();
Set<String> seenNodeIds = new LinkedHashSet<>();
for (OapiModels.AssignItem item : assigns) {
@@ -318,6 +625,16 @@ public class OapiFlowService {
throw new BizException(ErrorCode.PARAM_ERROR,
"不允许指派发起人本人:" + item.getNodeId());
}
+ // D11:手选人先逐人资格终审(存在/启用/校域),再核候选集——任一人不合格整体拒绝,
+ // 快照与实例均不落;不能只信前端候选列表,也不能以“在候选 ID 中”替代资格。
+ List<Map<String, Object>> rows = new ArrayList<>();
+ for (Integer uid : uids) {
+ String userName = assigneeResolver.requireEligibleAssigneeName(schId, uid);
+ Map<String, Object> row = new HashMap<>();
+ row.put("userId", uid);
+ row.put("userName", userName);
+ rows.add(row);
+ }
// 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝
List<AssigneeRef> candidates = assigneeResolver.resolve(schId, node);
Set<Integer> candidateIds = new LinkedHashSet<>();
@@ -344,14 +661,6 @@ public class OapiFlowService {
"指派人数越界[" + min + "~" + max + "]:" + item.getNodeId());
}
}
- List<Map<String, Object>> rows = new ArrayList<>();
- for (Integer uid : uids) {
- String userName = assigneeResolver.requireUserName(uid);
- Map<String, Object> row = new HashMap<>();
- row.put("userId", uid);
- row.put("userName", userName);
- rows.add(row);
- }
snapshot.put(item.getNodeId(), rows);
}
try {
@@ -366,13 +675,11 @@ public class OapiFlowService {
* 节点级解析异常降级空列表(I6)。
*/
public OapiModels.ApplyPreviewVo applyPreview(Long schId, String flowGuid, Integer submitterId) {
+ requireFormalPreviewGrant(schId, flowGuid);
if (schId == null || flowGuid == null || submitterId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId");
}
- FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
- schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
- .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
- "流程不存在或未发布:" + flowGuid));
+ FlwDef def = requirePublishedDefForApply(schId, flowGuid);
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo();
vo.setFlowGuid(def.getFlowGuid());
@@ -421,7 +728,7 @@ public class OapiFlowService {
/** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */
public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) {
- FlwInstance instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId)
+ FlwInstance instance = findByBizForOapi(schId, bizType, bizId)
.orElse(null);
return instance == null ? null : toInstanceVo(instance);
}
@@ -437,8 +744,9 @@ public class OapiFlowService {
if (schId == null || assigneeId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId");
}
- Page<FlwTask> p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType),
- PageRequest.of(normPage(page), normSize(size)));
+ Page<FlwTask> p = formalCommandEnabled
+ ? taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)))
+ : taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)));
List<Long> instanceIds = p.getContent().stream()
.map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList());
Map<Long, FlwInstance> instances = instanceIds.isEmpty()
@@ -457,8 +765,9 @@ public class OapiFlowService {
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
}
- Page<FlwInstance> p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status,
- PageRequest.of(normPage(page), normSize(size)));
+ Page<FlwInstance> p = formalCommandEnabled
+ ? instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)))
+ : instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)));
List<OapiModels.InstanceItemVo> items = p.getContent().stream()
.map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList());
return toPageResult(p.getTotalElements(), items, page, size);
@@ -490,8 +799,7 @@ public class OapiFlowService {
OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo();
vo.setBizType(cleanBizType);
vo.setBizId(cleanBizId);
- FlwInstance instance = instanceRepository
- .findBySchIdAndBizTypeAndBizId(schId, cleanBizType, cleanBizId).orElse(null);
+ FlwInstance instance = findByBizForOapi(schId, cleanBizType, cleanBizId).orElse(null);
if (instance == null) {
return vo;
}
@@ -546,6 +854,7 @@ public class OapiFlowService {
@Transactional
public FlwTask approveTask(Long schId, Long taskId, Integer operatorId, String operatorName,
String opinion, String edgeName) {
+ requireActorProof(schId, taskId, operatorId);
requireOperator(schId, taskId, operatorId, operatorName);
return taskService.handle(schId, taskId, true, opinion, operatorId, edgeName);
}
@@ -554,10 +863,27 @@ public class OapiFlowService {
@Transactional
public FlwTask rejectTask(Long schId, Long taskId, Integer operatorId, String operatorName,
String opinion) {
+ requireActorProof(schId, taskId, operatorId);
requireOperator(schId, taskId, operatorId, operatorName);
return taskService.handle(schId, taskId, false, opinion, operatorId);
}
+ /**
+ * R1(Codex D03):oapi 审批统一过 ActorContext 门——门禁本身无条件 fail-closed;
+ * 未装配(手工构造的老单测)时同样拒绝,不存在"未装配=直通"豁免。
+ */
+ private void requireActorProof(Long schId, Long taskId, Integer operatorId) {
+ if (actorContextGate != null) {
+ actorContextGate.requireActorProof(schId, taskId, operatorId);
+ return;
+ }
+ log.warn("[actor-gate] oapi 快捷审批被拒(门禁未装配,fail-closed):schId={} taskId={} operatorId={}",
+ schId, taskId, operatorId);
+ throw new BizException(ErrorCode.FORBIDDEN,
+ "SDK 快捷审批未放行:仅凭 appKey/operatorId 不构成用户授权。"
+ + "请通过流程中心人工办理页(CAS)办理");
+ }
+
private void requireOperator(Long schId, Long taskId, Integer operatorId, String operatorName) {
if (schId == null || taskId == null || operatorId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/taskId/operatorId");
@@ -662,7 +988,7 @@ public class OapiFlowService {
if (bizType == null || bizId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId");
}
- instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId)
+ instance = findByBizForOapi(schId, bizType, bizId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见"));
}
return toProgressVo(instance);
@@ -675,6 +1001,10 @@ public class OapiFlowService {
* 业务边界保证),浏览器侧只认 token。
*/
public OapiModels.DiagramUrlVo diagramUrl(Long schId, Long instanceId, String flowGuid, String mode) {
+ if (formalCommandEnabled && instanceId == null && !canReadFormalDefinition(schId, flowGuid)) {
+ throw new BizException(ErrorCode.FORBIDDEN, "没有此流程定义的读取权限");
+ }
+
if (flowGuid != null && !flowGuid.trim().isEmpty()) {
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid.trim(), FlwEnums.DefStatus.PUBLISHED.getCode())
@@ -756,6 +1086,12 @@ public class OapiFlowService {
}
/** 进度/换图共用的办件定位(schId 收敛在次) */
+ private java.util.Optional<FlwInstance> findByBizForOapi(Long school, String type, String biz) {
+ return formalCommandEnabled
+ ? instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(school, FlowOapiSourceScope.current(), type, biz)
+ : instanceRepository.findBySchIdAndBizTypeAndBizId(school, type, biz);
+ }
+
private FlwInstance requireInstance(Long schId, Long instanceId) {
FlwInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
@@ -1020,6 +1356,15 @@ public class OapiFlowService {
reg.setAppKey(appKey);
reg.setFlowGuid(body.getFlowGuid());
}
+ if (formalCommandEnabled) {
+ FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey);
+ com.yunhai.flowcenter.entity.FlwUsageGrant grant = formalGrants.findBySchIdAndFlowGuidAndAppId(schId, body.getFlowGuid(), source.appId).orElse(null);
+ boolean permitted = grant != null && Integer.valueOf(1).equals(grant.getEnabled()) && source.id.equals(grant.getSourcePrincipalId());
+ if (!permitted && !formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(schId, source.id, body.getFlowGuid(), "ACCEPTED")) {
+ throw new BizException(ErrorCode.FORBIDDEN, "没有 SELF 订阅权限");
+ }
+ reg.setSourcePrincipalId(source.id);
+ }
reg.setCallbackUrl(body.getCallbackUrl());
reg.setEventTypes(body.getEventTypes());
reg.setBasePath(body.getBasePath());
@@ -1112,6 +1457,9 @@ public class OapiFlowService {
public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) {
OapiModels.InstanceVo vo = new OapiModels.InstanceVo();
vo.setInstanceId(instance.getId());
+ vo.setApplyRound(instance.getApplyRound());
+ vo.setRequestToken(instance.getApplyRequestToken());
+ vo.setApplyAppKey(instance.getApplyAppKey());
vo.setStatus(instance.getStatus());
vo.setVersion(instance.getVersion());
vo.setBizTitle(instance.getBizTitle());
diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java
index 5728e74..d29c65b 100644
--- a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java
+++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java
@@ -171,9 +171,10 @@ class CallbackDispatchIT extends FlowTestSupport {
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
org.springframework.http.ResponseEntity<JsonNode> applyResp =
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
- // 3. 轮询 stub:submitted + task.created 两事件都到齐(探活 ping 不计)
+ // 3. 轮询 stub:submitted + task.created 两事件都到齐(探活 ping 不计;异步首推
+ // 可能被启动扫描的遗留行恢复推送短暂占池,等待上限放宽——P1-1)
waitUntil(() -> received.stream().anyMatch(r -> r.body.contains("flow.submitted"))
- && received.stream().anyMatch(r -> r.body.contains("flow.task.created")), 10_000);
+ && received.stream().anyMatch(r -> r.body.contains("flow.task.created")), 60_000);
boolean hasSubmitted = false;
boolean hasTaskCreated = false;
for (Received r : received) {
@@ -221,10 +222,13 @@ class CallbackDispatchIT extends FlowTestSupport {
.filter(r -> r.body != null && r.body.contains("\"ping\"")).findFirst().orElse(null);
assertNotNull(ping, "注册探活应命中 stub");
assertAppSignature("/callback/flow", ping);
- // 4. 日志表成功态(异步推送落定,轮询等待)
- assertTrue(logRepository.findAll().size() >= 2);
+ // 4. 日志表成功态(异步推送落定,轮询等待——只认本用例两行,残留行不计)
waitUntil(() -> logRepository.findAll().stream()
- .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()), 10_000);
+ .filter(l -> "cb-1".equals(l.getBizId())).count() >= 2, 60_000);
+ waitUntil(() -> logRepository.findAll().stream()
+ .filter(l -> "cb-1".equals(l.getBizId()))
+ .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()),
+ 60_000);
}
/**
@@ -261,7 +265,7 @@ class CallbackDispatchIT extends FlowTestSupport {
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
- waitUntil(() -> hits.stream().anyMatch(r -> r.body != null && r.body.contains("flow.submitted")), 10_000);
+ waitUntil(() -> hits.stream().anyMatch(r -> r.body != null && r.body.contains("flow.submitted")), 60_000);
Received push = hits.stream()
.filter(r -> r.body != null && r.body.contains("flow.submitted"))
.findFirst().orElseThrow(IllegalStateException::new);
@@ -288,16 +292,14 @@ class CallbackDispatchIT extends FlowTestSupport {
+ "\"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);
+ // 首推失败:retryCount=1(异步首推,轮询等待——只认本用例行)
+ waitFirstFailureLanded("cb-2");
// 手工逐次触发扫描语义再失败 5 轮(共 6 次,越过退避表 5 项)
+ FlwCallbackLog hung = soleRow("cb-2");
for (int round = 0; round < 5; round++) {
- for (FlwCallbackLog row : logRepository.findAll()) {
- pushService.pushAsync(row.getId());
- }
+ pushService.pushAsync(hung.getId());
+ hung = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new);
}
- FlwCallbackLog hung = logRepository.findAll().get(0);
// T1.1 核心语义:持续重试不自动死信——status 仍=0、retryCount=6、nextRetry≈now+60min(封顶)
assertEquals(FlwEnums.CallbackLogStatus.PENDING.getCode(), hung.getStatus(),
"越过退避表后必须保持待推持续重试,不得自动死信");
@@ -308,8 +310,9 @@ class CallbackDispatchIT extends FlowTestSupport {
&& hung.getNextRetryTime().isBefore(expectedNext.plusMinutes(5)),
"越过退避表后 nextRetryTime 应封顶 +60 分钟: " + hung.getNextRetryTime());
assertTrue(logRepository.findAll().stream()
- .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()),
- "任何行都不应出现死信(status=2)");
+ .filter(l -> "cb-2".equals(l.getBizId()))
+ .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()),
+ "本用例行不应出现死信(status=2)");
assertNotNull(hung.getLastError());
}
@@ -330,23 +333,21 @@ class CallbackDispatchIT extends FlowTestSupport {
+ "\"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);
+ waitFirstFailureLanded("cb-4");
// 失败到第 6 次(=挂起阈值):告警恰一次
+ FlwCallbackLog row = soleRow("cb-4");
for (int round = 0; round < 5; round++) {
- for (FlwCallbackLog row : logRepository.findAll()) {
- pushService.pushAsync(row.getId());
- }
+ pushService.pushAsync(row.getId());
+ row = logRepository.findById(row.getId()).orElseThrow(IllegalStateException::new);
}
assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 6 次失败后挂起告警应恰发送一次");
// 第 7 次失败:仍是 1(恰一次)
- for (FlwCallbackLog row : logRepository.findAll()) {
- pushService.pushAsync(row.getId());
- }
+ pushService.pushAsync(row.getId());
+ row = logRepository.findById(row.getId()).orElseThrow(IllegalStateException::new);
assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 7 次失败后告警计数仍应为 1");
- assertEquals(7, logRepository.findAll().get(0).getRetryCount());
+ assertEquals(7, row.getRetryCount());
}
@Test
@@ -373,13 +374,12 @@ class CallbackDispatchIT extends FlowTestSupport {
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);
+ waitFirstFailureLanded("cb-5");
// 消费方恢复后(扫描/重推触发)→ 200 → status=1 + pushTime 落定
- for (FlwCallbackLog row : logRepository.findAll()) {
- pushService.pushAsync(row.getId());
- }
- FlwCallbackLog recovered = logRepository.findAll().get(0);
+ FlwCallbackLog failed = soleRow("cb-5");
+ pushService.pushAsync(failed.getId());
+ FlwCallbackLog recovered = logRepository.findById(failed.getId())
+ .orElseThrow(IllegalStateException::new);
assertEquals(FlwEnums.CallbackLogStatus.SUCCESS.getCode(), recovered.getStatus(),
"瞬时失败恢复后应自动治愈为成功");
assertNotNull(recovered.getPushTime());
@@ -402,37 +402,39 @@ class CallbackDispatchIT extends FlowTestSupport {
+ "\"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);
+ waitFirstFailureLanded("cb-6");
+ FlwCallbackLog hung = soleRow("cb-6");
for (int round = 0; round < 5; round++) {
- for (FlwCallbackLog row : logRepository.findAll()) {
- pushService.pushAsync(row.getId());
- }
+ pushService.pushAsync(hung.getId());
+ hung = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new);
}
- FlwCallbackLog hung = logRepository.findAll().get(0);
assertEquals(6, hung.getRetryCount());
+ final FlwCallbackLog hungRow = hung;
- // 挂起列表:status=0 且 retryCount≥阈值的分页行,不含正常行
+ // 挂起列表:包含本用例挂起行,且该行合法(status=0、retryCount≥阈值)
com.yunhai.platform.utils.model.BasePageResult<FlwCallbackLog> hungPage =
callbackAdminService.pageHung(SCH, 1, 10);
- assertTrue(hungPage.getContent().stream().anyMatch(l -> l.getId().equals(hung.getId())));
- assertTrue(hungPage.getContent().stream()
- .allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()
- && l.getRetryCount() >= 6));
+ assertTrue(hungPage.getContent().stream().anyMatch(l -> l.getId().equals(hungRow.getId())));
+ FlwCallbackLog listed = hungPage.getContent().stream()
+ .filter(l -> l.getId().equals(hungRow.getId())).findFirst()
+ .orElseThrow(IllegalStateException::new);
+ assertTrue(listed.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()
+ && listed.getRetryCount() >= 6,
+ "挂起列表中的本用例行必须待推且达阈值");
// 管理端手工标记死信(唯一死信入口)
- callbackAdminService.markDead(SCH, hung.getId());
- FlwCallbackLog dead = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new);
+ callbackAdminService.markDead(SCH, hungRow.getId());
+ FlwCallbackLog dead = logRepository.findById(hungRow.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()));
+ callbackAdminService.rePush(SCH, java.util.Collections.singletonList(hungRow.getId()));
waitUntil(() -> {
- FlwCallbackLog row = logRepository.findById(hung.getId()).orElse(null);
+ FlwCallbackLog row = logRepository.findById(hungRow.getId()).orElse(null);
return row != null && row.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode();
- }, 10_000);
+ }, 60_000);
}
@Test
@@ -465,6 +467,29 @@ class CallbackDispatchIT extends FlowTestSupport {
return body;
}
+ /**
+ * 本用例自建投递行(bizId 每用例唯一)。P1-1 教训:库中会残留上一轮运行未推完的
+ * PENDING 行(崩溃/中断运行遗产),启动扫描会立即恢复推送它们——等待谓词与行定位
+ * 一律以本用例 bizId 过滤,不与残留行混淆。
+ */
+ private FlwCallbackLog soleRow(String bizId) {
+ java.util.List<FlwCallbackLog> rows = logRepository.findAll().stream()
+ .filter(l -> bizId.equals(l.getBizId()))
+ .collect(java.util.stream.Collectors.toList());
+ assertEquals(1, rows.size(), "本用例应恰有一条投递行:bizId=" + bizId);
+ return rows.get(0);
+ }
+
+ /** 等待本用例行首推失败落定(retryCount≥1)且首推线程已完全退出 IN_FLIGHT——
+ * 否则紧随的手动重推首轮会被在途跳过静默吞计(P1-1 实测根因)。 */
+ private void waitFirstFailureLanded(String bizId) {
+ waitUntil(() -> logRepository.findAll().stream().anyMatch(l ->
+ bizId.equals(l.getBizId()) && l.getRetryCount() != null && l.getRetryCount() >= 1),
+ 60_000);
+ FlwCallbackLog row = soleRow(bizId);
+ waitUntil(() -> !pushService.inFlight(row.getId()), 10_000);
+ }
+
/** 应用签名断言:canonical = METHOD + "\n" + 剥离 base-path 后的 path(+排序 query) + "\n" + ts */
private void assertAppSignature(String expectedTarget, Received r) {
assertNotNull(r.appKey, "推送必须带 X-App-Key");
diff --git a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java
index 3b78325..f35fb2a 100644
--- a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java
+++ b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java
@@ -432,12 +432,58 @@ class DefValidatorTest {
}
@Test
- void expressionMode_formVariables_passPublishTime() {
- // 表单变量(子应用运行时传)发布期不拦:缺失按 null 注入求值
+ void declaredBusinessVariablesRequiredForContractExpressions() {
+ FlowDefModel model = parse(expressionDef("EXPRESSION", "amount > 1000", "amount <= 1000"));
+ model.variables = new java.util.ArrayList<>();
+ assertTrue(assertThrows(BizException.class, () -> DefValidator.validate(model,
+ new HashSet<>(Collections.singletonList("ybb")), null)).getMessage().contains("amount"));
+ FlowDefModel.Variable v = new FlowDefModel.Variable(); v.key="amount"; v.type="number";
+ model.variables.add(v);
+ DefValidator.validate(model, new HashSet<>(Collections.singletonList("ybb")), null);
+ model.variables.add(v);
+ assertTrue(assertThrows(BizException.class, () -> DefValidator.validate(model,
+ new HashSet<>(Collections.singletonList("ybb")), null)).getMessage().contains("重复"));
+ model.variables.remove(1); v.type="any";
+ assertTrue(assertThrows(BizException.class, () -> DefValidator.validate(model,
+ new HashSet<>(Collections.singletonList("ybb")), null)).getMessage().contains("类型不支持"));
+ }
+
+ @Test
+ void historicalExpressionsWithoutContractRemainCompatible() {
DefValidator.validate(parse(expressionDef("EXPRESSION", "form.level == 'A'", "true")),
new HashSet<>(Collections.singletonList("ybb")), null);
}
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.ValueSource(strings={
+ "[{\"key\":\"schId\",\"type\":\"number\"}]",
+ "[{\"key\":\"bad key\",\"type\":\"string\"}]",
+ "[{\"key\":\"amount\",\"type\":\"number\",\"enum\":[\"1\"]}]",
+ "[{\"key\":\"amount\",\"type\":\"string\",\"enum\":[]}]",
+ "[{\"key\":\"amount\",\"type\":\"string\",\"enum\":[\"x\",\"x\"]}]",
+ "[null]"
+ })
+ void invalidDeclarationsRejected(String declarations) throws Exception {
+ FlowDefModel model = parse(expressionDef("EXPRESSION", "true", "false"));
+ model.variables = new ObjectMapper().readValue("{\"variables\":"+declarations+"}",FlowDefModel.class).variables;
+ assertThrows(BizException.class, () -> DefValidator.validate(model, Collections.singleton("ybb"), null));
+ }
+
+ @Test
+ void directExpressionLiteralsUseTheSameValueRulesAsApply() {
+ FlowDefModel model = parse(expressionDef("EXPRESSION", "true", "false"));
+ FlowDefModel.Variable v = new FlowDefModel.Variable();v.key="grade";v.type="string";v.enumValues=java.util.Arrays.asList("g1","g2");
+ model.variables=Collections.singletonList(v);
+ for (String invalid : java.util.Arrays.asList("grade == 'g9'", "'g9' == grade", "grade != 'g9'", "grade > 3")) {
+ model.edges.get(1).condition=invalid;
+ assertThrows(BizException.class, () -> DefValidator.validate(model, Collections.singleton("ybb"), null));
+ }
+ model.edges.get(1).condition="string.contains(grade, 'grade > 3')";
+ DefValidator.validate(model, Collections.singleton("ybb"), null);
+ model.edges.get(1).condition="grade > 'g1'"; // 保持既有同类型比较能力,不新增运算符策略。
+ DefValidator.validate(model, Collections.singleton("ybb"), null);
+ }
+
@Test
void expressionMode_skipsRouterKeyCheck() {
// EXPRESSION 模式不配 routerKey(enabledRouterKeys=null 也不查注册表)
diff --git a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
index dee0ff9..0d52670 100644
--- a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
+++ b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
@@ -4,6 +4,7 @@ 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.entity.FlwAdvanceIntent;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.service.FlowInstanceService;
import com.yunhai.flowcenter.service.FlowTaskService;
@@ -37,6 +38,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
@ActiveProfiles("test")
class FlowEngineIT extends FlowTestSupport {
+ @org.springframework.boot.test.mock.mockito.SpyBean
+ private FlowNodeAdvanceListener advanceListener;
+ @org.springframework.boot.test.mock.mockito.SpyBean
+ private com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository intents;
+ @org.springframework.boot.test.mock.mockito.MockBean
+ private org.springframework.scheduling.TaskScheduler controlledScheduler;
+ @Autowired private org.springframework.jdbc.core.JdbcTemplate d09Jdbc;
+ @org.springframework.boot.test.mock.mockito.SpyBean private FlowAdvanceService advanceService;
+ @org.springframework.boot.test.mock.mockito.SpyBean private com.yunhai.flowcenter.repository.FlwTaskRepository controlledTasks;
+ @org.springframework.boot.test.mock.mockito.SpyBean private FlowEngine controlledEngine;
+
@Autowired
private FlowInstanceService instanceService;
@Autowired
@@ -92,6 +104,9 @@ class FlowEngineIT extends FlowTestSupport {
@BeforeEach
void setUp() {
+ assertTrue(d09Jdbc.queryForObject("SELECT @@version", String.class).startsWith("8."));
+ assertEquals(13429, d09Jdbc.queryForObject("SELECT @@port", Integer.class));
+ assertEquals("fl_d09_20261026", d09Jdbc.queryForObject("SELECT DATABASE()", String.class));
cleanDb();
StubJudge.reset("甲线");
}
@@ -112,6 +127,362 @@ class FlowEngineIT extends FlowTestSupport {
return instanceService.apply(cmd);
}
+ @Test
+ void d09W_lastHandlerWithLowerTaskIdWinsAfterRecovery() {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ mapDept("d09a",201,"First");mapDept("d09a",202,"Last");mapDept("d09b",301,"Next");
+ FlwDef def=saveAndPublishDef("d09w","D09 W",multiOutletDef("d09a","d09b"));
+ Long id=apply(def.getFlowGuid(),"d09-w",201,"Applicant").getInstanceId();
+ List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
+ tasks.sort(java.util.Comparator.comparing(FlwTask::getId));
+ FlwTask first=tasks.get(1),last=tasks.get(0); // Completion order deliberately reverses ID order.
+ taskService.handle(SCH,first.getId(),true,"first",first.getAssigneeId(),"通过");
+ taskService.handle(SCH,last.getId(),true,"last",last.getAssigneeId(),"转乙科");
+ d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE instance_id=? AND status='PENDING'",id);
+ advanceListener.sweepIntents();
+ assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ com.yunhai.flowcenter.entity.FlwStateEvent created=stateEventRepository.findAll().stream()
+ .filter(e->id.equals(e.getInstanceId()) && "flow.task.created".equals(e.getEventType()))
+ .max(java.util.Comparator.comparing(com.yunhai.flowcenter.entity.FlwStateEvent::getId)).get();
+ assertTrue(created.getPayloadJson().contains("\"operatorUserId\":"+last.getAssigneeId()));
+ }
+
+ @Test
+ void d09V_claimedOldIntentCannotAdvanceRestartedDefinitionAndNewIntentTakesOver() throws Exception {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next");
+ FlwDef def=saveAndPublishDef("d09v","D09 V",multiOutletDef("d09a","d09b"));
+ Long id=apply(def.getFlowGuid(),"d09-v",201,"Applicant").getInstanceId();
+ List<FlwTask> old=taskRepository.findByInstanceIdAndStatus(id,0);
+ taskService.handle(SCH,old.get(0).getId(),true,"old",old.get(0).getAssigneeId(),"通过");
+ taskService.handle(SCH,old.get(1).getId(),false,"reject",old.get(1).getAssigneeId(),null);
+ d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE instance_id=? AND status='PENDING'",id);
+ FlwDef next=saveAndPublishDef("d09v2","D09 V2",multiOutletDef("d09a","d09b"));
+ next.setFlowGuid(def.getFlowGuid());next.setVersion(2);defRepository.saveAndFlush(next);
+ assertEquals(id,apply(def.getFlowGuid(),"d09-v",201,"Applicant").getInstanceId());
+ java.util.concurrent.CountDownLatch claimed=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
+ org.mockito.Mockito.doAnswer(inv->{
+ Object rows=d09DueFromDatabase();claimed.countDown();
+ assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));return rows;
+ }).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
+ org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
+ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor();
+ try {
+ java.util.concurrent.Future<?> stale=pool.submit(()->advanceListener.sweepIntents());
+ assertTrue(claimed.await(20,java.util.concurrent.TimeUnit.SECONDS));
+ List<FlwTask> current=taskRepository.findByInstanceIdAndStatus(id,0);
+ for(FlwTask task:current)taskService.handle(SCH,task.getId(),true,"new",task.getAssigneeId(),"转乙科");
+ FlwInstance before=instanceRepository.findById(id).get();
+ release.countDown();stale.get(20,java.util.concurrent.TimeUnit.SECONDS);
+ FlwInstance after=instanceRepository.findById(id).get();
+ assertEquals(before.getVersion(),after.getVersion());assertEquals(10,after.getStatus());
+ assertEquals("n1",after.getCurrentNodes());assertEquals(2,after.getDefVersion());
+ assertEquals(0,taskRepository.findByInstanceIdAndStatus(id,0).size());
+ org.mockito.Mockito.doAnswer(inv->d09DueFromDatabase()).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
+ org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
+ advanceListener.sweepIntents();
+ assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ } finally {release.countDown();pool.shutdownNow();}
+ }
+
+ private List<com.yunhai.flowcenter.entity.FlwAdvanceIntent> d09DueFromDatabase() {
+ return intents.findAll().stream().filter(i->"PENDING".equals(i.getStatus()))
+ .filter(i->i.getNextRetryAt()==null || !i.getNextRetryAt().isAfter(java.time.LocalDateTime.now()))
+ .limit(50).collect(java.util.stream.Collectors.toList());
+ }
+
+ private Long d09Multi(String biz) {
+ mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next");
+ FlwDef def=saveAndPublishDef(biz,biz,multiOutletDef("d09a","d09b"));
+ return apply(def.getFlowGuid(),biz,201,"Applicant").getInstanceId();
+ }
+
+ private FlwAdvanceIntent d09Head(Long id) {
+ return intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(id,"n1").get();
+ }
+
+ @Test void d09W_retryExhaustionRefreshesLastEdgeAndOperatorBeforeSweep() {
+ Long id=d09Multi("d09-retry");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
+ org.mockito.Mockito.doThrow(new org.springframework.orm.ObjectOptimisticLockingFailureException(FlwInstance.class,id))
+ .when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
+ FlwTask first=tasks.get(1),last=tasks.get(0);
+ taskService.handle(SCH,first.getId(),true,"first",first.getAssigneeId(),"通过");
+ taskService.handle(SCH,last.getId(),true,"last",last.getAssigneeId(),"转乙科");
+ FlwAdvanceIntent head=d09Head(id);
+ assertEquals(last.getId(),head.getTriggerTaskId());assertEquals(last.getAssigneeId(),head.getAssigneeId());
+ assertEquals("转乙科",head.getEdgeName());assertEquals("PENDING",head.getStatus());assertEquals(1,head.getAttempts());
+ org.mockito.Mockito.verify(advanceService,org.mockito.Mockito.times(6)).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
+ org.mockito.Mockito.doCallRealMethod().when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
+ d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE id=?",head.getId());
+ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ assertEquals("DONE",intents.findById(head.getId()).get().getStatus());
+ assertEquals(last.getAssigneeId(),stateEventRepository.findAll().stream()
+ .filter(e->id.equals(e.getInstanceId()) && "flow.task.created".equals(e.getEventType()))
+ .max(java.util.Comparator.comparing(com.yunhai.flowcenter.entity.FlwStateEvent::getId)).get().getOperatorId());
+ }
+
+ @Test void d09_claimSnapshotCannotSettleARefreshedSameGeneration() throws Exception {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ Long id=d09Multi("d09-snapshot");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
+ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
+ FlwAdvanceIntent observed=d09Head(id);
+ java.util.concurrent.CountDownLatch claimed=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
+ org.mockito.Mockito.doAnswer(inv->{Object rows=d09DueFromDatabase();claimed.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));return rows;})
+ .when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
+ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor();
+ try {
+ java.util.concurrent.Future<?> old=pool.submit(()->advanceListener.sweepIntents());assertTrue(claimed.await(20,java.util.concurrent.TimeUnit.SECONDS));
+ taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科");
+ FlwAdvanceIntent fresh=d09Head(id);assertTrue(fresh.getRevision()>observed.getRevision());
+ release.countDown();old.get(20,java.util.concurrent.TimeUnit.SECONDS);
+ FlwAdvanceIntent preserved=d09Head(id);assertEquals(fresh.getRevision(),preserved.getRevision());
+ assertEquals("PENDING",preserved.getStatus());assertEquals(tasks.get(0).getId(),preserved.getTriggerTaskId());assertEquals("转乙科",preserved.getEdgeName());
+ assertEquals(0,preserved.getAttempts());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
+ org.mockito.Mockito.doAnswer(inv->d09DueFromDatabase()).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
+ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ } finally {release.countDown();pool.shutdownNow();}
+ }
+
+ @Test void d09_staleFailureCannotDeferOrOverwriteLastHandlerRefresh() throws Exception {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ Long id=d09Multi("d09-defer-race");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
+ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
+ java.util.concurrent.CountDownLatch entered=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
+ org.mockito.Mockito.doAnswer(inv->{entered.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));
+ throw new org.springframework.orm.ObjectOptimisticLockingFailureException(FlwInstance.class,id);})
+ .when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
+ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor();
+ try {
+ java.util.concurrent.Future<?> old=pool.submit(()->advanceListener.sweepIntents());assertTrue(entered.await(20,java.util.concurrent.TimeUnit.SECONDS));
+ taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科");
+ FlwAdvanceIntent fresh=d09Head(id);release.countDown();old.get(20,java.util.concurrent.TimeUnit.SECONDS);
+ FlwAdvanceIntent preserved=d09Head(id);assertEquals(fresh.getRevision(),preserved.getRevision());assertEquals(0,preserved.getAttempts());
+ assertNull(preserved.getLastError());assertEquals("PENDING",preserved.getStatus());assertEquals(tasks.get(0).getId(),preserved.getTriggerTaskId());
+ org.mockito.Mockito.doCallRealMethod().when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
+ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ } finally {release.countDown();pool.shutdownNow();}
+ }
+
+ @Test void d09_lockedSettlementThenLastHandlerCommitRearmsTheHead() throws Exception {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ Long id=d09Multi("d09-settlement");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
+ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
+ java.util.concurrent.CountDownLatch locked=new java.util.concurrent.CountDownLatch(1),handlerEntered=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
+ org.mockito.Mockito.doAnswer(inv->{
+ if ("d09-settler".equals(Thread.currentThread().getName())) {locked.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));}
+ return controlledTasks.findAll().stream().filter(t->id.equals(t.getInstanceId()) && Integer.valueOf(0).equals(t.getStatus())).collect(java.util.stream.Collectors.toList());
+ }).when(controlledTasks).findByInstanceIdAndStatus(id,0);
+ FlowAdvanceService target=org.springframework.test.util.AopTestUtils.getUltimateTargetObject(advanceService);
+ org.mockito.Mockito.doAnswer(inv->{handlerEntered.countDown();return inv.callRealMethod();})
+ .when(target).recordHandled(org.mockito.ArgumentMatchers.any());
+ java.util.concurrent.ExecutorService worker=java.util.concurrent.Executors.newSingleThreadExecutor(r->new Thread(r,"d09-settler"));
+ java.util.concurrent.ExecutorService handler=java.util.concurrent.Executors.newSingleThreadExecutor();
+ try {
+ java.util.concurrent.Future<?> settling=worker.submit(()->advanceListener.sweepIntents());assertTrue(locked.await(20,java.util.concurrent.TimeUnit.SECONDS));
+ java.util.concurrent.Future<?> last=handler.submit(()->taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科"));
+ assertTrue(handlerEntered.await(20,java.util.concurrent.TimeUnit.SECONDS));assertFalse(last.isDone());
+ release.countDown();settling.get(20,java.util.concurrent.TimeUnit.SECONDS);last.get(20,java.util.concurrent.TimeUnit.SECONDS);
+ FlwAdvanceIntent head=d09Head(id);assertEquals("PENDING",head.getStatus());assertEquals(tasks.get(0).getId(),head.getTriggerTaskId());
+ assertEquals("转乙科",head.getEdgeName());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
+ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ } finally {release.countDown();worker.shutdownNow();handler.shutdownNow();}
+ }
+
+ @Test void d09H_oldClaimAcrossReturnBackHasNoSideEffectsAndNewGenerationTakesOver() throws Exception {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ mapDept("km",103,"Main");mapDept("ka",101,"A");mapDept("kb",102,"B");
+ FlwDef def=saveAndPublishDef("d09h","D09 H",mainlineParallelTailDef("km","km"));
+ Long id=apply(def.getFlowGuid(),"d09-h",201,"Applicant").getInstanceId();FlwTask first=tasksOf(id,103).get(0);
+ taskService.handle(SCH,first.getId(),true,"first",103);
+ Long oldActivationId=d09Head(id).getId();
+ java.util.concurrent.CountDownLatch claimed=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
+ org.mockito.Mockito.doAnswer(inv->{Object rows=d09DueFromDatabase();claimed.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));return rows;})
+ .when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
+ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor();
+ try {
+ java.util.concurrent.Future<?> stale=pool.submit(()->advanceListener.sweepIntents());assertTrue(claimed.await(20,java.util.concurrent.TimeUnit.SECONDS));
+ // A competing fast worker legitimately advances the old activation before the return.
+ org.mockito.Mockito.doCallRealMethod().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ java.util.Map<String,Object> data=new java.util.HashMap<>();data.put("nodeId","n1");
+ advanceListener.onTaskHandled(FlowEvent.of(FlowEvent.Type.TASK_HANDLED,id,first.getId(),data));
+ taskService.handle(SCH,tasksOf(id,101).get(0).getId(),true,"A",101);
+ taskService.handle(SCH,tasksOf(id,102).get(0).getId(),true,"B",102);
+ FlwTask tail=tasksOf(id,103).get(0);assertEquals("n3",tail.getNodeId());
+ taskService.returnBack(SCH,tail.getId(),"n1","return",103);
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ FlwTask current=tasksOf(id,103).get(0);assertNotEquals(first.getId(),current.getId());
+ taskService.handle(SCH,current.getId(),true,"new generation",103);
+ FlwInstance before=instanceRepository.findById(id).get();List<FlwTask> beforeTasks=allTasks(id);
+ release.countDown();stale.get(20,java.util.concurrent.TimeUnit.SECONDS);
+ FlwInstance after=instanceRepository.findById(id).get();assertEquals(before.getVersion(),after.getVersion());
+ assertEquals(10,after.getStatus());assertEquals("n1",after.getCurrentNodes());assertEquals(def.getId(),after.getDefId());
+ assertEquals(before.getApplyRound(),after.getApplyRound());assertEquals(beforeTasks.size(),allTasks(id).size());
+ for(FlwTask t:beforeTasks)assertEquals(t.getStatus(),taskRepository.findById(t.getId()).get().getStatus());
+ // Also model a historical stranded PENDING row with a CURRENT revision: only activation identity can reject it.
+ FlwAdvanceIntent historical=intents.findById(oldActivationId).get();historical.setStatus("PENDING");historical=intents.saveAndFlush(historical);
+ assertFalse(advanceService.tryAdvance(historical.getId(),historical.getRevision()));
+ assertTrue(intents.findById(oldActivationId).get().getLastError().contains("STALE_ACTIVATION"));
+ assertEquals(before.getVersion(),instanceRepository.findById(id).get().getVersion());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
+ assertEquals("PENDING",d09Head(id).getStatus());
+ org.mockito.Mockito.doAnswer(inv->d09DueFromDatabase()).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
+ advanceListener.sweepIntents();assertFalse(instanceRepository.findById(id).get().getCurrentNodes().contains("n1"));
+ assertEquals(2,taskRepository.findByInstanceIdAndStatus(id,0).size());
+ } finally {release.countDown();pool.shutdownNow();}
+ }
+
+ @Test void d09V_guardedRoundAndDefinitionChangeRejectsOldPayloadWithoutLosingNewRoute() {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next");
+ FlwDef def=saveAndPublishDef("d09round","D09 round",multiOutletDef("d09a","d09b"));
+ FlowInstanceService.ApplyCommand command=new FlowInstanceService.ApplyCommand(SCH,def,"svc_item","d09-round","Title","/",null,201,"Applicant",null,"100001","notice",null);
+ Long id=instanceService.applyGuarded(command,new FlowInstanceService.ApplyRetryPolicy("d09-round-1",1,false)).instanceId;
+ List<FlwTask> old=taskRepository.findByInstanceIdAndStatus(id,0);
+ taskService.handle(SCH,old.get(0).getId(),true,"old",old.get(0).getAssigneeId(),"通过");
+ taskService.handle(SCH,old.get(1).getId(),false,"reject",old.get(1).getAssigneeId(),null);
+ FlwAdvanceIntent stale=d09Head(id);assertEquals(1,stale.getActivationApplyRound());
+ FlwDef next=saveAndPublishDef("d09round2","D09 new line",multiOutletDef("d09a","d09b").replace("转乙科","新边").replace("通过","完成"));
+ next.setFlowGuid(def.getFlowGuid());next.setVersion(2);defRepository.saveAndFlush(next);
+ FlowInstanceService.ApplyCommand nextCommand=new FlowInstanceService.ApplyCommand(SCH,next,"svc_item","d09-round","Title","/",null,201,"Applicant",null,"100001","notice",null);
+ FlowInstanceService.GuardedOutcome restarted=instanceService.applyGuarded(nextCommand,new FlowInstanceService.ApplyRetryPolicy("d09-round-2",2,false));
+ assertEquals(id,restarted.instanceId);assertEquals(2,restarted.applyRound);assertEquals("RESTARTED",restarted.branch);
+ for(FlwTask task:taskRepository.findByInstanceIdAndStatus(id,0))taskService.handle(SCH,task.getId(),true,"new",task.getAssigneeId(),"新边");
+ FlwInstance before=instanceRepository.findById(id).get();
+ assertFalse(advanceService.tryAdvance(stale.getId(),stale.getRevision()));
+ FlwInstance after=instanceRepository.findById(id).get();assertEquals(before.getVersion(),after.getVersion());
+ assertEquals("n1",after.getCurrentNodes());assertEquals(10,after.getStatus());assertEquals(2,after.getApplyRound());
+ assertEquals(next.getId(),after.getDefId());assertEquals(2,after.getDefVersion());assertEquals(0,taskRepository.findByInstanceIdAndStatus(id,0).size());
+ assertEquals("DONE",intents.findById(stale.getId()).get().getStatus());assertTrue(intents.findById(stale.getId()).get().getLastError().contains("STALE_ACTIVATION"));
+ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ }
+
+ private FlwAdvanceIntent d09Legacy(Long id) {
+ FlwAdvanceIntent legacy=new FlwAdvanceIntent();legacy.setInstanceId(id);legacy.setNodeId("n1");legacy.setEdgeName("untrusted-old-edge");
+ return intents.saveAndFlush(legacy); // NULL activation and NULL retry time deliberately model existing data.
+ }
+
+ @Test void d09_nullLegacyIntentHandsOffToRegisteredSuccessorWithoutUsingOldPayload() {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ Long id=d09Multi("d09-null-successor");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
+ for(FlwTask task:tasks)taskService.handle(SCH,task.getId(),true,"current",task.getAssigneeId(),"转乙科");
+ FlwAdvanceIntent successor=d09Head(id),legacy=d09Legacy(id);FlwInstance before=instanceRepository.findById(id).get();
+ assertFalse(advanceService.tryAdvance(legacy.getId(),legacy.getRevision()));
+ assertEquals(before.getVersion(),instanceRepository.findById(id).get().getVersion());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
+ assertEquals("DONE",intents.findById(legacy.getId()).get().getStatus());
+ assertEquals("PENDING",intents.findById(successor.getId()).get().getStatus());
+ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ }
+
+ private void d09InstallBackfillProcedure() throws Exception {
+ String sql=org.springframework.util.StreamUtils.copyToString(new org.springframework.core.io.ClassPathResource(
+ "sql/20261026_d09_activation_intent_draft.sql").getInputStream(),java.nio.charset.StandardCharsets.UTF_8);
+ String procedure=sql.split("-- D09_BACKFILL_BEGIN",2)[1].split("-- D09_BACKFILL_END",2)[0].trim();
+ d09Jdbc.execute("DROP PROCEDURE IF EXISTS d09_backfill_activation");d09Jdbc.execute(procedure);
+ }
+
+ @Test void d09_nullLegacyPendingCohortIsHeldThenAuditedBackfillAndRealHandlingResume() throws Exception {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ Long id=d09Multi("d09-null-pending");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
+ intents.delete(d09Head(id));FlwAdvanceIntent legacy=d09Legacy(id);
+ advanceListener.sweepIntents();assertEquals("EXHAUSTED",intents.findById(legacy.getId()).get().getStatus());
+ assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());assertEquals(2,taskRepository.findByInstanceIdAndStatus(id,0).size());
+ assertThrows(IllegalStateException.class,()->taskService.handle(SCH,tasks.get(0).getId(),true,"must roll back",tasks.get(0).getAssigneeId(),"通过"));
+ assertEquals(0,taskRepository.findById(tasks.get(0).getId()).get().getStatus());
+ d09InstallBackfillProcedure();
+ assertThrows(org.springframework.dao.DataAccessException.class,()->d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1","["+tasks.get(0).getId()+"]",null,"IT omitted member"));
+ assertFalse(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(id,"n1").isPresent());
+ String cohort="["+tasks.get(0).getId()+","+tasks.get(1).getId()+"]";
+ d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,null,"IT audited pending cohort");
+ assertEquals("DONE",intents.findById(legacy.getId()).get().getStatus());assertTrue(intents.findById(legacy.getId()).get().getLastError().contains("HANDOFF"));
+ assertEquals("DONE",d09Head(id).getStatus());assertNull(d09Head(id).getTriggerTaskId());
+ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
+ taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科");
+ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ }
+
+ @Test void d09_nullLegacyCompletedCohortRequiresProvenLastHandlerBeforeBackfill() throws Exception {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ Long id=d09Multi("d09-null-completed");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
+ taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
+ taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科");
+ intents.delete(d09Head(id));FlwAdvanceIntent legacy=d09Legacy(id);advanceListener.sweepIntents();
+ assertEquals("EXHAUSTED",intents.findById(legacy.getId()).get().getStatus());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
+ d09InstallBackfillProcedure();String cohort="["+tasks.get(0).getId()+","+tasks.get(1).getId()+"]";
+ assertThrows(org.springframework.dao.DataAccessException.class,()->d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,null,"IT missing last-handler evidence"));
+ assertEquals("EXHAUSTED",intents.findById(legacy.getId()).get().getStatus());
+ d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,tasks.get(0).getId(),"IT witnessed lower-ID last handler");
+ assertEquals(tasks.get(0).getId(),d09Head(id).getTriggerTaskId());assertEquals("PENDING",d09Head(id).getStatus());
+ assertThrows(org.springframework.dao.DataAccessException.class,()->d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,tasks.get(0).getId(),"IT cannot overwrite successor"));
+ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ }
+
+ @Test void d09_oldWorkerAlreadyPastGenerationGuardRollsBackAcrossRestart() throws Exception {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Cancel");
+ FlwDef def=saveAndPublishDef("d09-fence","D09 fence",parallelDef("d09a","d09b"));
+ Long id=apply(def.getFlowGuid(),"d09-fence",201,"Applicant").getInstanceId();
+ List<FlwTask> initial=taskRepository.findByInstanceIdAndStatus(id,0);
+ String node=initial.stream().filter(t->t.getAssigneeId()==201).findFirst().get().getNodeId();
+ for(FlwTask task:initial)if(node.equals(task.getNodeId()))taskService.handle(SCH,task.getId(),true,"old approved",task.getAssigneeId());
+ FlwAdvanceIntent oldHead=intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(id,node).get();
+ java.util.concurrent.CountDownLatch validated=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
+ org.mockito.Mockito.doAnswer(inv->{
+ if("d09-old-guard".equals(Thread.currentThread().getName())) {validated.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));}
+ return inv.callRealMethod();
+ }).when(controlledEngine).continueAdvance(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq(node),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any());
+ java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor(r->new Thread(r,"d09-old-guard"));
+ try {
+ java.util.concurrent.Future<?> old=pool.submit(()->advanceListener.sweepIntents());assertTrue(validated.await(20,java.util.concurrent.TimeUnit.SECONDS));
+ FlwTask cancel=initial.stream().filter(t->t.getAssigneeId()==301).findFirst().get();
+ taskService.handle(SCH,cancel.getId(),false,"reject other branch",301);
+ assertEquals(30,instanceRepository.findById(id).get().getStatus());
+ assertEquals(id,apply(def.getFlowGuid(),"d09-fence",201,"Applicant").getInstanceId());
+ for(FlwTask task:taskRepository.findByInstanceIdAndStatus(id,0))if(node.equals(task.getNodeId()))taskService.handle(SCH,task.getId(),true,"new approved",task.getAssigneeId());
+ FlwInstance before=instanceRepository.findById(id).get();List<FlwTask> tasksBefore=allTasks(id);
+ java.util.List<java.util.Map<String,Object>> branchesBefore=d09Jdbc.queryForList("SELECT id,status FROM flw_branch WHERE instance_id=? ORDER BY id",id);
+ release.countDown();old.get(20,java.util.concurrent.TimeUnit.SECONDS);
+ FlwInstance after=instanceRepository.findById(id).get();assertEquals(before.getVersion(),after.getVersion());assertEquals(before.getCurrentNodes(),after.getCurrentNodes());
+ assertEquals(10,after.getStatus());assertEquals(tasksBefore.size(),allTasks(id).size());
+ for(FlwTask task:tasksBefore)assertEquals(task.getStatus(),taskRepository.findById(task.getId()).get().getStatus());
+ assertEquals(branchesBefore,d09Jdbc.queryForList("SELECT id,status FROM flw_branch WHERE instance_id=? ORDER BY id",id));
+ // Its failed settlement did not consume the new generation. That generation advances independently.
+ advanceListener.sweepIntents();assertFalse(instanceRepository.findById(id).get().getCurrentNodes().contains(node));
+ d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE id=?",oldHead.getId());
+ advanceListener.sweepIntents();assertEquals("DONE",intents.findById(oldHead.getId()).get().getStatus());
+ assertTrue(intents.findById(oldHead.getId()).get().getLastError().contains("STALE_ACTIVATION"));
+ } finally {release.countDown();pool.shutdownNow();}
+ }
+
+ @Test void d09_transferRemainsInItsActivationAndLowerIdLastHandlerStillWins() {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ Long id=d09Multi("d09-transfer");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
+ FlwAdvanceIntent original=d09Head(id);FlwTask transferred=tasks.get(0),last=tasks.get(1);
+ taskService.transfer(SCH,transferred.getId(),301,"transfer",transferred.getAssigneeId());
+ FlwTask replacement=taskRepository.findByInstanceIdAndStatus(id,0).stream().filter(t->t.getAssigneeId()==301).findFirst().get();
+ assertTrue(replacement.getId()>last.getId());assertEquals(original.getId(),d09Head(id).getId());assertTrue(d09Head(id).containsTask(replacement.getId()));
+ taskService.handle(SCH,replacement.getId(),true,"first",301,"通过");
+ taskService.handle(SCH,last.getId(),true,"last",last.getAssigneeId(),"转乙科");
+ advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
+ assertEquals(last.getId(),d09Head(id).getTriggerTaskId());assertEquals(last.getAssigneeId(),d09Head(id).getAssigneeId());
+ }
+
+ @Test void d09_ambiguousTransferTimestampCannotGuessAcrossActivations() {
+ org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
+ Long id=d09Multi("d09-transfer-ambiguous");FlwInstance instance=instanceRepository.findById(id).get();
+ FlwTask source=tasksOf(id,201).get(0);taskService.transfer(SCH,source.getId(),301,"old transfer",201);
+ FlwTask reject=tasksOf(id,202).get(0);taskService.handle(SCH,reject.getId(),false,"reject",202);
+ FlwDef def=defRepository.findById(instance.getDefId()).get();apply(def.getFlowGuid(),"d09-transfer-ambiguous",201,"Applicant");
+ FlwTask current=tasksOf(id,201).get(0);int before=allTasks(id).size();
+ FlowAdvanceService target=org.springframework.test.util.AopTestUtils.getUltimateTargetObject(advanceService);
+ org.mockito.Mockito.doAnswer(inv->{FlowEvent event=inv.getArgument(0);
+ // Force equal millisecond timestamps in two generations; a timestamp is only a candidate filter.
+ d09Jdbc.update("UPDATE flw_task SET handle_time=(SELECT stamp FROM (SELECT arrive_time stamp FROM flw_task WHERE id=?) s) WHERE id=?",event.getTaskId(),source.getId());
+ return inv.callRealMethod();
+ }).when(target).registerTransfer(org.mockito.ArgumentMatchers.any());
+ assertThrows(IllegalStateException.class,()->taskService.transfer(SCH,current.getId(),301,"ambiguous transfer",201));
+ assertEquals(0,taskRepository.findById(current.getId()).get().getStatus());assertEquals(before,allTasks(id).size());
+ assertFalse(d09Head(id).containsTask(source.getId()));assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
+ }
+
@Test
void apply_createsInstanceAndCountersignTasks_allApprove_completes() {
mapDept("ybb", 101, "甲");
diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
index 2628057..afe349f 100644
--- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
+++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
@@ -6,6 +6,7 @@ import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwRouter;
import com.yunhai.flowcenter.service.DiagramTokenService;
import com.yunhai.flowcenter.service.FlowRouterAdminService;
+import com.yunhai.flowcenter.service.FlowTaskService;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.flowcenter.support.FlowTestSupport;
import com.yunhai.flowcenter.support.OapiSignedClient;
@@ -52,6 +53,9 @@ class OapiFlowContractIT extends FlowTestSupport {
@Autowired
private OapiFlowService flowService;
+ @Autowired
+ private FlowTaskService taskService;
+
@Autowired
private FlowRouterAdminService routerAdminService;
@@ -431,7 +435,12 @@ class OapiFlowContractIT extends FlowTestSupport {
assertEquals(0, finished.get("total").asInt());
}
- /** 办理(第三步 T-C1b):非办理人业务码拒绝不 500;同意推进+opinion 进回调 payload;拒绝必填意见 */
+ /**
+ * 办理(第三步 T-C1b;2026-10-25 R1 返修重写):oapi approve/reject 无可验证用户证明
+ * 一律 fail-closed(即便自报办理人 ID)——缺 operatorName/非办理人/办理人本人均业务码拒绝、
+ * 任务保持待办;合法办理经 <b>CAS 会话路径</b>(FlowTaskService.handle,与
+ * FlowUserController 同入口)完成:同意推进 + opinion 进回调 payload、拒绝必填意见。
+ */
@Test
void approveAndReject_operatorChecked_opinionPropagates() throws Exception {
mapDept("ybb", 101, "甲");
@@ -464,12 +473,12 @@ class OapiFlowContractIT extends FlowTestSupport {
.get("instanceId").asLong();
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
- // 缺 operatorName → 业务码拒绝
+ // R1:oapi 缺 operatorName → 业务码拒绝(门禁先拒,fail-closed 优先于参数校验)
ResponseEntity<JsonNode> noName = client.post(
"/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101", "{}", JsonNode.class);
assertNotEquals(0, noName.getBody().get("code").asInt(), "缺 operatorName 应被拒绝");
- // 非办理人(999)→ 业务码拒绝(code!=0,HTTP 200),任务仍在待办
+ // R1:非办理人(999)自报 → 业务码拒绝(code!=0,HTTP 200),任务仍在待办
ResponseEntity<JsonNode> forged = client.post(
"/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=999&operatorName=路人",
"{\"opinion\":\"越权\"}", JsonNode.class);
@@ -478,31 +487,41 @@ class OapiFlowContractIT extends FlowTestSupport {
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(),
"越权后任务仍在待办");
- // 办理人(101)同意 → 任务 1、单节点单人会签放行 → 办件 20;opinion 进回调 payload
- ResponseEntity<JsonNode> ok = client.post(
+ // R1:办理人(101)本人自报 operatorId → 同样拒绝(知道办理人 ID ≠ 该用户本人在操作)
+ ResponseEntity<JsonNode> selfReported = client.post(
"/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101&operatorName=甲",
"{\"opinion\":\"同意,情况属实\"}", JsonNode.class);
- assertEquals(0, ok.getBody().get("code").asInt(), "办理人同意应成功");
+ assertNotEquals(0, selfReported.getBody().get("code").asInt(),
+ "R1:oapi 快捷审批即便自报真实办理人也不得放行");
+ assertTrue(selfReported.getBody().get("msg").asText().contains("快捷审批未放行"),
+ "拒绝文案须指明迁移路径(CAS 人工办理)");
+ assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(),
+ "拒绝后任务仍在待办");
+
+ // 合法办理改走 CAS 会话路径(FlowUserController 同入口):同意 → 任务 1、办件 20;
+ // opinion 进回调 payload
+ taskService.handle(1L, taskId, true, "同意,情况属实", 101, null);
assertEquals(1, taskRepository.findById(taskId).get().getStatus());
assertEquals(20, instanceRepository.findById(instanceId).get().getStatus());
assertTrue(awaitPayloadContains(receivedPayloads, "同意,情况属实"),
"opinion 应透传到回调 payload");
- // 第二件:拒绝无意见 → 业务码拒绝;带意见 → 任务 2、办件 30
+ // 第二件:oapi 拒绝(无意见/有意见)→ R1 一律业务码拒绝(门禁先于意见必填校验);
+ // 会话路径拒绝无意见 → 业务码拒绝;带意见 → 任务 2、办件 30
Long id2 = unwrap(client.post("/oapi/flow/apply?schId=1", "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"svc_item\",\"bizId\":\"ap-2\",\"bizTitle\":\"驳回件\","
+ "\"submitterId\":202,\"submitterName\":\"李四\"}", JsonNode.class).getBody())
.get("instanceId").asLong();
Long taskId2 = taskRepository.findByInstanceIdAndStatus(id2, 0).get(0).getId();
- ResponseEntity<JsonNode> noOpinion = client.post(
+ ResponseEntity<JsonNode> oapiNoOpinion = client.post(
"/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲",
"{}", JsonNode.class);
- assertNotEquals(0, noOpinion.getBody().get("code").asInt(), "拒绝必须填写意见");
- ResponseEntity<JsonNode> rejected = client.post(
- "/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲",
- "{\"opinion\":\"材料不全\"}", JsonNode.class);
- assertEquals(0, rejected.getBody().get("code").asInt());
+ assertNotEquals(0, oapiNoOpinion.getBody().get("code").asInt(), "R1:oapi 拒绝同样不放行");
+ assertThrows(BizException.class,
+ () -> taskService.handle(1L, taskId2, false, null, 101),
+ "会话路径拒绝必须填写意见");
+ taskService.handle(1L, taskId2, false, "材料不全", 101);
assertEquals(2, taskRepository.findById(taskId2).get().getStatus());
assertEquals(30, instanceRepository.findById(id2).get().getStatus());
assertTrue(awaitPayloadContains(receivedPayloads, "材料不全"),
@@ -649,7 +668,7 @@ class OapiFlowContractIT extends FlowTestSupport {
assertEquals(0, n1.get("candidates").size(), "解析异常节点降级空候选");
}
- /** 合法 assigns:200 + 快照落库(姓名走 requireUserName 严格口径)+ 只为被指派人建待办 */
+ /** 合法 assigns:200 + 快照落库(姓名走 requireEligibleAssignee 严格口径)+ 只为被指派人建待办 */
@Test
void apply_withAssigns_ok() throws Exception {
mapDept("ybb", 101, "甲");
diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java
index 346c21a..8ad9bd5 100644
--- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java
+++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java
@@ -2,6 +2,7 @@ package com.yunhai.flowcenter.oapi;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.flowcenter.entity.FlwDef;
+import com.yunhai.flowcenter.service.FlowTaskService;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.flowcenter.support.FlowTestSupport;
import com.yunhai.flowcenter.support.OapiSignedClient;
@@ -46,8 +47,14 @@ class OapiFlowTasksByBizTest extends FlowTestSupport {
@Autowired
private OapiFlowService flowService;
+ @Autowired
+ private FlowTaskService taskService;
+
private OapiSignedClient client;
+ @Autowired
+ private javax.sql.DataSource dataSource;
+
@TestConfiguration
static class StubConfig {
@Bean
@@ -58,7 +65,21 @@ class OapiFlowTasksByBizTest extends FlowTestSupport {
}
@BeforeEach
- void setUp() {
+ void setUp() throws Exception {
+ // 在 cleanDb 之前验证真实连接,不能只相信环境变量已覆盖 profile。
+ try (java.sql.Connection connection = dataSource.getConnection()) {
+ String schema = connection.getCatalog();
+ String host = java.net.URI.create(connection.getMetaData().getURL().substring(5)).getHost();
+ System.out.println("CI_DB_TARGET=" + host + "/" + schema);
+ assertTrue("127.0.0.1".equals(host) || "localhost".equals(host)
+ || (host != null && host.startsWith("mavenbuild-") && host.endsWith("-db")),
+ "真库门禁只允许本机或本次 CI 容器");
+ assertTrue("bsch_flowcenter_test".equals(schema) || "flowcenter_ci".equals(schema)
+ || (schema != null && schema.startsWith("flowcenter_ci_")), "拒绝清理非测试 schema");
+ if (System.getenv("CI_TEST_DB_URL") != null) {
+ assertTrue(schema.startsWith("flowcenter_ci"), "CI 必须实际连接独立 schema");
+ }
+ }
cleanDb();
client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
}
@@ -126,9 +147,8 @@ class OapiFlowTasksByBizTest extends FlowTestSupport {
FlwDef def = saveAndPublishDef("bb2", "已办结", linearDef("ybb", "审核"));
Long instanceId = applyOne(def, "done-1", "已办结通知");
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
- assertEquals(0, client.post("/oapi/flow/task/" + taskId
- + "/approve?schId=1&operatorId=101&operatorName=甲", "{}", JsonNode.class)
- .getBody().get("code").asInt(), "办理人同意应成功");
+ // R1:oapi 快捷审批已 fail-closed,办结动作改走 CAS 会话路径(FlowTaskService.handle)
+ taskService.handle(SCH, taskId, true, "同意", 101, null);
JsonNode data = byBiz("done-1", 101);
assertEquals(0, data.get("tasks").size(), "办结后本人无待办");
assertEquals(20, data.get("status").asInt(), "终态可展示:回带办件状态");
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
index 2dc0793..f08b6b2 100644
--- a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
@@ -138,18 +138,66 @@ class FlApplyRecoveryTest {
@Test
@DisplayName("10/20 命中不同 token:仍是纯幂等返回(既有语义逐字不变)")
void approvingOrCompletedStayIdempotent() {
+ // T04(2026-10-25)改判:带 token 的请求撞在途/通过件且 token 不符 = 另一份命令 →
+ // CONFLICT(不把他人命令的成功当自己的,审计 P0-2);无 token 的存量调用方保持 IDEMPOTENT。
FlwInstance approving = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(approving));
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.CONFLICT);
+ // 存量无 token:幂等回读不变
+ FlowInstanceService.ApplyCommand legacy = cmd(null);
+ assertThat(service.applyGuarded(legacy, policy(null, null, false)).branch)
.isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
FlwInstance completed = instance(900L, FlwEnums.InstanceStatus.COMPLETED.getCode(), TOKEN_A, 1, "100018");
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
- .isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
+ .isEqualTo(FlowApplyRetryModels.Branch.CONFLICT);
verify(flowEngine, never()).startAdvance(any(), any());
}
+ @Test
+ @DisplayName("同 token 但载荷指纹不一致:CONFLICT(同 ID 不同载荷是两份命令,T04)")
+ void sameTokenDifferentPayloadIsConflict() {
+ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
+ existing.setApplyPayloadHash("hash-a");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(existing));
+
+ FlowInstanceService.ApplyCommand replay = cmd(TOKEN_A);
+ replay.payloadHash = "hash-b";
+ assertThat(service.applyGuarded(replay, policy(TOKEN_A, 1, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.CONFLICT);
+
+ FlowInstanceService.ApplyCommand identical = cmd(TOKEN_A);
+ identical.payloadHash = "hash-a";
+ assertThat(service.applyGuarded(identical, policy(TOKEN_A, 1, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
+ }
+
+ @Test
+ @DisplayName("expectedPrevRound 命中行轮次:服务端只分配 N+1,不信客户端 attempt(T04)")
+ void serverAllocatesNextRoundFromExpectedPrev() {
+ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(existing));
+
+ // 客户端谎报 attempt=99:服务端按 expectedPrev=2 分配第 3 轮
+ FlowInstanceService.GuardedOutcome outcome = service.applyGuarded(
+ cmd(TOKEN_B), new FlowInstanceService.ApplyRetryPolicy(TOKEN_B, 99, false, 2));
+ assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.RESTARTED);
+ assertThat(outcome.applyRound).isEqualTo(3);
+
+ // expectedPrev 与行轮次不符:STALE_ROUND 不重启(restart 会原地改 token,这里换新实例)
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(
+ instance(901L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018")));
+ assertThat(service.applyGuarded(cmd(TOKEN_B),
+ new FlowInstanceService.ApplyRetryPolicy(TOKEN_B, 99, false, 1)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND);
+ verify(flowEngine, org.mockito.Mockito.times(1)).startAdvance(any(), any());
+ }
+
// ==================== 轮次守卫 ====================
@Test
@@ -287,6 +335,37 @@ class FlApplyRecoveryTest {
assertThat(existing.getApplyRound()).isNull();
}
+ @Test
+ void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception {
+ FlwInstance row = instance(900L, 30, TOKEN_A, 1, "100018");
+ row.setVersion(3);
+ when(instanceRepository.findById(900L)).thenReturn(Optional.of(row));
+ com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of(
+ com.yunhai.flowcenter.engine.FlowEvent.Type.REJECTED, 900L);
+ new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event);
+ row.setApplyRound(2); row.setApplyRequestToken(TOKEN_B); row.setVersion(4);
+ new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event);
+ assertThat(event.getRoundIdentity().get("applyRound")).isEqualTo(1);
+
+ com.yunhai.flowcenter.repository.FlwCallbackRegRepository regs = mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class);
+ com.yunhai.flowcenter.callback.CallbackLogStore store = mock(com.yunhai.flowcenter.callback.CallbackLogStore.class);
+ com.yunhai.flowcenter.entity.FlwCallbackReg reg = new com.yunhai.flowcenter.entity.FlwCallbackReg();
+ reg.setId(7L);
+ when(regs.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)).thenReturn(java.util.Collections.singletonList(reg));
+ com.yunhai.flowcenter.callback.CallbackDispatcher dispatcher = new com.yunhai.flowcenter.callback.CallbackDispatcher(
+ instanceRepository, defRepository, regs, mock(com.yunhai.flowcenter.repository.FlwCallbackLogRepository.class),
+ store, mock(com.yunhai.flowcenter.callback.CallbackPushService.class), new com.yunhai.flowcenter.callback.CallbackProperties(), mapper,
+ mock(com.yunhai.flowcenter.repository.FlwStateEventRepository.class),
+ mock(com.yunhai.flowcenter.callback.CallbackFactStore.class));
+ org.springframework.test.util.ReflectionTestUtils.setField(dispatcher, "pushExecutor", mock(java.util.concurrent.ExecutorService.class));
+ dispatcher.onFlowEvent(event);
+ ArgumentCaptor<com.yunhai.flowcenter.entity.FlwCallbackLog> log = ArgumentCaptor.forClass(com.yunhai.flowcenter.entity.FlwCallbackLog.class);
+ verify(store).persist(log.capture());
+ com.fasterxml.jackson.databind.JsonNode data = mapper.readTree(log.getValue().getPayloadJson()).get("data");
+ assertThat(data.get("applyRound").asInt()).isEqualTo(1);
+ assertThat(data.get("requestToken").asText()).isEqualTo(TOKEN_A);
+ }
+
// ==================== 夹具 ====================
/** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
index 1279736..c68c859 100644
--- a/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
@@ -4,6 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.enums.FlwEnums;
+import com.yunhai.flowcenter.enums.FormBindingErrorCode;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
@@ -45,13 +48,12 @@ import static org.mockito.Mockito.when;
* 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);</li>
* <li><b>发布取确切版本</b>:带版本的关联走 {@code schema?uuid=&version=};
* <b>中心返回的坐标与请求不符即阻断发布</b>,而不是把错的快照嵌进 def 版本行;
- * 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;</li>
+ * 存量 UUID-only 行发布与启用同样阻断,必须重新绑定明确版本;</li>
* <li><b>目录客户端的调用形态</b>:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
* 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。</li>
* </ol>
*
- * <p>注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是
- * 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。</p>
+ * <p>发布与启用按同一错误码分类;表达式三查测试同时使用明确版本。</p>
*/
@DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)")
class FlFormBindingTest {
@@ -125,6 +127,7 @@ class FlFormBindingTest {
f.put("key", key);
f.put("type", type);
f.put("required", Boolean.FALSE);
+ if ("select".equals(type)) f.put("enum", Arrays.asList("g1", "g2"));
return f;
}
@@ -134,6 +137,7 @@ class FlFormBindingTest {
@DisplayName("成对提交:uuid 与 version 一起落行")
void pairIsSavedTogether() {
when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
null, null, null, "FU1", 3);
@@ -198,7 +202,6 @@ class FlFormBindingTest {
FlwDef published = service.publish(SCH, 9L);
verify(schemaClient).fetchSchema(SCH, "FU1", 3);
- verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
assertTrue(published.getContentJson().contains("\"formContract\""), published.getContentJson());
assertTrue(published.getContentJson().contains("FU1"));
}
@@ -214,33 +217,123 @@ class FlFormBindingTest {
BizException e = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
+ assertEquals(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION.getCode(), e.getErrorCode().getCode());
assertTrue(e.getMessage().contains("核验失败"), e.getMessage());
assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage());
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
}
- @Test
- @DisplayName("存量行(本列之前已关联、无版本):继续走当前发布版旧口径")
- void legacyRowWithoutVersionKeepsOldBehaviour() {
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ @DisplayName("缺版本:发布和启用均阻断,不回落当前版本")
+ void missingVersionBlocksPublishAndEnable(boolean enable) {
FlwDef def = draft("FU1", null);
+ if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot("FU1", 9));
- service.publish(SCH, 9L);
+ BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
- verify(schemaClient).fetchSchema(SCH, "FU1");
+ assertEquals(FormBindingErrorCode.VERSION_REQUIRED.getCode(), ex.getErrorCode().getCode());
+ assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
+ verify(defRepository, never()).save(any());
+ }
+
+ private FlwDef changeStatus(boolean enable) {
+ return enable ? service.enable(SCH, 9L) : service.publish(SCH, 9L);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void invalidVersionBlocksPublishAndEnable(boolean enable) {
+ FlwDef def = draft("FU1", 0);
+ if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
+ when(defRepository.findById(9L)).thenReturn(Optional.of(def));
+ BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
+ assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
+ verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
+ verify(defRepository, never()).save(any());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void orphanVersionBlocksPublishAndEnable(boolean enable) {
+ FlwDef def = draft(null, 3);
+ if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
+ when(defRepository.findById(9L)).thenReturn(Optional.of(def));
+ BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
+ assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
+ verify(defRepository, never()).save(any());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void schemaFailuresHaveDistinctStableCodesAndDoNotWrite(boolean enable) {
+ FlwDef def = draft("FU1", 3);
+ if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
+ when(defRepository.findById(9L)).thenReturn(Optional.of(def));
+ RuntimeException[] failures = {
+ new FormCenterSchemaClient.SchemaUnavailableException("timeout", null),
+ new FormCenterSchemaClient.SchemaContractViolationException("wrong version"),
+ new FormCenterSchemaClient.SchemaResourceUnavailableException(null),
+ new FormCenterSchemaClient.SchemaAccessDeniedException(null)
+ };
+ FormBindingErrorCode[] codes = {FormBindingErrorCode.SCHEMA_UNAVAILABLE,
+ FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION, FormBindingErrorCode.RESOURCE_UNAVAILABLE,
+ FormBindingErrorCode.ACCESS_DENIED};
+ for (int i = 0; i < failures.length; i++) {
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenThrow(failures[i]);
+ BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
+ assertEquals(codes[i].getCode(), ex.getErrorCode().getCode());
+ assertSame(failures[i], ex.getCause());
+ assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
+ assertEquals(FLOW_JSON, def.getContentJson());
+ org.mockito.Mockito.reset(schemaClient);
+ }
+ verify(defRepository, never()).save(any());
+ }
+
+ @Test
+ void enableUsesExactVersionAndPreservesPublishedSnapshot() {
+ FlwDef def = draft("FU1", 3);
+ def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
+ def.setContentJson(FLOW_JSON.substring(0, FLOW_JSON.length() - 1)
+ + ",\"formContract\":{\"formUuid\":\"FU1\",\"formVersion\":3,\"fields\":[]}}");
+ String frozenJson = def.getContentJson();
+ when(defRepository.findById(9L)).thenReturn(Optional.of(def));
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
+ FlwDef enabled = service.enable(SCH, 9L);
+ assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), enabled.getStatus());
+ assertEquals(frozenJson, enabled.getContentJson());
+ verify(schemaClient).fetchSchema(SCH, "FU1", 3);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"oops", "3.5", "3.0", "2147483648", "", "true"})
+ void malformedVersionAtHttpBoundaryHasStableCode(String value) {
+ FlowAdminGuard guard = mock(FlowAdminGuard.class);
+ when(guard.requireAdmin(any(), any())).thenReturn(SCH);
+ com.yunhai.flowcenter.controller.api.FlowAdminController controller =
+ new com.yunhai.flowcenter.controller.api.FlowAdminController(guard, service,
+ null, null, null, null, null, null, new ObjectMapper(), null, null, null);
+ Map<String, Object> body = new LinkedHashMap<>();
+ body.put("formUuid", "FU1");
+ body.put("formVersion", value);
+ BizException ex = assertThrows(BizException.class, () -> controller.defSave(null, body));
+ assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
+ verify(defRepository, never()).save(any());
+ org.mockito.Mockito.verifyNoInteractions(schemaClient);
}
@Test
@DisplayName("未关联表单的流程:一次都不碰表单中心")
void noBindingNoCall() {
FlwDef def = draft(null, null);
+ def.setContentJson("{\"variables\":[{\"key\":\"days\",\"type\":\"number\"},{\"key\":\"grade\",\"type\":\"string\"}]," + FLOW_JSON.substring(1));
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
service.publish(SCH, 9L);
- verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
}
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
index 18f9c38..4d6d172 100644
--- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
@@ -85,6 +85,7 @@ class FlowDefAdminServicePublishContractTest {
d.setAppKey("notice");
d.setContentJson(JSON);
d.setFormUuid(formUuid);
+ d.setFormVersion(formUuid == null ? null : 3);
return d;
}
@@ -115,7 +116,7 @@ class FlowDefAdminServicePublishContractTest {
@Test
void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
FlwDef published = service.publish(SCH, 9L);
@@ -134,7 +135,7 @@ class FlowDefAdminServicePublishContractTest {
// 出口条件改成引用快照外变量
d.setContentJson(JSON.replace("grade == 'g1'", "level == 'high'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage());
@@ -147,7 +148,7 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "reason > 3"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"),
@@ -159,7 +160,7 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "days == 'three'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"),
@@ -171,7 +172,7 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "grade == 'g9'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"),
@@ -184,7 +185,7 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "days >= 2.5"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
}
@@ -195,29 +196,139 @@ class FlowDefAdminServicePublishContractTest {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "bizType == 'x'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
- when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot());
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
}
@Test
- void legacyDefWithoutFormUuidSkipsSnapshotEntirely() {
+ void newUnboundVersionCannotOmitVariables() {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft(null)));
+ assertTrue(assertThrows(BizException.class, () -> service.publish(SCH, 9L))
+ .getMessage().contains("必须声明 variables"));
+ verify(defRepository, never()).save(any());
+ verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
+ }
- FlwDef published = service.publish(SCH, 9L);
+ @Test
+ void draftCannotBypassPublishContractThroughOfflineAndEnable() {
+ FlwDef d = draft(null);
+ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
+ assertThrows(BizException.class, () -> service.offline(SCH, 9L));
+ assertThrows(BizException.class, () -> service.enable(SCH, 9L));
+ assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), d.getStatus());
+ verify(defRepository, never()).save(any());
+ }
- assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
- assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动");
- verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
+ @Test
+ void unboundDeclarationsUseSameRulesAsFrozenFcDeclarations() throws Exception {
+ FlwDef bound = draft("FU1");
+ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(bound));
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
+ String published = service.publish(SCH, 9L).getContentJson();
+ FlwDef unbound = draft(null);
+ unbound.setContentJson(published);
+ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(unbound));
+ service.publish(SCH, 9L);
+ ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
+ for (FlwDef d : Arrays.asList(bound, unbound)) {
+ com.yunhai.flowcenter.engine.FlowDefModel model = com.yunhai.flowcenter.engine.FlowDefModel.parse(mapper, d.getContentJson());
+ validator.requireVariablesPresent(model, "{\"days\":4,\"grade\":\"g1\",\"reason\":\"ok\"}");
+ for (String bad : Arrays.asList("{\"days\":4,\"grade\":\"g1\"}",
+ "{\"days\":4,\"grade\":\"g9\",\"reason\":\"ok\"}",
+ "{\"days\":\"4\",\"grade\":\"g1\",\"reason\":\"ok\"}",
+ "{\"days\":4,\"grade\":\"g1\",\"reason\":\"ok\",\"extra\":1}")) {
+ assertThrows(BizException.class, () -> validator.requireVariablesPresent(model, bad));
+ }
+ }
+ }
+
+ @Test
+ void enableUsesFrozenContractAndDoesNotReplaceItFromRemoteSchema() {
+ FlwDef d = draft("FU1");
+ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
+ service.publish(SCH, 9L);
+ String frozen = d.getContentJson();
+ d.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
+ Map<String, Object> changed = snapshot();
+ changed.put("fields", Arrays.asList(field("other", "text", true, null)));
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(changed);
+ service.enable(SCH, 9L);
+ assertEquals(frozen, d.getContentJson());
+ d.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
+ d.setContentJson(frozen.replace("grade == 'g1'", "grade == 'g9'"));
+ assertThrows(BizException.class, () -> service.enable(SCH, 9L));
+ }
+
+ @Test
+ void historicalDisabledVersionWithoutDeclarationsCanBeEnabledUnchanged() {
+ FlwDef d = draft(null);
+ d.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
+ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
+ service.enable(SCH, 9L);
+ assertEquals(JSON, d.getContentJson());
+ }
+
+ @Test
+ void fcVariablesAreDerivedEvenIfDraftTriesToSupplyEmptyDeclarations() throws Exception {
+ FlwDef d = draft("FU1");
+ d.setContentJson(JSON.replaceFirst("\\{", "{\"variables\":[],"));
+ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
+ service.publish(SCH, 9L);
+ JsonNode vars = mapper.readTree(d.getContentJson()).path("variables");
+ assertEquals(3, vars.size());
+ assertEquals("number", vars.get(0).path("type").asText());
+ assertTrue(vars.get(2).path("required").asBoolean());
+ assertEquals("g1", vars.get(1).path("enum").get(0).asText());
+ }
+
+ @Test
+ void allFcTypesPreserveActualSerializedShapes() {
+ java.util.ArrayList<Map<String, Object>> fields = new java.util.ArrayList<>();
+ for (String type : Arrays.asList("text", "textarea", "phone", "number", "date", "select", "radio",
+ "checkbox", "multiselect", "upload", "image", "address", "user", "users")) {
+ fields.add(field(type, type, false, Arrays.asList("select", "radio", "checkbox", "multiselect").contains(type)
+ ? Arrays.asList("v1", "v2") : null));
+ }
+ Map<String, Object> snap = snapshot(); snap.put("fields", fields);
+ FlwDef d = draft("FU1");
+ d.setContentJson(JSON.replace("days > 3 && schId > 0", "schId > 0").replace("grade == 'g1'", "true"));
+ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snap);
+ service.publish(SCH, 9L);
+ com.yunhai.flowcenter.engine.FlowDefModel model = com.yunhai.flowcenter.engine.FlowDefModel.parse(mapper, d.getContentJson());
+ ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
+ validator.requireVariablesPresent(model, "{\"text\":\"ok\",\"textarea\":\"ok\",\"phone\":\"123\",\"number\":1.5,"
+ + "\"date\":\"2024-02-29\",\"select\":\"v1\",\"radio\":\"v2\",\"checkbox\":[\"v1\"],\"multiselect\":[\"v2\"],"
+ + "\"upload\":[{\"id\":1,\"fileName\":\"a.pdf\",\"key\":\"form/a\"}],\"image\":[],\"address\":[\"广东省\"],"
+ + "\"user\":[{\"id\":101,\"name\":\"A\"}],\"users\":[]}");
+ validator.requireVariablesPresent(model, "{\"date\":\"\",\"select\":\"\",\"number\":null,\"checkbox\":[]}");
+ assertThrows(BizException.class, () -> validator.requireVariablesPresent(model, "{\"checkbox\":[\"forged\"]}"));
+ assertThrows(BizException.class, () -> validator.requireVariablesPresent(model, "{\"number\":\"\"}"));
+ }
+
+ @Test
+ void unboundEmptyDeclarationsCannotHideExpressionReferencesAndNullCannotOptOut() {
+ for (String variables : Arrays.asList("[]", "null")) {
+ FlwDef d = draft(null);
+ d.setContentJson("{\"variables\":" + variables + "," + JSON.substring(1));
+ when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
+ assertThrows(BizException.class, () -> service.publish(SCH, 9L));
+ }
+ verify(defRepository, never()).save(any());
}
@Test
void schemaUnavailableBlocksPublishAsRetryableBizError() {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
- when(schemaClient.fetchSchema(SCH, "FU1"))
+ when(schemaClient.fetchSchema(SCH, "FU1", 3))
.thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null));
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
+ assertEquals(com.yunhai.flowcenter.enums.FormBindingErrorCode.SCHEMA_UNAVAILABLE.getCode(),
+ ex.getErrorCode().getCode());
assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage());
verify(defRepository, never()).save(any());
}
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java
index 01f655c..77cd33c 100644
--- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java
@@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -64,7 +65,9 @@ class FlowDefAdminServiceUpdateMetaTest {
}
@Test
- void 三不变_contentJson仅顶层name() throws Exception {
+ void 已发布版本_contentJson封存_仅行内元数据可改() throws Exception {
+ // T03(2026-10-25)发布封存:执行文档不可变——行 name 照改(目录元数据),
+ // content_json 顶层 name 保持发布时字节(旧断言"同步改 JSON"已按新原则作废)
String json = "{\"code\":\"work-leave\",\"name\":\"旧名\",\"start\":\"start\","
+ "\"nodes\":[{\"id\":\"start\",\"type\":\"START\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"end\"}]}";
@@ -79,23 +82,36 @@ class FlowDefAdminServiceUpdateMetaTest {
assertEquals("work-leave", saved.getCode());
assertEquals("新名称", saved.getName());
assertEquals("100008", saved.getAppKey());
- assertEquals("新名称", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText());
- // 结构零改动:节点/边/其余顶层字段原样保留
+ // 发布字节冻结:文档内 name 不随目录改名漂移
+ assertEquals("旧名", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText());
assertEquals("work-leave", new ObjectMapper().readTree(saved.getContentJson()).path("code").asText());
assertTrue(saved.getContentJson().contains("\"type\":\"START\""));
}
@Test
- void contentJson无顶层name_补写() throws Exception {
+ void 已发布版本_无顶层name_不补写() throws Exception {
+ // 发布封存:缺顶层 name 的已发布文档维持原样,不补写
FlwDef def = published("{\"code\":\"c\",\"start\":\"start\",\"nodes\":[],\"edges\":[]}");
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
FlwDef saved = service.updateMeta(SCH, 9L, "补写名", null);
- assertEquals("补写名", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText());
+ assertFalse(new ObjectMapper().readTree(saved.getContentJson()).has("name"));
assertNull(saved.getAppKey());
}
+ @Test
+ void 草稿版本_contentJson顶层name仍同步() throws Exception {
+ // 草稿文档可变:顶层 name 同步保留(与发布封存互为边界)
+ FlwDef def = published("{\"code\":\"c\",\"name\":\"旧名\",\"start\":\"start\",\"nodes\":[],\"edges\":[]}");
+ def.setStatus(0); // DRAFT
+ when(defRepository.findById(9L)).thenReturn(Optional.of(def));
+
+ FlwDef saved = service.updateMeta(SCH, 9L, "草稿新名", null);
+
+ assertEquals("草稿新名", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText());
+ }
+
@Test
void appKey白名单外_被拦() {
when(appRepository.isEnabledApp(SCH, "888888")).thenReturn(false);
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java
index 6f149f6..d8a2994 100644
--- a/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java
@@ -29,8 +29,8 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
- * F5-4 僵尸逃生门(useLatestDef):v1 表达式必失败挂起 → 默认重跑仍按 v1 快照(行为逐字不变);
- * useLatestDef=true 改取当前已发布最高版本 v2 → 快照刷新 + trace 留痕 + 引擎按 v2 续走;
+ * T03 在途锁版(2026-10-25):默认重跑按 v1 快照(行为逐字不变);useLatestDef=true 已按
+ * "在途实例固定原版本"原则停用——显式拒绝并说明替代路径,不静默切换;
* 在途 CONDITION 节点在新定义不存在时报人类可读业务错。锚定 128/129 僵尸案例。
*/
@DisplayName("reroute useLatestDef(F5-4 僵尸逃生门)")
@@ -116,57 +116,43 @@ class FlowRouterAdminServiceRerouteDefTest {
assertTrue(e.getMessage().contains("判断仍失败"), "v1 必失败场景默认重跑仍失败挂起(逃生门入口即为此)");
}
+ // ==================== T03 在途锁版(2026-10-25):useLatestDef 旁路停用 ====================
+
@Test
- void useLatestDefSwitchesToV2AndRefreshesSnapshotWithTrace() {
+ void useLatestDefIsRejectedByDesign() {
+ // 在途实例固定原版本(主方案 §1.3 / T03):新发布版只影响新申报,不再提供"切最新版续走"旁路;
+ // 挂起件按原定义重跑,修复后的表达式按原版本重新发布生效
FlwDef v1 = def(11L, 1, V1);
- FlwDef v2 = def(12L, 2, V2_WITH_COND);
- FlwInstance instance = parkedInstance(v1);
- when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
- eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
- .thenReturn(Optional.of(v2));
-
- service.reroute(SCH, 500L, true);
+ parkedInstance(v1);
- ArgumentCaptor<FlwDef> defCap = ArgumentCaptor.forClass(FlwDef.class);
- verify(flowEngine).rerouteCondition(any(), defCap.capture(), eq("n1"));
- assertEquals(12L, defCap.getValue().getId(), "useLatestDef=true 引擎按 v2 续走");
- assertEquals(12L, instance.getDefId(), "定义快照三列同步刷新(R4 同语义)");
- assertEquals(Integer.valueOf(2), instance.getDefVersion());
- ArgumentCaptor<FlwTask> trace = ArgumentCaptor.forClass(FlwTask.class);
- verify(taskRepository).save(trace.capture());
- assertEquals(FlwEnums.TaskStatus.NODE_REROUTE_DEF, trace.getValue().getNodeId(),
- "trace 落保留节点 id 的留痕行");
- assertTrue(trace.getValue().getNodeName().contains("v1→v2"), "留痕行载明版本迁移");
- verify(instanceRepository).save(instance);
+ BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 500L, true));
+ assertTrue(e.getMessage().contains("在途换版已停用"),
+ "useLatestDef=true 必须显式拒绝并给出替代路径说明,不能静默忽略");
+ verify(flowEngine, never()).rerouteCondition(any(), any(), any());
+ verify(defRepository, never())
+ .findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any());
+ verify(taskRepository, never()).save(any());
}
@Test
- void useLatestDefWithSameVersionIsNoopRefresh() {
- FlwDef v1 = def(11L, 1, V1);
- FlwInstance instance = parkedInstance(v1);
- when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
- eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
- .thenReturn(Optional.of(v1));
-
- service.reroute(SCH, 500L, true);
-
- verify(flowEngine).rerouteCondition(any(), eq(v1), eq("n1"));
- verify(taskRepository, never()).save(any());
- assertEquals(11L, instance.getDefId(), "已是最新版不落留痕行、不刷新");
+ void useLatestDefRejectionHappensBeforeAnyLookup() {
+ // 显式拒绝发生在任何查询之前:即使办件 id 不存在也是同一个"已停用"错(不泄存在性、不误查)
+ BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 99999L, true));
+ assertTrue(e.getMessage().contains("在途换版已停用"));
+ verify(instanceRepository, never()).findById(any());
}
@Test
- void useLatestDefRejectsWhenNodeMissingInNewDef() {
+ void defaultRerouteStillWorksAfterLockdown() {
+ // 旁路停用不影响默认路径:按原版本快照重跑照常
FlwDef v1 = def(11L, 1, V1);
- // v2 把 n1 改型为 APPROVAL:在途 CONDITION 节点在新定义不存在
- FlwDef v2 = def(12L, 2, V2);
- parkedInstance(v1);
- when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
- eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
- .thenReturn(Optional.of(v2));
+ FlwInstance instance = parkedInstance(v1);
- BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 500L, true));
- assertTrue(e.getMessage().contains("最新已发布定义中不存在"), "节点在新定义缺失要报人类可读错,不是笼统的无需重跑");
- verify(flowEngine, never()).rerouteCondition(any(), any(), any());
+ service.reroute(SCH, 500L, false);
+
+ ArgumentCaptor<FlwDef> defCap = ArgumentCaptor.forClass(FlwDef.class);
+ verify(flowEngine).rerouteCondition(any(), defCap.capture(), eq("n1"));
+ assertEquals(11L, defCap.getValue().getId(), "锁版后默认重跑仍按原版本(v1)");
+ assertEquals(11L, instance.getDefId(), "定义快照零刷新");
}
}
diff --git a/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java b/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java
index 472668f..73aad4e 100644
--- a/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java
@@ -2,6 +2,12 @@ package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
+import com.yunhai.platform.openapi.core.OpenApiException;
+import com.yunhai.platform.openapi.core.OpenApiErrorCode;
+import com.yunhai.platform.utils.exception.DException;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -72,7 +78,7 @@ class FormCenterSchemaClientTest {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER")));
- Map<String, Object> snap = client.fetchSchema(27L, "U1");
+ Map<String, Object> snap = client.fetchSchema(27L, "U1", 3);
assertEquals("U1", snap.get("formUuid"));
assertEquals(3, snap.get("formVersion"));
@@ -88,11 +94,11 @@ class FormCenterSchemaClientTest {
@Test
void legacySingleStringAudienceIsWrappedAsArray() {
- Map<String, Object> data = schemaData("U1", 1, null);
+ Map<String, Object> data = schemaData("U1", 3, null);
data.put("audience", "STUDENT");
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
- Map<String, Object> snap = client.fetchSchema(27L, "U1");
+ Map<String, Object> snap = client.fetchSchema(27L, "U1", 3);
assertEquals(Arrays.asList("STUDENT"), snap.get("audience"),
"C5 切换窗口的旧单值串按一元素数组容错");
@@ -104,7 +110,7 @@ class FormCenterSchemaClientTest {
.thenThrow(new RuntimeException("connect refused"));
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
- () -> client.fetchSchema(27L, "U1"));
+ () -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
.get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@@ -115,7 +121,77 @@ class FormCenterSchemaClientTest {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null);
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
- () -> client.fetchSchema(27L, "U1"));
- assertTrue(true);
+ () -> client.fetchSchema(27L, "U1", 3));
+ verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
+ .get(anyString(), anyString(), anyMap(), eq(Map.class));
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {"0", "-1", "4", "3.5", "wrong", "2147483648"})
+ void malformedOrWrongReturnedVersionIsContractFailureWithoutRetry(String returnedVersion) {
+ Map<String, Object> data = schemaData("U1", 3, Arrays.asList("STUDENT"));
+ data.put("version", returnedVersion);
+ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
+ assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
+ () -> client.fetchSchema(27L, "U1", 3));
+ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {"", "U2"})
+ void missingOrWrongReturnedUuidIsContractFailureWithoutRetry(String returnedUuid) {
+ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
+ .thenReturn(schemaData(returnedUuid, 3, Arrays.asList("STUDENT")));
+ assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
+ () -> client.fetchSchema(27L, "U1", 3));
+ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, -1})
+ void illegalRequestedVersionMakesNoRemoteCall(int version) {
+ assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(27L, "U1", version));
+ org.mockito.Mockito.verifyNoInteractions(openCall);
+ }
+
+ @Test
+ void resourceRejectionUsesEnvelopeCodeAndDoesNotRetry() {
+ // SDK 实际携带 OpenApiErrorCode,不会转换成本地 ErrorCode 枚举;文案刻意无关键词。
+ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
+ .thenThrow(new DException(new OpenApiErrorCode(2, "opaque")));
+ assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class,
+ () -> client.fetchSchema(27L, "U1", 3));
+ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {3, 4})
+ void authRejectionDoesNotBlindlyRetry(int code) {
+ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
+ .thenThrow(new DException(new OpenApiErrorCode(code, "opaque")));
+ assertThrows(FormCenterSchemaClient.SchemaAccessDeniedException.class,
+ () -> client.fetchSchema(27L, "U1", 3));
+ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
+ }
+
+ @Test
+ void http404DoesNotBlindlyRetry() {
+ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
+ .thenThrow(new OpenApiException("opaque", 404, ""));
+ assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class,
+ () -> client.fetchSchema(27L, "U1", 3));
+ verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
+ }
+
+ @Test
+ void internalRemoteFailureRemainsRetryable() {
+ when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
+ .thenThrow(new DException(new OpenApiErrorCode(6, "opaque")));
+ assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
+ () -> client.fetchSchema(27L, "U1", 3));
+ verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
+ .get(anyString(), anyString(), anyMap(), eq(Map.class));
}
}
diff --git a/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java
index 24d705e..6e804ea 100644
--- a/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java
@@ -47,6 +47,7 @@ class OapiFlowApplyDefVersionTest {
private FlowInstanceService instanceService;
private AssigneeResolver assigneeResolver;
private OapiFlowService service;
+ private FlwInstanceRepository instances;
@BeforeEach
@SuppressWarnings("unchecked")
@@ -54,7 +55,8 @@ class OapiFlowApplyDefVersionTest {
defRepository = mock(FlwDefRepository.class);
instanceService = mock(FlowInstanceService.class);
assigneeResolver = mock(AssigneeResolver.class);
- service = new OapiFlowService(defRepository, mock(FlwInstanceRepository.class),
+ instances = mock(FlwInstanceRepository.class);
+ service = new OapiFlowService(defRepository, instances,
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class),
mock(FlowEngine.class), assigneeResolver, new ObjectMapper(),
@@ -150,4 +152,20 @@ class OapiFlowApplyDefVersionTest {
assertEquals(Integer.valueOf(1), vo.getDefVersion());
assertTrue(vo.getNodes() == null || vo.getNodes().isEmpty());
}
+ @Test
+ void v2ResponseCarriesRealRejectedStatusAndStableBranch() {
+ stubDef(3);
+ com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2 body = new com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2();
+ body.setFlowGuid(FLOW_GUID); body.setBizType("svc-hall"); body.setBizId("42"); body.setSubmitterId(21);
+ body.setRequestToken("token-1"); body.setAttempt(1);
+ when(instanceService.applyGuarded(any(), any())).thenReturn(
+ new FlowInstanceService.GuardedOutcome(500L, true, "SAME_REQUEST", 1));
+ com.yunhai.flowcenter.entity.FlwInstance row = new com.yunhai.flowcenter.entity.FlwInstance();
+ row.setId(500L); row.setStatus(30); row.setVersion(4); row.setApplyRound(1);
+ when(instances.findById(500L)).thenReturn(Optional.of(row));
+ com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyResultV2 result = service.applyV2(SCH_ID, "100009", body);
+ assertEquals("SAME_REQUEST", result.getBranch()); assertEquals(Integer.valueOf(1), result.getApplyRound());
+ assertEquals(Integer.valueOf(30), result.getStatus()); assertEquals(Integer.valueOf(4), result.getVersion());
+ }
+
}
diff --git a/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java b/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java
index 9c02828..339c7c3 100644
--- a/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java
+++ b/src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java
@@ -22,6 +22,14 @@ public class StubUserClient implements OpenApiUserClient {
public static final long TEST_SCH_ID = 1L;
+ /** D11 资格终审夹具:特殊账号按固定 ID 下发,避免各 IT 各自造 UserBase 桩。 */
+ public static final int DISABLED_USER_ID = 9001;
+ public static final int OTHER_SCHOOL_USER_ID = 9002;
+ public static final int UNKNOWN_STATUS_USER_ID = 9003;
+ public static final int NO_SCHOOL_USER_ID = 9004;
+ public static final int MISSING_USER_ID = 9999;
+ public static final int USERBASE_UNAVAILABLE_ID = 9998;
+
@Override
public BoaUser getUserById(String userId) {
return buildUser(Integer.valueOf(userId));
@@ -33,8 +41,29 @@ public class StubUserClient implements OpenApiUserClient {
}
private static BoaUser buildUser(int userId) {
+ if (userId == MISSING_USER_ID) {
+ return null;
+ }
+ if (userId == USERBASE_UNAVAILABLE_ID) {
+ throw new IllegalStateException("stub UserBase unavailable");
+ }
BoaUser user = new BoaUser(userId, "user" + userId, "用户" + userId);
- user.setSchool(new BoaGroup((int) TEST_SCH_ID, "测试学校"));
+ if (userId == NO_SCHOOL_USER_ID) {
+ user.setSchool(null);
+ } else if (userId == OTHER_SCHOOL_USER_ID) {
+ user.setSchool(new BoaGroup(2, "外校"));
+ } else {
+ user.setSchool(new BoaGroup((int) TEST_SCH_ID, "测试学校"));
+ }
+ // T06 禁用账号终审(2026-10-25):stub 用户按在册正常账号下发(1=正常),
+ // 与 UserBase toLightBoaUser 口径对齐;个别用例自建 BoaUser 不受影响
+ if (userId == DISABLED_USER_ID) {
+ user.setStatus(0);
+ } else if (userId == UNKNOWN_STATUS_USER_ID) {
+ user.setStatus(null);
+ } else {
+ user.setStatus(1);
+ }
return user;
}
diff --git a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java
index 5e764f4..e71bcf6 100644
--- a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java
+++ b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java
@@ -54,6 +54,10 @@ public abstract class FlowTestSupport {
protected FlwRouterRepository routerRepository;
@Autowired
protected FlwRouteLogRepository routeLogRepository;
+ @Autowired
+ protected com.yunhai.flowcenter.repository.FlwStateEventRepository stateEventRepository;
+ @Autowired
+ protected com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository advanceIntentRepository;
/** tag → 实际 deptKey(flw_dept.id 字符串);mapDept 注册,def helper 消费 */
private final Map<String, String> deptKeyByTag = new HashMap<>();
@@ -64,6 +68,11 @@ public abstract class FlowTestSupport {
}
protected void cleanDb() {
+ // D09: intents must not outlive the tasks/instances whose activations they describe.
+ advanceIntentRepository.deleteAll();
+ // 事实行先清:否则上一用例/上一轮遗留的 dispatched=0 事实会被 60s 补分发扫描
+ // 按实例行复活重投(跨类注入并发推送者;FormalApplyCommandIT 此前被迫自清即此缺口)
+ stateEventRepository.deleteAll();
logRepository.deleteAll();
regRepository.deleteAll();
ccRepository.deleteAll();
diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml
index ac07ccc..baa7036 100644
--- a/src/test/resources/application-test.yml
+++ b/src/test/resources/application-test.yml
@@ -10,6 +10,15 @@ spring:
yh:
app-auth:
trust-registry: false
+ # R2:test 域引入 yh-flow-callback-spring-boot-starter(真实接收端契约测试用),
+ # 关闭其自动装配——FL 是回调生产方不是消费方,测试上下文不应多出接收端点/存储表
+ flow-callback:
+ enabled: false
+
+# 回调推送管道 DEBUG(P1-1 插桩:在途跳过/行缺席早退此前零痕迹,计数被吞无从取证)
+logging:
+ level:
+ com.yunhai.flowcenter.callback: debug
flow-center:
usermsg: