build / build-push (push) Successful in 2m0s
requireLine() 存在性判断取反,导致已存在的流程线被拒绝、不存在的线反被放行, 线级停发/恢复线上 100% 失败。改为查询为空时拒绝。 - FlowDefAdminService.java:302 补 ! 号(唯一生产代码改动) - FlDefLineOfflineTest 改用真实 FlowDefAdminService(删除业务副本 spy),补非法 GUID/空值/空白/错误学校/trim/在途隔离断言 - FlNewInstancePathTest 新增新实例路径定向测试
5236 lines
316 KiB
Diff
5236 lines
316 KiB
Diff
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=&uuid= ——按 form_uuid 拉当前已发布版 schema
|
||
- * (T11 建立的 by-uuid 形态;version 缺省=当前发布版)。调用形态对齐 {@link RouteJudgeClient}:
|
||
+ * GET {目录解析 form-center}/oapi/form/schema?schId=&uuid=&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:
|