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' 'nexus-reponexus-internalhttp://nexus:8081/repository/maven-public/truetruealwaysnexus-repo' > /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 @@ spring-boot-starter-test test + + + com.yunhai.platform + yh-flow-callback-spring-boot-starter + test + 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) 重推;死信留管理端手工重推。 + * + *

R2(Codex D04)投递可靠性口径: + *

*/ @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 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 stored = objectMapper.readValue(fact.getPayloadJson(), Map.class); + java.util.List 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 stored) throws Exception { + String payloadJson; + if (stored.containsKey("eventId") && stored.containsKey("flowGuid")) { + // R2 冻结体:完整信封已含全部投递字段,原样复用(不可变) + payloadJson = fact.getPayloadJson(); + } else { + // R2 前存量事实行:信封字段缺失,按事实列值+现查 flowGuid 幂等修复 + Map 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 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 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 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 legacyEnvelope(FlowEvent event, FlwInstance instance, FlwDef def, + String eventId) { + Map 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 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 resetForReplay(Long school, Collection ids) { + try (Claim claim = openSession()) { + List 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 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 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; * 持续重试不终止(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 IN_FLIGHT = new HashSet<>(); + /** 仅观测,不阻断数据库租期后的恢复;每个执行者有独立本地状态。 */ + private final Map 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 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()} * 封顶继续排期,绝不自动死信;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 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 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 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 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 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 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 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}]}。 + * 只读预览:不建实例、不建任务、不落任何流程事实;正式提交仍走 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 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 refs = new ArrayList<>(); if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { + // D11:科室启用 ≠ 成员都可办理——逐人走同一资格终审;不合格成员按既有 + // “只取启用成员”口径剔除(local status 已在 resolveEnabled 过滤),并留日志。 + List 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 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 enabledDeptKeys, Set enabledRouterKeys) { List 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} 口径)。 * *

EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦 - * (表单变量是子应用运行时传的,发布期无从核对 schema,一律放行、缺失按 null 注入求值)。 + * 新契约的变量引用/类型/值域由 VariableContract 统一检查;历史无声明版本保持兼容。 */ private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set enabledRouterKeys, List 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 generations = intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId); + Set parents = new HashSet<>(); + List 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 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 nodes; public List edges; public Settings settings; + /** null = 历史无契约版本;新发布必须显式声明(无业务变量时为 [])。 */ + public List 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 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 data; + private Map 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 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 nodes = new HashMap<>(); + final java.util.Set 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 仅保留迁移前旧入口的一业务一行语义;正式模式关闭旧写入口。 * *

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 分轮。 * *

轮次守卫是单调的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投, * 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。

@@ -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 { + /** T05 投递幂等:同一事件事实对同一订阅方只建一行(补分发与正常分发竞争时去重) */ + boolean existsByEventIdAndRegId(String eventId, Long regId); + /** 重推扫描主路径:待推/重试中且到点 */ List 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 { /** 抄送我的(用户面页签,走 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 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 { List 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 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 findBySchIdAndFlowGuidIn(Long schId, Collection 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 { + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @Query("select i from FlwInstance i where i.id=:id") + Optional 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 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 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 findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId); List 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 { + /** 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 findTransferParentCandidates(@Param("newTaskId") Long newTaskId, @Param("operatorId") Integer operatorId); + List findByInstanceIdOrderByIdAsc(Long instanceId); List findByInstanceIdAndStatus(Long instanceId, Integer status); @@ -52,10 +58,13 @@ public interface FlwTaskRepository extends JpaRepository { @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 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 pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, @Param("appKey") String appKey, Pageable pageable); @@ -84,7 +93,7 @@ public interface FlwTaskRepository extends JpaRepository { @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 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 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 快照纪律同构:在途办件按发起时快照校验), * 并做表达式键查①红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试); - * 运行期对 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 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 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 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 snapshot, - List problems) { - @SuppressWarnings("unchecked") - List> fields = (List>) snapshot.get("fields"); - java.util.Set fieldKeys = new java.util.HashSet<>(); - if (fields != null) { - for (Map 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 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 snapshot) { + private String embedFormContract(String contentJson, Map snapshot, + List 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 NUMERIC_TYPES = - new java.util.HashSet<>(java.util.Arrays.asList("number")); - /** 值域字面量型(enum 域查③适用):options 收进快照 enum 的类型 */ - private static final java.util.Set 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); + } /** - * 类型桶查②红牌 + 域查③红牌(启发式实现:正则模式匹配字面量比较形态,非 AST—— - * v2 §4.7 保守边界:不执行表达式、不做隐式转换,只拦"数值比较 × 非数值字段"与 - * "枚举等值 × 字面量不在值域"两类高发错配;不命中的形态留给运行期既有挂起纪律)。 + * 线级停发(2026-10-25 T03):整条 flowGuid 停止新发起/预览,在途实例按各自固定 defId 原版跑完。 + * 与版本级 {@link #offline} 正交(可叠加);批量 UPDATE 全版本行,读任一行即读线状态。 + * 不改写任何发布内容字节,恢复走 {@link #enableLine}。 */ - @SuppressWarnings("unchecked") - private void validateExpressionTypesAndDomains(FlowDefModel model, Map snapshot, - List problems) { - List> fields = (List>) snapshot.get("fields"); - if (fields == null || fields.isEmpty()) { - return; - } - Map> fieldByKey = new HashMap<>(); - for (Map 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 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 enums = (List) 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> fieldByKey, List problems) { - Map 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 checkedSchema(Long schId, String uuid, Integer version) { - if (version == null || version < 1) { - throw new BizException(ErrorCode.PARAM_ERROR, "关联表单必须选择确切的正整数版本,请重新选择"); + private Map 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 复用仅描述旧入口。 * *

R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿):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;非构造参数——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, + * 不信客户端 attempt。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 的 + * "切到最新发布版续走"旁路已按"在途实例固定原版本"原则停用——新发布版只影响新申报; + * 挂起件按原定义重跑(修复表达式后按原版本重新发布或由发起方重新申报)。 + * 保留参数与显式拒绝(不静默忽略),旧调用方拿到人类可读业务错。 */ @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 第一刀:发布期契约快照)。
- * 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} 装配链注入)。
- * 只在发布期调用(拉取失败=发布阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。
+ * 只在管理期调用(拉取失败=阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。
* audience 解析:契约 v2 C5/G3 起是字符串数组(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 fetchSchema(Long schId, String formUuid) { - RuntimeException last = null; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - Map query = new HashMap<>(); - query.put("schId", String.valueOf(schId)); - query.put("uuid", formUuid); - Map 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); } /** * 发布期快照的确切版本形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=}, * 并把返回坐标与请求坐标逐件核对——不符即 {@link SchemaContractViolationException}(不重试)。 * - *

刻意不与两参 {@link #fetchSchema(Long, String)} 共用循环体:那条是 C1 第一刀的既有行为 - * ({@code FormCenterSchemaClientTest} 逐条钉住它的重试与文案),改它等于同时动两条契约。 - * 新管理链(def 带 {@code formVersion})只走本方法。

+ * fields 按分组扁平化,options 值域收进 enum;没有缺省当前版本的重载。 */ public Map 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 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 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 listFlows(Long schId) { List 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; + } + /** * 并发撞键后的收口:重读既有实例 → 先核归属 → 同 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 missingRequiredAssignNodes(FlowDefModel model, List assigns) { + java.util.Set submitted = new java.util.HashSet<>(); + if (assigns != null) { + for (OapiModels.AssignItem item : assigns) { + if (item.getNodeId() != null) { + submitted.add(item.getNodeId()); + } + } + } + List 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 assigns, Integer submitterId) { + FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); + // T06(2026-10-25)required 覆盖终审:遍历定义里全部开启且必选的指派节点—— + // 不能只遍历请求里出现的项,更不能让空 assigns 绕过 required 检查(审计 §7.4 点名)。 + List 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>> snapshot = new java.util.LinkedHashMap<>(); Set 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> rows = new ArrayList<>(); + for (Integer uid : uids) { + String userName = assigneeResolver.requireEligibleAssigneeName(schId, uid); + Map row = new HashMap<>(); + row.put("userId", uid); + row.put("userName", userName); + rows.add(row); + } // 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝 List candidates = assigneeResolver.resolve(schId, node); Set candidateIds = new LinkedHashSet<>(); @@ -344,14 +661,6 @@ public class OapiFlowService { "指派人数越界[" + min + "~" + max + "]:" + item.getNodeId()); } } - List> rows = new ArrayList<>(); - for (Integer uid : uids) { - String userName = assigneeResolver.requireUserName(uid); - Map 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 p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), - PageRequest.of(normPage(page), normSize(size))); + Page 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 instanceIds = p.getContent().stream() .map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList()); Map instances = instanceIds.isEmpty() @@ -457,8 +765,9 @@ public class OapiFlowService { if (schId == null) { throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId"); } - Page p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, - PageRequest.of(normPage(page), normSize(size))); + Page 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 tasksBefore=allTasks(id); + java.util.List> 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 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/非办理人/办理人本人均业务码拒绝、 + * 任务保持待办;合法办理经 CAS 会话路径(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 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 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 ok = client.post( + // R1:办理人(101)本人自报 operatorId → 同样拒绝(知道办理人 ID ≠ 该用户本人在操作) + ResponseEntity 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 noOpinion = client.post( + ResponseEntity oapiNoOpinion = client.post( "/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲", "{}", JsonNode.class); - assertNotEquals(0, noOpinion.getBody().get("code").asInt(), "拒绝必须填写意见"); - ResponseEntity 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 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; * 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点); *
  • 发布取确切版本:带版本的关联走 {@code schema?uuid=&version=}; * 中心返回的坐标与请求不符即阻断发布,而不是把错的快照嵌进 def 版本行; - * 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;
  • + * 存量 UUID-only 行发布与启用同样阻断,必须重新绑定明确版本; *
  • 目录客户端的调用形态:命名目标 {@code form-center} + 查询里带 schId/page/pageSize, * 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。
  • * * - *

    注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是 - * 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。

    + *

    发布与启用按同一错误码分类;表达式三查测试同时使用明确版本。

    */ @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 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 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> 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 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 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 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 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 snap = client.fetchSchema(27L, "U1"); + Map 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 data = schemaData("U1", 1, null); + Map data = schemaData("U1", 3, null); data.put("audience", "STUDENT"); when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data); - Map snap = client.fetchSchema(27L, "U1"); + Map 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 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 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: