diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 82794b8..4afa17c 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -111,7 +111,7 @@ jobs: 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" tables=$(docker exec "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -h127.0.0.1 -u"$MYSQL_USER" "$MYSQL_DATABASE" -Nse "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()"') - [ "$tables" = 14 ] || { echo "Expected 14 CI schema tables, got $tables" >&2; exit 1; } + [ "$tables" = 20 ] || { echo "Expected 20 CI schema tables, got $tables" >&2; exit 1; } echo "CI_SCHEMA_TABLES=$tables" # 先在独立空 schema 验证真实 validate 失败会阻断打包;随后再生成正向候选 JAR。 printf '%s\n' 'CREATE DATABASE flowcenter_ci_invalid;' "GRANT ALL ON flowcenter_ci_invalid.* TO 'flow_ci'@'%';" | \ diff --git a/ci/README.md b/ci/README.md index dcd0d17..66e657d 100644 --- a/ci/README.md +++ b/ci/README.md @@ -5,7 +5,7 @@ 所有指定类必须存在;Surefire 不允许零测试;Hibernate 固定 `ddl-auto=validate`。 工作流在 `infra-infra-net` 内新建 `${BUILD_CONTAINER}-db`,镜像 `mysql:8.0`,不映射宿主端口。 -在启动之前把 `flow-test-schema.sql` 复制进初始化目录;该文件只含 14 张表的结构,不含业务数据。 +在启动之前把 `flow-test-schema.sql` 复制进初始化目录;该文件只含 20 张表的结构,不含业务数据。 镜像初始化阶段建立测试 schema,测试阶段只 validate。每个 job 的数据库容器名和随机凭据独立, EXIT trap 回收 Maven/MySQL 容器;不访问 160 业务库。 @@ -34,6 +34,6 @@ CI 场景必须实际连接 `flowcenter_ci*`,不能悄悄改用默认开发库 `codex/core-rc-20261023` 的 push 只执行测试/JAR;镜像推送与通知同时受 main ref 与 tests_only 条件约束。 检出后核验 `CI_SOURCE_SHA` 等于事件 SHA,不能使用随后更新的 main。 FL 在同一独立 MySQL 容器额外创建空 `flowcenter_ci_invalid`,先运行 `verify-failure-guard.sh`: -要求 11 项真实数据库测试因 validate 缺表报错、Maven 非零且无 JAR;随后对 14 表正常 schema 执行正向门禁。 +要求 11 项真实数据库测试因 validate 缺表报错、Maven 非零且无 JAR;随后对 20 表正常 schema 执行正向门禁。 空库检查的预期失败不属于 380 项正向用例。回收检查打印 `CI_CONTAINER_CLEANUP=PASS`,同时删除本次容器的匿名卷。 本文件是运行约定;只有取得对应远端准确 SHA 的实际日志,才能宣称 runner 已完成验证。 diff --git a/ci/flow-test-schema.sql b/ci/flow-test-schema.sql index b09bab3..7b1c308 100644 --- a/ci/flow-test-schema.sql +++ b/ci/flow-test-schema.sql @@ -1,3 +1,6 @@ +-- FL 集成测试独立 schema:当前 116 个实体映射出的全部 20 张表结构,不含任何业务数据。 +-- 生成方式:2026-10-27 用当前代码在隔离 MySQL 8.0.45 以 ddl-auto=create 建库后导结构(mysqldump --no-data)。 +-- 实体结构变更时必须重新生成本文件并重跑 ci/test-gate.sh;不得改 ddl-auto 来绕过缺表/缺列。 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; @@ -11,6 +14,37 @@ /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_advance_intent` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `activation_apply_round` int DEFAULT NULL, + `activation_def_id` bigint DEFAULT NULL, + `activation_def_version` int DEFAULT NULL, + `activation_task_id` bigint DEFAULT NULL, + `activation_task_ids` longtext COLLATE utf8mb4_general_ci, + `assignee_id` int DEFAULT NULL, + `assignee_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `attempts` int NOT NULL, + `edge_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, + `handled_task_ids` longtext COLLATE utf8mb4_general_ci, + `instance_id` bigint NOT NULL, + `last_error` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `next_retry_at` datetime DEFAULT NULL, + `node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `revision` bigint NOT NULL, + `status` varchar(16) COLLATE utf8mb4_general_ci NOT NULL, + `trigger_task_id` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_advance_activation` (`activation_task_id`), + KEY `idx_flw_advance_intent_due` (`status`,`next_retry_at`), + KEY `idx_flw_advance_intent_instance` (`instance_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `flw_app` ( `id` bigint NOT NULL AUTO_INCREMENT, `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, @@ -29,6 +63,34 @@ CREATE TABLE `flw_app` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_apply_command` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `biz_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `canonical_hash` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `canonical_version` int NOT NULL, + `def_id` bigint DEFAULT NULL, + `def_version` int DEFAULT NULL, + `flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `instance_id` bigint DEFAULT NULL, + `reject_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `reject_detail` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL, + `request_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `apply_round` int DEFAULT NULL, + `sch_id` bigint NOT NULL, + `source_principal_id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + `state` varchar(16) COLLATE utf8mb4_general_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_command_request` (`sch_id`,`source_principal_id`,`request_id`), + KEY `idx_flw_command_instance` (`instance_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `flw_assignee_map` ( `id` bigint NOT NULL AUTO_INCREMENT, `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, @@ -68,6 +130,25 @@ CREATE TABLE `flw_attachment` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_biz_coordinate` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `active_instance_id` bigint DEFAULT NULL, + `biz_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `last_instance_id` bigint DEFAULT NULL, + `last_round` int NOT NULL, + `sch_id` bigint NOT NULL, + `source_principal_id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_coord` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `flw_branch` ( `id` bigint NOT NULL AUTO_INCREMENT, `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, @@ -100,12 +181,12 @@ CREATE TABLE `flw_callback_log` ( `payload_json` longtext COLLATE utf8mb4_general_ci NOT NULL, `push_time` datetime DEFAULT NULL, `reg_id` bigint NOT NULL, + `resp_body` text COLLATE utf8mb4_general_ci, `retry_count` int NOT NULL, `sch_id` bigint NOT NULL, `status` int NOT NULL, - `resp_body` text COLLATE utf8mb4_general_ci, PRIMARY KEY (`id`), - UNIQUE KEY `uk_flw_log_event` (`event_id`), + UNIQUE KEY `uk_flw_log_event_reg` (`event_id`,`reg_id`), KEY `idx_flw_log_scan` (`status`,`next_retry_time`), KEY `idx_flw_log_inst` (`instance_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; @@ -119,13 +200,14 @@ CREATE TABLE `flw_callback_reg` ( `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, `update_time` datetime DEFAULT NULL, `app_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `base_path` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, `callback_url` varchar(500) COLLATE utf8mb4_general_ci NOT NULL, `event_types` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, `flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, `sch_id` bigint NOT NULL, `secret` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL, `status` int NOT NULL, - `base_path` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_flw_reg_sch_app_flow` (`sch_id`,`app_key`,`flow_guid`), KEY `idx_flw_reg_flow` (`sch_id`,`flow_guid`,`status`) @@ -135,21 +217,29 @@ CREATE TABLE `flw_callback_reg` ( /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `flw_cc` ( `id` bigint NOT NULL AUTO_INCREMENT, - `instance_id` bigint NOT NULL COMMENT '归属办件', - `sch_id` bigint NOT NULL COMMENT '租户键(冗余,列表免 join)', - `node_id` varchar(64) NOT NULL COMMENT '定义节点 id', - `node_name` varchar(128) DEFAULT NULL COMMENT '节点名快照', - `user_id` int NOT NULL COMMENT '接收人 id', - `user_name` varchar(128) DEFAULT NULL COMMENT '接收人姓名快照', - `sent_time` datetime(3) NOT NULL COMMENT '抄送时间(节点激活时刻,毫秒)', - `create_by` varchar(64) DEFAULT NULL, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, `create_time` datetime DEFAULT NULL, - `update_by` varchar(64) DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, `update_time` datetime DEFAULT NULL, + `instance_id` bigint NOT NULL, + `node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, + `sch_id` bigint NOT NULL, + `sent_time` datetime(3) NOT NULL, + `user_id` int NOT NULL, + `user_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_flw_cc_user` (`user_id`,`sent_time`), KEY `idx_flw_cc_inst` (`instance_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='抄送留痕(NOTIFY 节点逐接收人一行)'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_command_source` ( + `id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL, + `app_id` int NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -159,22 +249,21 @@ CREATE TABLE `flw_def` ( `create_time` datetime DEFAULT NULL, `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, `update_time` datetime DEFAULT NULL, + `app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `change_log` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL, `code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, `content_json` longtext COLLATE utf8mb4_general_ci NOT NULL, `flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, - `form_id` bigint DEFAULT NULL, + `form_uuid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `form_version` int DEFAULT NULL, `item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `line_offline` int NOT NULL DEFAULT '0', `name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, `publish_time` datetime DEFAULT NULL, `sch_id` bigint NOT NULL, `status` int NOT NULL, `version` int NOT NULL, - `app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, - `change_log` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL, - `form_uuid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, - `form_version` int DEFAULT NULL, PRIMARY KEY (`id`), - UNIQUE KEY `uk_flw_def_sch_code_ver` (`sch_id`,`code`,`version`), UNIQUE KEY `uk_flw_def_guid_ver` (`sch_id`,`flow_guid`,`version`), KEY `idx_flw_def_guid` (`sch_id`,`flow_guid`,`status`,`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; @@ -221,6 +310,12 @@ CREATE TABLE `flw_instance` ( `create_time` datetime DEFAULT NULL, `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, `update_time` datetime DEFAULT NULL, + `apply_app_code` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `apply_app_key` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `apply_payload_hash` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `apply_request_token` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `apply_round` int DEFAULT NULL, + `assign_json` longtext COLLATE utf8mb4_general_ci, `biz_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, `biz_snapshot` longtext COLLATE utf8mb4_general_ci, `biz_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, @@ -230,23 +325,22 @@ CREATE TABLE `flw_instance` ( `def_code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, `def_id` bigint NOT NULL, `def_version` int NOT NULL, + `event_seq_counter` int DEFAULT NULL, `finish_time` datetime DEFAULT NULL, `form_data_json` longtext COLLATE utf8mb4_general_ci, `item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, `item_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, + `legacy_slot` int DEFAULT NULL, `sch_id` bigint NOT NULL, + `source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL, `status` int NOT NULL, `submit_time` datetime DEFAULT NULL, `submitter_id` int NOT NULL, `submitter_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, `version` int NOT NULL, - `apply_app_code` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, - `apply_app_key` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, - `assign_json` longtext COLLATE utf8mb4_general_ci, - `apply_request_token` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, - `apply_round` int DEFAULT NULL, PRIMARY KEY (`id`), - UNIQUE KEY `uk_flw_instance_biz` (`sch_id`,`biz_type`,`biz_id`), + UNIQUE KEY `uk_flw_instance_legacy` (`sch_id`,`biz_type`,`biz_id`,`legacy_slot`), + UNIQUE KEY `uk_flw_instance_round` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`,`apply_round`), KEY `idx_flw_inst_submitter` (`sch_id`,`submitter_id`,`status`), KEY `idx_flw_inst_status_time` (`sch_id`,`status`,`submit_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; @@ -281,30 +375,56 @@ CREATE TABLE `flw_router` ( `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, `update_time` datetime DEFAULT NULL, `base_path` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `direct_host` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, `name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL, `path` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, + `reg_app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, `router_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, `service_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, `status` int NOT NULL, - `direct_host` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, - `reg_app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_flw_router_key` (`router_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_state_event` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `apply_round` int DEFAULT NULL, + `dispatched` int NOT NULL, + `event_id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `event_seq` int NOT NULL, + `event_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL, + `instance_id` bigint NOT NULL, + `operator_id` int DEFAULT NULL, + `payload_json` longtext COLLATE utf8mb4_general_ci NOT NULL, + `sch_id` bigint NOT NULL, + `task_id` bigint DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_state_event_id` (`event_id`), + KEY `idx_flw_state_event_instance` (`instance_id`), + KEY `idx_flw_state_event_dispatch` (`dispatched`,`create_time`), + KEY `idx_flw_state_event_seq` (`instance_id`,`event_seq`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `flw_task` ( `id` bigint NOT NULL AUTO_INCREMENT, `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, `create_time` datetime DEFAULT NULL, `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, `update_time` datetime DEFAULT NULL, - `arrive_time` datetime(3) NOT NULL COMMENT '到达时间(毫秒)', + `arrive_time` datetime(3) NOT NULL, `assignee_id` int NOT NULL, `assignee_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, `deadline` datetime DEFAULT NULL, - `handle_time` datetime(3) DEFAULT NULL COMMENT '办理时间(毫秒)', + `edge_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `handle_time` datetime(3) DEFAULT NULL, `instance_id` bigint NOT NULL, `node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, `node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, @@ -312,12 +432,33 @@ CREATE TABLE `flw_task` ( `sch_id` bigint NOT NULL, `status` int NOT NULL, `urge_count` int NOT NULL, - `edge_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_flw_task_inst_status` (`instance_id`,`status`), KEY `idx_flw_task_assignee` (`assignee_id`,`status`,`arrive_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `flw_usage_grant` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `app_id` int NOT NULL, + `app_key_snapshot` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `app_name_snapshot` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, + `enabled` int NOT NULL, + `flow_guid` varchar(64) COLLATE utf8mb4_general_ci NOT NULL, + `remark` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `sch_id` bigint NOT NULL, + `source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_usage_grant` (`sch_id`,`flow_guid`,`app_id`), + KEY `idx_flw_usage_grant_sch_flow` (`sch_id`,`flow_guid`), + KEY `idx_flw_usage_grant_app` (`app_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; 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 61afcc8..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,58 +237,87 @@ 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 { - 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()); - } - // 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。 - if (event.getRoundIdentity() != null) { - data.putAll(event.getRoundIdentity()); - } - payload.put("data", data); - String payloadJson = objectMapper.writeValueAsString(payload); + logId = logStore.persist(callbackLog); + } catch (DuplicateKeyException dup) { + return; // uk(event_id, reg_id) 并发兜底:另一路径已建行,幂等成功 + } + submitPush(logId); + } - 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 起) + /** 异步首推提交:失败不回退建行结果(行已在,重试扫描接手) */ + private void submitPush(Long logId) { + try { 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 永远只是加速器) */ @Scheduled(fixedDelay = 30_000) public void scanRetry() { diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackFactStore.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackFactStore.java new file mode 100644 index 0000000..4456a65 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackFactStore.java @@ -0,0 +1,24 @@ +package com.yunhai.flowcenter.callback; + +import com.yunhai.flowcenter.repository.FlwStateEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** Fact bookkeeping has its own commit; AFTER_COMMIT still has the completed business resources bound. */ +@Service +@RequiredArgsConstructor +public class CallbackFactStore { + + private final FlwStateEventRepository stateEventRepository; + + /** Called only after every matching delivery has been persisted (including the zero-subscriber case). */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markDispatched(String eventId) { + stateEventRepository.findByEventId(eventId).ifPresent(fact -> { + fact.setDispatched(1); + stateEventRepository.save(fact); + }); + } +} 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/callback/CallbackSourcePolicy.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java new file mode 100644 index 0000000..3011cc8 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java @@ -0,0 +1,57 @@ +package com.yunhai.flowcenter.callback; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.entity.FlwCallbackReg; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.service.FlowSourcePrincipalResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** Same SELF predicate for live fanout, fact recovery, retries and manual re-delivery. */ +@Component @RequiredArgsConstructor +public class CallbackSourcePolicy { + private final FlowSourcePrincipalResolver resolver; + private final FlwInstanceRepository instances; + private final ObjectMapper mapper; + public boolean matches(FlwCallbackReg reg, FlwInstance instance) { + if (instance.getSourcePrincipalId() == null) { + return true; // Legacy callback migration is explicit; formal facts always use UUID SELF. + } + if (!instance.getSourcePrincipalId().equals(reg.getSourcePrincipalId())) return false; + // Recheck the credential mapping: a reused appKey cannot receive its predecessor's facts. + return instance.getSourcePrincipalId().equals(resolver.resolve(reg.getAppKey()).id); + } + public boolean permits(FlwCallbackReg reg, com.yunhai.flowcenter.entity.FlwCallbackLog delivery) { + if (!permits(reg, delivery.getInstanceId(), delivery.getPayloadJson())) return false; + FlwInstance instance = instances.findById(delivery.getInstanceId()).orElseThrow(IllegalStateException::new); + if (instance.getSourcePrincipalId() == null) return true; + try { + JsonNode body = mapper.readTree(delivery.getPayloadJson()); + return java.util.Objects.equals(delivery.getEventId(), body.path("eventId").asText()) + && java.util.Objects.equals(delivery.getEvent(), body.path("event").asText()) + && java.util.Objects.equals(delivery.getBizType(), body.path("bizType").asText()) + && java.util.Objects.equals(delivery.getBizId(), body.path("bizId").asText()); + } catch (Exception e) { return false; } + } + public boolean permits(FlwCallbackReg reg, Long instanceId, String payload) { + FlwInstance instance = instances.findById(instanceId).orElse(null); + if (instance == null || !matches(reg, instance)) return false; + if (instance.getSourcePrincipalId() == null) return true; + try { + JsonNode body = mapper.readTree(payload); + JsonNode data = body.path("data"); + return instance.getSourcePrincipalId().equals(body.path("sourcePrincipalId").asText()) + && instance.getApplyRound().equals(body.path("applyRound").asInt()) + && instance.getSourcePrincipalId().equals(data.path("sourcePrincipalId").asText()) + && instance.getApplyRequestToken().equals(data.path("requestId").asText()) + && instance.getApplyPayloadHash().equals(data.path("canonicalHash").asText()) + && instance.getDefVersion().equals(data.path("defVersion").asInt()) + && instance.getApplyRound().equals(data.path("applyRound").asInt()) + && instance.getId().equals(body.path("instanceId").asLong()) + && instance.getSchId().equals(body.path("schId").asLong()) + && instance.getBizType().equals(body.path("bizType").asText()) + && instance.getBizId().equals(body.path("bizId").asText()); + } catch (Exception e) { return false; } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/callback/FlwStateEventRecorder.java b/src/main/java/com/yunhai/flowcenter/callback/FlwStateEventRecorder.java new file mode 100644 index 0000000..d5fc4fd --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/FlwStateEventRecorder.java @@ -0,0 +1,145 @@ +package com.yunhai.flowcenter.callback; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowEvent; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwStateEvent; +import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwStateEventRepository; +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.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * 状态事件事实记录器(2026-10-25 T05;同日 R2 返修冻结完整信封):普通同步监听器,与发布方 + * 同一事务执行——状态变更提交成功 ⇔ 不可变事件事实已落库,关闭"提交到 AFTER_COMMIT + * 分发之间进程被杀,事件永久丢失"的窗口(审计 E24)。 + * + *

职责边界:只落事实行并给 {@link FlowEvent} 赋稳定身份与冻结信封 + * (eventId 全订阅方/全部重试复用;eventSeq 实例内单调;R2 起信封含 eventId/eventSeq/flowGuid + * 全部投递字段——正常分发与崩溃恢复共用同一冻结体,Codex D04"恢复消息体缺 eventId"关闭); + * 回调分发、订阅匹配、推送仍归 {@link CallbackDispatcher}。URGE 站内信不入事件流(既有口径)。 + * 无事务的发布(fallbackExecution 场景)退化为自动提交直写——事实仍落,只是失去与状态的 + * 同事务原子性(记录 warn,不静默)。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FlwStateEventRecorder { + + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final FlwStateEventRepository eventRepository; + private final FlwInstanceRepository instanceRepository; + private final FlwDefRepository defRepository; + private final ObjectMapper objectMapper; + + @EventListener + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + public void record(FlowEvent event) { + if (event.getType() == FlowEvent.Type.URGE) { + return; + } + if (event.getStableEventId() != null) { + return; // 幂等:同一事件对象重复发布不重复落事实 + } + try { + FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null); + if (instance == null) { + log.warn("[state-event] 事实落库时实例不存在(跳过):instance={} event={}", + event.getInstanceId(), event.getType().getCode()); + return; + } + FlwDef def = instance.getDefId() == null ? null + : defRepository.findById(instance.getDefId()).orElse(null); + FlwStateEvent fact = new FlwStateEvent(); + fact.setEventId(UUID.randomUUID().toString().replace("-", "").toLowerCase()); + fact.setSchId(instance.getSchId()); + fact.setInstanceId(instance.getId()); + fact.setApplyRound(instance.getApplyRound()); + // T05 发号(终形态):FOR UPDATE 标量当前读(行 X 锁,任何隔离级别串行正确) + // + 锁内回写;不实体 merge(@Version 会 StaleObjectState,实证)。 + Integer nextSeq = instanceRepository.nextEventSeqForUpdate(instance.getId()); + if (nextSeq == null) { + throw new IllegalStateException("事件序号发号返回空:instance=" + instance.getId()); + } + instanceRepository.setEventSeqCounter(instance.getId(), nextSeq); + fact.setEventSeq(nextSeq); + fact.setEventType(event.getType().getCode()); + fact.setTaskId(event.getTaskId()); + fact.setOperatorId(event.getOperatorId()); + // R2:冻结完整投递信封(含 eventId/eventSeq/flowGuid)——事实载荷即投递体, + // 正常链与恢复链字节级同构,接收端必填字段(eventId/event/bizType/bizId)恒在。 + fact.setPayloadJson(objectMapper.writeValueAsString( + fullEnvelope(event, instance, def, fact.getEventId(), nextSeq))); + eventRepository.save(fact); + event.assignStableIdentity(fact.getEventId(), fact.getEventSeq()); + event.assignStablePayload(fact.getPayloadJson()); + } catch (Exception e) { + // 事实落库失败=状态事务随之一并失败(同事务)或无事务场景仅告警; + // 绝不反噬引擎主流程——分发链对账兜底仍在 + log.error("[state-event] 事件事实落库异常:instance={} event={} {}", + event.getInstanceId(), event.getType().getCode(), e.toString(), e); + if (org.springframework.transaction.support.TransactionSynchronizationManager + .isActualTransactionActive()) { + // 同事务场景:事实写失败必须让状态一起回滚(事件与状态原子),不吞 + throw new IllegalStateException("状态事件事实落库失败,事务回滚:" + event.getType().getCode(), e); + } + } + } + + /** + * 完整投递信封(R2 冻结体):{@code eventId/eventSeq/event/schId/flowGuid/instanceId/ + * instanceVersion/applyRound/bizType/bizId/bizTitle/occurredTime/taskId/operatorUserId/ + * operatorName/data}。def 缺席(定义已删)时不带 flowGuid——该场合分发器同样不投递。 + */ + private Map fullEnvelope(FlowEvent event, FlwInstance instance, + FlwDef def, String eventId, Integer eventSeq) { + Map payload = new HashMap<>(); + payload.put("eventId", eventId); + payload.put("eventSeq", eventSeq); + payload.put("event", event.getType().getCode()); + payload.put("schId", instance.getSchId()); + if (def != null) { + payload.put("flowGuid", def.getFlowGuid()); + } + payload.put("instanceId", instance.getId()); + payload.put("instanceVersion", instance.getVersion()); + payload.put("applyRound", instance.getApplyRound()); + payload.put("bizType", instance.getBizType()); + payload.put("bizId", instance.getBizId()); + payload.put("bizTitle", instance.getBizTitle()); + payload.put("occurredTime", LocalDateTime.now().format(TIME)); + 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()); + } + if (event.getRoundIdentity() != null) { + data.putAll(event.getRoundIdentity()); + } + if (instance.getSourcePrincipalId() != null) { + payload.put("sourcePrincipalId", instance.getSourcePrincipalId()); + data.put("sourcePrincipalId", instance.getSourcePrincipalId()); + data.put("requestId", instance.getApplyRequestToken()); + data.put("defVersion", instance.getDefVersion()); + data.put("canonicalHash", instance.getApplyPayloadHash()); + data.put("applyRound", instance.getApplyRound()); + } + payload.put("data", data); + return payload; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/callback/FormalCoordinateReleaseListener.java b/src/main/java/com/yunhai/flowcenter/callback/FormalCoordinateReleaseListener.java new file mode 100644 index 0000000..b4790d7 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/callback/FormalCoordinateReleaseListener.java @@ -0,0 +1,29 @@ +package com.yunhai.flowcenter.callback; +import com.yunhai.flowcenter.engine.FlowEvent; +import com.yunhai.flowcenter.repository.*; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** Release in the state transaction; late events can never free a newer round's pointer. */ +@Component @RequiredArgsConstructor +public class FormalCoordinateReleaseListener { + private final FlwInstanceRepository instances; + private final FlwBizCoordinateRepository coordinates; + @EventListener + public void release(FlowEvent event) { + if (event.getType() != FlowEvent.Type.COMPLETED && event.getType() != FlowEvent.Type.REJECTED + && event.getType() != FlowEvent.Type.WITHDRAWN) return; + instances.findById(event.getInstanceId()).ifPresent(instance -> { + if (instance.getSourcePrincipalId() != null) { + if (!org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException("Formal terminal transition requires the state transaction"); + } + coordinates.release(instance.getSchId(), instance.getSourcePrincipalId(), instance.getBizType(), + instance.getBizId(), instance.getId(), instance.getApplyRound()); + } + }); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/config/FlowSourceScopeConfiguration.java b/src/main/java/com/yunhai/flowcenter/config/FlowSourceScopeConfiguration.java new file mode 100644 index 0000000..c690aa4 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/config/FlowSourceScopeConfiguration.java @@ -0,0 +1,13 @@ +package com.yunhai.flowcenter.config; +import com.yunhai.flowcenter.service.FlowOapiSourceScope; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +@Configuration @RequiredArgsConstructor +public class FlowSourceScopeConfiguration implements WebMvcConfigurer { + private final FlowOapiSourceScope scope; + @Override public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(scope).addPathPatterns("/oapi/flow/**"); + } +} 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 8b9f2ff..cdbe98c 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -109,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); @@ -191,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/FormalApplyCommandController.java b/src/main/java/com/yunhai/flowcenter/controller/oapi/FormalApplyCommandController.java new file mode 100644 index 0000000..5952ca9 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/FormalApplyCommandController.java @@ -0,0 +1,21 @@ +package com.yunhai.flowcenter.controller.oapi; +import com.yunhai.flowcenter.model.FormalApplyModels; +import com.yunhai.flowcenter.service.FormalApplyCommandService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController @RequestMapping("/oapi/flow/commands") @RequiredArgsConstructor +public class FormalApplyCommandController { + private final FormalApplyCommandService commands; + @PostMapping + public FormalApplyModels.Receipt submit(@RequestParam("schId") Long school, + @RequestAttribute("yh.app.auth") String appKey, @RequestBody FormalApplyModels.Request request) { + return commands.submit(school, appKey, request); + } + @GetMapping("/{requestId}") + public FormalApplyModels.Receipt query(@RequestParam("schId") Long school, + @RequestAttribute("yh.app.auth") String appKey, @PathVariable String requestId, + @RequestParam(value="expectedHash", required=false) String expectedHash) { + return commands.query(school, appKey, requestId, expectedHash); + } +} 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 30eb842..84b7efc 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java @@ -71,6 +71,46 @@ public class FlowEvent { 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<>(); 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/engine/VariableContract.java b/src/main/java/com/yunhai/flowcenter/engine/VariableContract.java new file mode 100644 index 0000000..d12d817 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/VariableContract.java @@ -0,0 +1,209 @@ +package com.yunhai.flowcenter.engine; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** D10 定义与发起共用的纯校验器;不转换输入,不依赖 FC/数据库。 */ +public final class VariableContract { + private VariableContract() { } + private static final Set TYPES = new HashSet<>(Arrays.asList( + "string", "number", "boolean", "date", "datetime", "money", "stringArray", "fileArray", "userArray")); + private static final String TOKEN = "(?:'(?:[^'\\\\]|\\\\.)*'|\"(?:[^\"\\\\]|\\\\.)*\"|-?\\d+(?:\\.\\d+)?|[A-Za-z_][A-Za-z0-9_]*)"; + // 先分词以跳过完整字符串;不把函数参数字符串中的 "amount > 3" 当成比较。 + private static final Pattern TOKENS = Pattern.compile(TOKEN + "|==|!=|>=|<=|>|<|\\S"); + private static final Set COMPARISONS = new HashSet<>(Arrays.asList("==", "!=", ">=", "<=", ">", "<")); + + public static Set referencedKeys(FlowDefModel model) { + Set keys = new LinkedHashSet<>(); + 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; + try { + for (String key : ExpressionEvaluator.variableNames(e.condition.trim())) { + if (!DefValidator.EXPRESSION_BUILTINS.contains(key)) keys.add(key); + } + } catch (RuntimeException ex) { + // 历史版本保持原有语法异常跳过口径;新契约必须 fail closed。 + if (model.variables != null) fail("表达式不合法:" + ex.getMessage()); + } + } + } + return keys; + } + + public static void validateDefinition(FlowDefModel model, boolean publishing) { + if (model.variables == null) { + if (publishing) fail("新发布版本必须声明 variables(无变量请显式声明 [])"); + return; + } + Map fields = new LinkedHashMap<>(); + for (FlowDefModel.Variable v : model.variables) { + if (v == null || v.key == null || !v.key.matches("[A-Za-z0-9_]{1,64}")) fail("变量 key 不合法"); + if (DefValidator.EXPRESSION_BUILTINS.contains(v.key)) fail("变量不能占用内置键:" + v.key); + if (fields.put(v.key, v) != null) fail("变量 key 重复:" + v.key); + if (!TYPES.contains(v.type)) fail("变量类型不支持:" + v.key + " / " + v.type); + if (v.enumValues != null) { + if (!"string".equals(v.type) && !"stringArray".equals(v.type)) fail("enum 仅适用于字符串及字符串数组:" + v.key); + if (v.enumValues.isEmpty() || v.enumValues.contains(null) + || new HashSet<>(v.enumValues).size() != v.enumValues.size()) fail("enum 值域为空或重复:" + v.key); + for (String option : v.enumValues) { + if (blank(option)) fail("enum 值域不能含空白:" + v.key); + validateValue(v, "stringArray".equals(v.type) ? Collections.singletonList(option) : option, true); + } + } + } + for (String key : referencedKeys(model)) { + if (!fields.containsKey(key)) fail("表达式变量 " + key + " 未声明(不在关联表单字段集内或 variables 中)"); + } + for (FlowDefModel.Node n : model.nodes) { + if (n == null || !n.expressionJudge()) continue; + for (FlowDefModel.Edge e : model.outEdges(n.id)) { + if (e.condition == null) continue; + Matcher m = TOKENS.matcher(e.condition); + List tokens = new ArrayList<>(); + while (m.find()) tokens.add(m.group()); + for (int i = 1; i + 1 < tokens.size(); i++) { + if (!COMPARISONS.contains(tokens.get(i))) continue; + checkLiteral(fields.get(tokens.get(i-1)), tokens.get(i+1)); + checkLiteral(fields.get(tokens.get(i+1)), tokens.get(i-1)); + } + } + } + } + + private static void checkLiteral(FlowDefModel.Variable field, String token) { + if (field == null || "null".equals(token)) return; + Object value; + if (token.startsWith("'") || token.startsWith("\"")) { + // Aviator 的转义交由编译器;带转义字面量不做正则域推断。 + if (token.indexOf('\\') >= 0) return; + value = token.substring(1, token.length() - 1); + } else if ("true".equals(token) || "false".equals(token)) value = Boolean.valueOf(token); + else if (token.matches("-?\\d+(\\.\\d+)?")) value = new BigDecimal(token); + else return; + try { validateValue(field, value, true); } + catch (BizException invalid) { fail("类型桶查②/域查③:" + invalid.getMessage()); } + } + + public static void validateInput(FlowDefModel model, Map submitted) { + Set refs = referencedKeys(model); + if (model.variables == null) { + for (String key : refs) { + if (!submitted.containsKey(key) || submitted.get(key) == null) fail("流程条件所需变量缺失或为空(key=" + key + ")"); + } + return; + } + validateDefinition(model, false); + Set declared = new HashSet<>(); + for (FlowDefModel.Variable v : model.variables) { + declared.add(v.key); + validateValue(v, submitted.get(v.key), Boolean.TRUE.equals(v.required) || refs.contains(v.key)); + } + for (String key : submitted.keySet()) if (!declared.contains(key)) fail("未声明的变量:" + key); + } + + /** 必填/表达式引用均需实值;可选 null 可省略,其他非 null 值始终按类型与值域检查。 */ + private static void validateValue(FlowDefModel.Variable v, Object value, boolean required) { + if (value == null) { + if (required) fail("变量缺失或为空:" + v.key); + return; + } + if (required && ((value instanceof String && blank((String) value)) + || (value instanceof Collection && ((Collection) value).isEmpty()))) fail("变量未填写:" + v.key); + // FC 文本/单选和日期控件的清空值为 "";仅非必填且未被表达式引用时视作未填。 + // 数值控件空值是 null,不能借空字符串绕过 number/money/boolean/数组的类型检查。 + if (!required && "".equals(value) && Arrays.asList("string", "date", "datetime").contains(v.type)) return; + boolean valid = false; + switch (v.type) { + case "string": valid = value instanceof String; break; + case "number": case "money": + if (value instanceof Number) { + try { new BigDecimal(value.toString()); valid = true; } catch (NumberFormatException ignored) { } + } + break; + case "boolean": valid = value instanceof Boolean; break; + case "date": + if (value instanceof String && ((String)value).matches("\\d{4}-\\d{2}-\\d{2}")) { + try { LocalDate.parse((String)value); valid = true; } catch (RuntimeException ignored) { } + } + break; + case "datetime": + if (value instanceof String && ((String)value).matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})?")) { + try { LocalDateTime.parse((String)value); valid = true; } + catch (RuntimeException local) { + try { OffsetDateTime.parse((String)value); valid = true; } catch (RuntimeException ignored) { } + } + } + break; + case "stringArray": case "fileArray": case "userArray": + valid = value instanceof List; + if (valid) for (Object item : (List)value) { + if ("stringArray".equals(v.type)) { + if (!(item instanceof String) || (v.enumValues != null && !v.enumValues.contains(item))) valid = false; + } else if (!validRecord(item, "fileArray".equals(v.type))) valid = false; + } + break; + default: break; + } + if (!valid) fail("变量类型/格式不合法:" + v.key + "(应为 " + v.type + ")"); + if (value instanceof String && v.enumValues != null && !v.enumValues.contains(value)) fail("变量 " + v.key + " 不在枚举值域 " + v.enumValues + ":" + value); + } + + private static boolean validRecord(Object value, boolean file) { + if (!(value instanceof Map)) return false; + Map row = (Map)value; + Object id = row.get("id"); + try { + if (!(id instanceof Number) || new BigDecimal(id.toString()).scale() > 0 || ((Number)id).longValue() <= 0) return false; + } catch (NumberFormatException invalid) { return false; } + return file ? nonblank(row.get("fileName")) && nonblank(row.get("key")) : nonblank(row.get("name")); + } + private static boolean nonblank(Object value) { return value instanceof String && !blank((String)value); } + private static boolean blank(String value) { + return value.codePoints().allMatch(c -> Character.isWhitespace(c) || Character.isSpaceChar(c)); + } + + /** FC 快照是唯一派生来源,保存时传入的 variables 不能覆盖绑定表单的契约。 */ + public static List fromSnapshot(Map snapshot) { + Object fields = snapshot.get("fields"); + if (!(fields instanceof List)) fail("formContract.fields 必须为数组"); + List variables = new ArrayList<>(); + for (Object item : (List)fields) { + if (!(item instanceof Map)) fail("formContract 字段不合法"); + Map field = (Map)item; + FlowDefModel.Variable v = new FlowDefModel.Variable(); + v.key = (String)field.get("key"); + v.required = Boolean.TRUE.equals(field.get("required")); + String type = String.valueOf(field.get("type")); + switch (type) { + case "text": case "textarea": case "phone": case "select": case "radio": v.type = "string"; break; + case "number": case "date": v.type = type; break; + case "checkbox": case "multiselect": case "address": v.type = "stringArray"; break; + case "image": case "upload": v.type = "fileArray"; break; + case "user": case "users": v.type = "userArray"; break; + default: fail("FC 字段类型不支持:" + v.key + " / " + type); + } + if (Arrays.asList("select", "radio", "checkbox", "multiselect").contains(type)) { + Object enums = field.get("enum"); + if (!(enums instanceof List) || ((List)enums).isEmpty()) fail("FC 枚举值域缺失:" + v.key); + v.enumValues = new ArrayList<>(); + for (Object option : (List)enums) { + if (!(option instanceof String)) fail("FC 枚举必须为字符串:" + v.key); + v.enumValues.add((String)option); + } + } + variables.add(v); + } + return variables; + } + private static void fail(String message) { throw new BizException(ErrorCode.PARAM_ERROR, message); } +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwAdvanceIntent.java b/src/main/java/com/yunhai/flowcenter/entity/FlwAdvanceIntent.java new file mode 100644 index 0000000..11bd28d --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwAdvanceIntent.java @@ -0,0 +1,107 @@ +package com.yunhai.flowcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.Version; +import javax.persistence.UniqueConstraint; + +import java.time.LocalDateTime; + +/** + * 待推进意图(2026-10-25 T05):任务办理事务内持久化"这个节点该继续推进了"的事实, + * 事务外 worker 领取执行——办理事务提交成功 ⇔ 推进意图已持久化,进程崩溃/重启后 + * 扫描续跑(旧链路 AFTER_COMMIT 三次内存重试,耗尽只 log.error,重启即丢)。 + * + *

D09:每个激活批次记录精确任务成员,未办理时以 DONE 等待;办理同事务串行刷新 + * 本批次的末笔任务及 PENDING 工作。worker 按 id+revision 锁定当前读,推进与结算同事务; + * NULL 代次只允许明确接棒或留待有证据的人工回填,不作为本代放行。

+ */ +@Getter +@Setter +@Entity +@Table(name = "flw_advance_intent", + uniqueConstraints = @UniqueConstraint(name = "uk_flw_advance_activation", columnNames = "activation_task_id"), + indexes = { + @Index(name = "idx_flw_advance_intent_due", columnList = "status, next_retry_at"), + @Index(name = "idx_flw_advance_intent_instance", columnList = "instance_id")}) +public class FlwAdvanceIntent extends BaseEntity { + + public static final String STATE_PENDING = "PENDING"; + public static final String STATE_DONE = "DONE"; + public static final String STATE_EXHAUSTED = "EXHAUSTED"; + + @Column(name = "instance_id", nullable = false) + private Long instanceId; + + /** 触发推进的节点(TASK_HANDLED 的 nodeId) */ + @Column(name = "node_id", nullable = false, length = 64) + private String nodeId; + + /** First task CREATED in this activation batch; never the last task to finish. NULL = unanchored legacy work. */ + @Column(name = "activation_task_id") + private Long activationTaskId; + + /** Exact membership, including transfers; comma-delimited positive task IDs, e.g. ,12,14,. */ + @Column(name = "activation_task_ids", columnDefinition = "LONGTEXT") + private String activationTaskIds; + + @Column(name = "handled_task_ids", columnDefinition = "LONGTEXT") + private String handledTaskIds; + + @Column(name = "trigger_task_id") + private Long triggerTaskId; + + @Column(name = "activation_def_id") + private Long activationDefId; + + @Column(name = "activation_def_version") + private Integer activationDefVersion; + + @Column(name = "activation_apply_round") + private Integer activationApplyRound; + + /** A detached worker may act/defer only the revision it actually observed. */ + @Version + @Column(name = "revision", nullable = false) + private Long revision = 0L; + + public boolean containsTask(Long id) { return contains(activationTaskIds, id); } + public boolean handledTask(Long id) { return contains(handledTaskIds, id); } + public void addTask(Long id) { if (!containsTask(id)) activationTaskIds = append(activationTaskIds, id); } + public void addHandledTask(Long id) { if (!handledTask(id)) handledTaskIds = append(handledTaskIds, id); } + private static boolean contains(String ids, Long id) { return ids != null && id != null && ids.contains("," + id + ","); } + private static String append(String ids, Long id) { + if (id == null || id <= 0) throw new IllegalArgumentException("Missing activation task identity"); + return (ids == null ? "," : ids) + id + ","; + } + + /** 出边名(可空=引擎默认路由) */ + @Column(name = "edge_name", length = 128) + private String edgeName; + + /** 办理人随行(推进链操作人信封,可空) */ + @Column(name = "assignee_id") + private Integer assigneeId; + + @Column(name = "assignee_name", length = 64) + private String assigneeName; + + /** PENDING=待执行;DONE=已执行(含推进判定为无需推进的幂等结论);EXHAUSTED=重试耗尽留人工 */ + @Column(name = "status", nullable = false, length = 16) + private String status = STATE_PENDING; + + @Column(name = "attempts", nullable = false) + private Integer attempts = 0; + + /** 退避到期时间(耗尽前按指数退避续扫) */ + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + + @Column(name = "last_error", length = 255) + private String lastError; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwApplyCommand.java b/src/main/java/com/yunhai/flowcenter/entity/FlwApplyCommand.java new file mode 100644 index 0000000..124023f --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwApplyCommand.java @@ -0,0 +1,24 @@ +package com.yunhai.flowcenter.entity; +import lombok.Getter; +import lombok.Setter; +import javax.persistence.*; +/** Immutable settled command fact: ACCEPTED or REJECTED. Never repurpose a request ID. */ +@Entity @Getter @Setter +@Table(name="flw_apply_command", uniqueConstraints=@UniqueConstraint(name="uk_flw_command_request",columnNames={"sch_id","source_principal_id","request_id"}),indexes=@Index(name="idx_flw_command_instance",columnList="instance_id")) +public class FlwApplyCommand extends BaseEntity { + @Column(name="sch_id",nullable=false) private Long schId; + @Column(name="source_principal_id",nullable=false,length=36) private String sourcePrincipalId; + @Column(name="request_id",nullable=false,length=64) private String requestId; + @Column(name="canonical_hash",nullable=false,length=64) private String canonicalHash; + @Column(name="canonical_version",nullable=false) private Integer canonicalVersion; + @Column(name="biz_type",nullable=false,length=32) private String bizType; + @Column(name="biz_id",nullable=false,length=64) private String bizId; + @Column(name="flow_guid",nullable=false,length=32) private String flowGuid; + @Column(name="state",nullable=false,length=16) private String state; + @Column(name="instance_id") private Long instanceId; + @Column(name="apply_round") private Integer round; + @Column(name="def_version") private Integer defVersion; + @Column(name="def_id") private Long defId; + @Column(name="reject_code",length=64) private String rejectCode; + @Column(name="reject_detail",length=500) private String rejectDetail; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwBizCoordinate.java b/src/main/java/com/yunhai/flowcenter/entity/FlwBizCoordinate.java new file mode 100644 index 0000000..f1d8027 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwBizCoordinate.java @@ -0,0 +1,15 @@ +package com.yunhai.flowcenter.entity; +import lombok.Getter; +import lombok.Setter; +import javax.persistence.*; +@Entity @Getter @Setter +@Table(name="flw_biz_coordinate", uniqueConstraints=@UniqueConstraint(name="uk_flw_coord", columnNames={"sch_id","source_principal_id","biz_type","biz_id"})) +public class FlwBizCoordinate extends BaseEntity { + @Column(name="sch_id",nullable=false) private Long schId; + @Column(name="source_principal_id",nullable=false,length=36) private String sourcePrincipalId; + @Column(name="biz_type",nullable=false,length=32) private String bizType; + @Column(name="biz_id",nullable=false,length=64) private String bizId; + @Column(name="last_round",nullable=false) private Integer lastRound = 0; + @Column(name="last_instance_id") private Long lastInstanceId; + @Column(name="active_instance_id") private Long activeInstanceId; +} 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/FlwCommandSource.java b/src/main/java/com/yunhai/flowcenter/entity/FlwCommandSource.java new file mode 100644 index 0000000..e0b8e35 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwCommandSource.java @@ -0,0 +1,10 @@ +package com.yunhai.flowcenter.entity; +import lombok.Getter; +import lombok.Setter; +import javax.persistence.*; +/** Persistent admission mutex. Rows survive application deletion; never recycle. */ +@Entity @Table(name="flw_command_source") @Getter @Setter +public class FlwCommandSource { + @Id @Column(length=36) private String id; + @Column(nullable=false) private Integer appId; +} 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/entity/FlwStateEvent.java b/src/main/java/com/yunhai/flowcenter/entity/FlwStateEvent.java new file mode 100644 index 0000000..c85c3cc --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwStateEvent.java @@ -0,0 +1,77 @@ +package com.yunhai.flowcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 流程状态事件事实(2026-10-25 T05):状态/审批动作与不可变事件事实同一数据库事务落库。 + * + *

关闭审计 E24 的丢失窗口:旧链路 AFTER_COMMIT 才按订阅生成 eventId、另事务落回调行—— + * 提交到分发之间进程被杀,事件永久丢失(订阅方靠对账兜底)。本事实行在发布线程的同一事务内 + * 写入:状态提交成功 ⇔ 事件事实已持久化;回调分发只是它的消费,崩溃后由 sweep 扫描补分发。

+ * + *

身份:{@code eventId} 在事实创建时一次性生成,全部订阅方/全部投递重试复用同一 eventId + * (消费端 (event_id, consumer_app_key) 唯一键去重);{@code eventSeq} 为实例内尽力单调序号 + * (FOR UPDATE 发号器;REQUIRES_NEW 子事务推进拓扑下极端并发窗口可能同值——有序性以 + * eventSeq+发生时间为准,同实例严格全序需专用发号器,列入后续;不承诺全局副作用严格有序)。

+ */ +@Getter +@Setter +@Entity +@Table(name = "flw_state_event", + uniqueConstraints = @UniqueConstraint(name = "uk_flw_state_event_id", columnNames = {"event_id"}), + indexes = { + @Index(name = "idx_flw_state_event_instance", columnList = "instance_id"), + @Index(name = "idx_flw_state_event_dispatch", columnList = "dispatched, create_time"), + @Index(name = "idx_flw_state_event_seq", columnList = "instance_id, event_seq")}) +public class FlwStateEvent extends BaseEntity { + + /** 稳定事件身份(32 位 uuid 去横线;投递与重试全链复用) */ + @Column(name = "event_id", nullable = false, length = 32) + private String eventId; + + /** 租户键 */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 实例身份 */ + @Column(name = "instance_id", nullable = false) + private Long instanceId; + + /** 发布时冻结的申报轮次(旧事件不得被 AFTER_COMMIT 重读贴上新轮标签) */ + @Column(name = "apply_round") + private Integer applyRound; + + /** 实例内单调序号(1 起;同实例状态行锁内取 max+1) */ + @Column(name = "event_seq", nullable = false) + private Integer eventSeq; + + /** 事件类型码(FlowEvent.Type#getCode,与回调订阅枚举同值域) */ + @Column(name = "event_type", nullable = false, length = 32) + private String eventType; + + /** 关联任务(办件级事件为 null) */ + @Column(name = "task_id") + private Long taskId; + + /** 操作人(可空) */ + @Column(name = "operator_id") + private Integer operatorId; + + /** 完整载荷快照(与回调信封同构 JSON;发生时状态,不随后续变化) */ + @Column(name = "payload_json", nullable = false, columnDefinition = "LONGTEXT") + private String payloadJson; + + /** + * 分发标记:0=待分发(含崩溃窗口恢复);1=已按当前订阅生成了全部回调行(或无可订阅方)。 + * 分发幂等:重扫前按 (eventId, regId) 查重,不重复建投递行。 + */ + @Column(name = "dispatched", nullable = false) + private Integer dispatched = 0; +} diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwUsageGrant.java b/src/main/java/com/yunhai/flowcenter/entity/FlwUsageGrant.java new file mode 100644 index 0000000..d0a913c --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwUsageGrant.java @@ -0,0 +1,63 @@ +package com.yunhai.flowcenter.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 应用使用授权(2026-10-25 T01):哪个稳定应用主体可以在哪所学校使用哪条流程线。 + * + *

与 {@link FlwApp}(定义保存白名单)和流程定义管理权(FlowAdminGuard 管理角色)三权分立: + * 有 flw_app 行不等于能新发起;是应用管理员也不等于能编辑/发布学校流程定义。 + * 许可键是三元 {@code (schId, flowGuid, appId)}——appId 是 UserBase application_info 主键 + * (稳定主体,appKey 可重置/注销后不复用历史来源),appKey 快照仅供展示回溯。

+ * + *

撤权语义:{@code enabled=0} 只拦截新发起;已受理命令的回执恢复、在途实例推进 + * 不回读本表(P0-3 收口顺序:认证 → 自有命令回执 → 未命中才查新发起资格)。

+ */ +@Getter +@Setter +@Entity +@Table(name = "flw_usage_grant", + uniqueConstraints = @UniqueConstraint(name = "uk_flw_usage_grant", + columnNames = {"sch_id", "flow_guid", "app_id"}), + indexes = {@Index(name = "idx_flw_usage_grant_sch_flow", columnList = "sch_id, flow_guid"), + @Index(name = "idx_flw_usage_grant_app", columnList = "app_id")}) +public class FlwUsageGrant extends BaseEntity { + @Column(name="source_principal_id",length=36) + private String sourcePrincipalId; + + + /** 租户键 */ + @Column(name = "sch_id", nullable = false) + private Long schId; + + /** 流程线稳定身份(跨版本不变;版本归属由 flw_def 承载) */ + @Column(name = "flow_guid", nullable = false, length = 64) + private String flowGuid; + + /** 稳定应用主体主键(UserBase application_info.id;非 appKey 凭据索引) */ + @Column(name = "app_id", nullable = false) + private Integer appId; + + /** 授权时的 appKey 快照(展示/审计回溯用;判权不读它) */ + @Column(name = "app_key_snapshot", length = 64) + private String appKeySnapshot; + + /** 应用名快照(授权时 UserBase 返回值;同步不自动刷新) */ + @Column(name = "app_name_snapshot", length = 128) + private String appNameSnapshot; + + /** 1=许可生效;0=撤销(只拦新发起) */ + @Column(name = "enabled", nullable = false) + private Integer enabled = 1; + + /** 备注(谁/为何授予或撤销,人工审计线索) */ + @Column(name = "remark", length = 255) + private String remark; +} diff --git a/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java b/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java index c8252cd..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} 之上把"中心判出来的是哪一种命中"说清楚 */ @@ -74,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/FormalApplyModels.java b/src/main/java/com/yunhai/flowcenter/model/FormalApplyModels.java new file mode 100644 index 0000000..74f9a9a --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/model/FormalApplyModels.java @@ -0,0 +1,34 @@ +package com.yunhai.flowcenter.model; + +import lombok.Getter; +import lombok.Setter; + +/** Service-owned v3 contract. No shared SDK DTO changes. */ +public final class FormalApplyModels { + private FormalApplyModels() { } + @Getter @Setter + public static class Request extends OapiModels.ApplyBody { + private String requestId; + private Long expectedPreviousInstanceId; + private Integer expectedPreviousRound; + } + @Getter @Setter + public static class Receipt { + private String state; + private String requestId; + private Long schoolId; + private String sourcePrincipalId; + private String canonicalHash; + private Integer canonicalVersion = 1; + private String bizType; + private String bizId; + private String flowGuid; + private Long instanceId; + private Integer round; + private Integer defVersion; + private Integer status; + private Integer version; + private String rejectCode; + private String rejectDetail; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java index a48a4b9..611203f 100644 --- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -109,6 +109,11 @@ public final class OapiModels { 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/FlwAdvanceIntentRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwAdvanceIntentRepository.java new file mode 100644 index 0000000..6065599 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwAdvanceIntentRepository.java @@ -0,0 +1,35 @@ +package com.yunhai.flowcenter.repository; + +import com.yunhai.flowcenter.entity.FlwAdvanceIntent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.repository.Lock; +import javax.persistence.LockModeType; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface FlwAdvanceIntentRepository extends JpaRepository { + + /** 到点待执行:PENDING 且(无退避或已到期) */ + @Query(value="select * from flw_advance_intent where status=:status " + + "and (next_retry_at is null or next_retry_at<=:now) order by create_time,id limit 50", nativeQuery=true) + List findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc( + @Param("status") String status, @Param("now") LocalDateTime now); + + List findByInstanceIdAndNodeIdOrderByIdDesc(Long instanceId, String nodeId); + + Optional findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc( + Long instanceId, String nodeId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select i from FlwAdvanceIntent i where i.id=:id") + Optional findByIdForUpdate(@Param("id") Long id); + + /** 幂等记账:同实例同节点是否已有未完成意图(办理事务内去重,避免并发办理堆积重复行) */ + @Query("select count(i) > 0 from FlwAdvanceIntent i where i.instanceId = :instanceId " + + "and i.nodeId = :nodeId and i.status = 'PENDING'") + boolean existsPending(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwApplyCommandRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwApplyCommandRepository.java new file mode 100644 index 0000000..0c4a46d --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwApplyCommandRepository.java @@ -0,0 +1,8 @@ +package com.yunhai.flowcenter.repository; +import com.yunhai.flowcenter.entity.FlwApplyCommand; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +public interface FlwApplyCommandRepository extends JpaRepository { + boolean existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(Long school, String source, String flow, String state); + Optional findBySchIdAndSourcePrincipalIdAndRequestId(Long schId,String sourcePrincipalId,String requestId); +} diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwBizCoordinateRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwBizCoordinateRepository.java new file mode 100644 index 0000000..c8feb9a --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwBizCoordinateRepository.java @@ -0,0 +1,14 @@ +package com.yunhai.flowcenter.repository; +import com.yunhai.flowcenter.entity.FlwBizCoordinate; +import org.springframework.data.jpa.repository.*; +import org.springframework.data.repository.query.Param; +import javax.persistence.LockModeType; +import java.util.Optional; +public interface FlwBizCoordinateRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from FlwBizCoordinate c where c.schId=:sch and c.sourcePrincipalId=:source and c.bizType=:type and c.bizId=:biz") + Optional lock(@Param("sch") Long sch,@Param("source") String source,@Param("type") String type,@Param("biz") String biz); + @Modifying + @Query("update FlwBizCoordinate c set c.activeInstanceId=null where c.schId=:sch and c.sourcePrincipalId=:source and c.bizType=:type and c.bizId=:biz and c.activeInstanceId=:instance and c.lastRound=:round") + int release(@Param("sch") Long sch,@Param("source") String source,@Param("type") String type,@Param("biz") String biz,@Param("instance") Long instance,@Param("round") Integer round); +} 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/FlwCommandSourceRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwCommandSourceRepository.java new file mode 100644 index 0000000..716f64b --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwCommandSourceRepository.java @@ -0,0 +1,10 @@ +package com.yunhai.flowcenter.repository; +import com.yunhai.flowcenter.entity.FlwCommandSource; +import org.springframework.data.jpa.repository.*; +import org.springframework.data.repository.query.Param; +import javax.persistence.LockModeType; +import java.util.Optional; +public interface FlwCommandSourceRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select s from FlwCommandSource s where s.id=:id") + Optional lock(@Param("id") String id); +} 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/FlwStateEventRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwStateEventRepository.java new file mode 100644 index 0000000..3a77769 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwStateEventRepository.java @@ -0,0 +1,24 @@ +package com.yunhai.flowcenter.repository; + +import com.yunhai.flowcenter.entity.FlwStateEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface FlwStateEventRepository extends JpaRepository { + + /** 实例内单调序号取号:调用方持有该实例的状态行锁(同事务状态变更),max+1 串行安全 */ + @Query("select coalesce(max(e.eventSeq), 0) from FlwStateEvent e where e.instanceId = :instanceId") + int maxSeqByInstance(@Param("instanceId") Long instanceId); + + /** 崩溃窗口恢复扫描:未分发且已过缓冲期(给 AFTER_COMMIT 首分发留时间) */ + List findTop50ByDispatchedAndCreateTimeLessThanEqualOrderByCreateTimeAsc( + Integer dispatched, LocalDateTime deadline); + + java.util.Optional findByEventId(String eventId); + + boolean existsByEventId(String eventId); +} 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/repository/FlwUsageGrantRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java new file mode 100644 index 0000000..ee3cd37 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java @@ -0,0 +1,18 @@ +package com.yunhai.flowcenter.repository; + +import com.yunhai.flowcenter.entity.FlwUsageGrant; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface FlwUsageGrantRepository extends JpaRepository { + boolean existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(Long school, String flow, String source, Integer enabled); + + + Optional findBySchIdAndFlowGuidAndAppId(Long schId, String flowGuid, Integer appId); + + List findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(Long schId, String flowGuid); + + List findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(Long schId, Integer appId); +} diff --git a/src/main/java/com/yunhai/flowcenter/service/ApplyVariableValidator.java b/src/main/java/com/yunhai/flowcenter/service/ApplyVariableValidator.java new file mode 100644 index 0000000..68ac889 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/ApplyVariableValidator.java @@ -0,0 +1,49 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowDefModel; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; + +/** 发起前按冻结的定义契约校验;历史无声明版本保留原有的引用变量存在性规则。 */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ApplyVariableValidator { + + private final ObjectMapper objectMapper; + + /** def 的全部 EXPRESSION 出边引用的业务变量键(去重保序) */ + public Set referencedBusinessKeys(FlowDefModel model) { + return com.yunhai.flowcenter.engine.VariableContract.referencedKeys(model); + } + + public void requireVariablesPresent(FlowDefModel model, String formDataJson) { + com.yunhai.flowcenter.engine.VariableContract.validateInput(model, parse(formDataJson, model.variables != null)); + } + + /** 与 ConditionRouteService.parseVariables 同源:formData 顶层即变量;非对象 JSON=空(后续逐键报缺失) */ + private Map parse(String formDataJson, boolean strict) { + if (formDataJson == null || formDataJson.trim().isEmpty()) { + return new java.util.HashMap<>(); + } + try { + Map values = objectMapper.readValue(formDataJson, new TypeReference>() { }); + if (values == null) { + if (strict) throw new IllegalArgumentException("null object"); + return new java.util.HashMap<>(); + } + return values; + } catch (Exception e) { + if (strict) throw new BizException(ErrorCode.PARAM_ERROR, "formData 必须为 JSON 对象"); + return new java.util.HashMap<>(); + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowActorContextGate.java b/src/main/java/com/yunhai/flowcenter/service/FlowActorContextGate.java new file mode 100644 index 0000000..7903e32 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowActorContextGate.java @@ -0,0 +1,36 @@ +package com.yunhai.flowcenter.service; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * SDK 快捷审批 ActorContext 门禁(2026-10-25 T07 建立;同日 R1 返修改为无条件 fail-closed)。 + * + *

oapi 通道的 approve/reject 只有自报 operatorId/operatorName——appKey 验签证明的是 + * 应用,不是用户;"task.assigneeId == operatorId" 的本人比较 likewise 只能证明调用方知道 + * 办理人 ID,不能证明实际办理的用户。在"可验证用户证明"(面向 FL 的用户会话票据/受限委托: + * 绑定用户、学校、调用应用、任务/动作范围、期限与防重放标识,见 Codex 终审 D03/C.2)落地前, + * 快捷审批一律不放行,不存在放行配置。

+ * + *

R1 返修(Codex 终审第一轮):原 {@code flow-center.sdk-approve.require-user-proof} + * 开关的 false=存量放行语义已废除——未配置与 false 均不得默认放行不可信审批,此前依赖该 + * 通道的存量消费方(work-leave、svc-hall 应用内审批快捷动作等)须迁移到标准 FL iframe/CAS + * 人工办理({@code FlowUserController} → {@code FlowTaskService.handle},会话用户身份, + * 不经本门)。未来 ActorContext 证明落地后,在此校验证明(用户/校/应用/任务/期限/nonce) + * 并按证明放行;届时再恢复配置化灰度。

+ */ +@Slf4j +@Component +public class FlowActorContextGate { + + /** oapi 审批动作前置门(approve/reject 共用):无条件拒绝 */ + public void requireActorProof(Long schId, Long taskId, Integer operatorId) { + log.warn("[actor-gate] SDK 快捷审批被拒(无可验证用户证明,fail-closed):schId={} taskId={} operatorId={}", + schId, taskId, operatorId); + throw new BizException(ErrorCode.FORBIDDEN, + "SDK 快捷审批未放行:仅凭 appKey/operatorId 不构成用户授权。" + + "请通过流程中心人工办理页(CAS)办理"); + } +} 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 f5826d7..da109e3 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -3,6 +3,7 @@ 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; @@ -218,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); @@ -229,79 +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 = checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "发布"); - 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)); + 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; @@ -310,109 +265,46 @@ 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_]*)"); - - /** - * 类型桶查②红牌 + 域查③红牌(启发式实现:正则模式匹配字面量比较形态,非 AST—— - * v2 §4.7 保守边界:不执行表达式、不做隐式转换,只拦"数值比较 × 非数值字段"与 - * "枚举等值 × 字面量不在值域"两类高发错配;不命中的形态留给运行期既有挂起纪律)。 - */ - @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 + "' 不在其中(域查③)"); - } - } - } - } - } - } - - 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 + " 数字(类型桶查②)"); - } - } - /** 停用:不可新发起,存量跑完 */ @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); } + /** + * 线级停发(2026-10-25 T03):整条 flowGuid 停止新发起/预览,在途实例按各自固定 defId 原版跑完。 + * 与版本级 {@link #offline} 正交(可叠加);批量 UPDATE 全版本行,读任一行即读线状态。 + * 不改写任何发布内容字节,恢复走 {@link #enableLine}。 + */ + @Transactional + public int offlineLine(Long schId, String flowGuid) { + requireLine(schId, flowGuid); + return defRepository.updateLineOffline(schId, flowGuid.trim(), 1); + } + + /** + * 线级恢复(T03):只恢复线的可用性——line_offline 归零;各版本 status/发布内容/版本指针 + * 一律不动(停发期间发布新版不受影响,恢复后新发起即用当时最高已发布版)。 + */ + @Transactional + public int enableLine(Long schId, String flowGuid) { + requireLine(schId, flowGuid); + return defRepository.updateLineOffline(schId, flowGuid.trim(), 0); + } + + 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); + } + } + /** * 启用(2026-09-18):只对"停用"版恢复为已发布,与 offline 对称。 * 复用发布期的静态校验(把"任务无主"重新拦一次)——若建的时候某科室还有启用办理人、之后 @@ -520,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/FlowOapiSourceScope.java b/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java new file mode 100644 index 0000000..f70ed42 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java @@ -0,0 +1,65 @@ +package com.yunhai.flowcenter.service; + +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwTaskRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** Authenticated request scope; no user-supplied source/header is trusted. */ +@Component @RequiredArgsConstructor +public class FlowOapiSourceScope implements HandlerInterceptor { + private static final String ATTRIBUTE = FlowOapiSourceScope.class.getName(); + private final FlowSourcePrincipalResolver resolver; + private final FlwInstanceRepository instances; + private final FlwTaskRepository tasks; + @Value("${flow-center.formal-command.enabled:false}") private boolean enabled; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!enabled) return true; + String source = resolver.resolve((String) request.getAttribute("yh.app.auth")).id; + request.setAttribute(ATTRIBUTE, source); + String id = request.getParameter("instanceId"); + java.util.regex.Matcher pathId = java.util.regex.Pattern.compile("/instance/(\\d+)(?:/|$)").matcher(request.getRequestURI()); + if (id == null && pathId.find()) id = pathId.group(1); + String taskId = request.getParameter("taskId"); + java.util.regex.Matcher taskPath = java.util.regex.Pattern.compile("/task/(\\d+)(?:/|$)").matcher(request.getRequestURI()); + if (taskId == null && taskPath.find()) taskId = taskPath.group(1); + if (id != null) requireOwn(parse(id), source); + if (taskId != null) { + Long task = parse(taskId); + requireOwn(tasks.findById(task).orElseThrow(() -> denied()).getInstanceId(), source); + } + return true; + } + + private void requireOwn(Long id, String source) { + FlwInstance instance = instances.findById(id).orElseThrow(() -> denied()); + if (!source.equals(instance.getSourcePrincipalId())) throw denied(); + } + + public static String current() { + String source = currentOr(null); + if (source == null) throw denied(); + return source; + } + public static String currentOr(String fallback) { + if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes)) return fallback; + Object value = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest().getAttribute(ATTRIBUTE); + return value instanceof String ? (String) value : fallback; + } + private Long parse(String value) { + try { return Long.valueOf(value); } catch (NumberFormatException e) { throw denied(); } + } + private static BizException denied() { return new BizException(ErrorCode.FORBIDDEN, "仅允许访问本应用来源的流程事实"); } +} 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/FlowSourcePrincipalResolver.java b/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java new file mode 100644 index 0000000..56da8dc --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java @@ -0,0 +1,42 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiAppClient; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class FlowSourcePrincipalResolver { + private final OpenApiAppClient appClient; + + public Principal resolve(String authenticatedAppKey) { + if (authenticatedAppKey == null || authenticatedAppKey.trim().isEmpty()) { + throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已认证的调用应用"); + } + JsonNode app = appClient.getAppByKey(authenticatedAppKey); + if (app == null || !app.path("id").isIntegralNumber()) { + throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体不可解析"); + } + String source = app.path("sourcePrincipalId").asText(""); + try { + if (!UUID.fromString(source).toString().equals(source)) throw new IllegalArgumentException(); + } catch (IllegalArgumentException e) { + throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 稳定来源尚未迁移,禁止回退到数字 ID/appKey"); + } + return new Principal(source, app.path("id").intValue(), authenticatedAppKey, + app.path("appCode").isTextual() ? app.path("appCode").textValue() : null); + } + + @lombok.RequiredArgsConstructor + public static class Principal { + public final String id; + public final Integer appId; + public final String appKey; + public final String appCode; + } +} 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/FlowUsageGrantAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java new file mode 100644 index 0000000..3e0e9ea --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java @@ -0,0 +1,133 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.flowcenter.entity.FlwUsageGrant; +import com.yunhai.flowcenter.repository.FlwUsageGrantRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiAppClient; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 应用使用授权管理面(2026-10-25 T01):学校×流程线×稳定主体的授予/撤销/查询。 + * + *

授予时把 appKey 解析成 UserBase 稳定主键 appId 落库(授权对象是主体不是凭据), + * 并按学校开通事实做发放闸(enforce 开启时未开通拒发)。撤销只置 {@code enabled=0} + * (不删行,审计与幂等判据保留);撤权只拦新发起,不回写在途实例与已受理命令。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FlowUsageGrantAdminService { + + private final FlwUsageGrantRepository grantRepository; + private final UserBaseActivationClient activationClient; + + @Autowired(required = false) + private OpenApiAppClient appClient; + + @org.springframework.beans.factory.annotation.Value("${flow-center.formal-command.enabled:false}") + private boolean formalEnabled; + + public List listByFlow(Long schId, String flowGuid) { + return grantRepository.findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(schId, flowGuid); + } + + public List listByApp(Long schId, Integer appId) { + return grantRepository.findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(schId, appId); + } + + /** + * 授予/重申(三元键 upsert)。 + * + * @throws BizException appKey 不可解析为稳定主体(fail-closed,管理面必须授到真实主体上); + * enforce 开启且应用未在该校开通或核验不可用 + */ + @Transactional + public FlwUsageGrant grant(Long schId, String flowGuid, String appKey, String remark, String operator) { + if (schId == null || schId <= 0 || flowGuid == null || flowGuid.trim().isEmpty() + || appKey == null || appKey.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "schId/flowGuid/appKey 必填"); + } + String cleanKey = appKey.trim(); + JsonNode registered = fetchRegistered(cleanKey); + Integer appId = registered == null ? null : readId(registered); + if (appId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "appKey 未能在 UserBase 解析出稳定应用主体,拒绝授权:" + cleanKey); + } + if (activationClient.isEnforce() + && !activationClient.isAppActivatedAtSchool(appId, schId)) { + throw new BizException(ErrorCode.FORBIDDEN, + "该应用未在学校 " + schId + " 开通,不能授予流程使用许可"); + } + FlwUsageGrant grant = grantRepository + .findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId) + .orElseGet(FlwUsageGrant::new); + boolean isNew = grant.getId() == null; + if (isNew) { + grant.setSchId(schId); + grant.setFlowGuid(flowGuid.trim()); + grant.setAppId(appId); + } + if (formalEnabled) { + String source = registered.path("sourcePrincipalId").asText(""); + try { + if (!java.util.UUID.fromString(source).toString().equals(source)) throw new IllegalArgumentException(); + } catch (IllegalArgumentException invalid) { + throw new BizException(ErrorCode.FORBIDDEN, "UserBase 稳定来源未迁移,不能发放正式许可"); + } + if (!activationClient.isAppActivatedStrict(appId, schId)) throw new BizException(ErrorCode.FORBIDDEN, "学校未开通"); + grant.setSourcePrincipalId(source); + } + grant.setAppKeySnapshot(cleanKey); + grant.setAppNameSnapshot(registered.path("appName").asText(null)); + grant.setEnabled(1); + grant.setRemark(remark); + FlwUsageGrant saved = grantRepository.save(grant); + log.info("[usage-grant] 授予 schId={} flowGuid={} appId={} appKey={} operator={} 新建={}", + schId, flowGuid, appId, cleanKey, operator, isNew); + return saved; + } + + /** 撤销(enabled=0,行保留);幂等:对已撤销行重复撤销不报错 */ + @Transactional + public FlwUsageGrant revoke(Long schId, String flowGuid, Integer appId, String remark, String operator) { + FlwUsageGrant grant = grantRepository + .findBySchIdAndFlowGuidAndAppId(schId, flowGuid, appId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, + "使用许可不存在:school=" + schId + " flow=" + flowGuid + " app=" + appId)); + grant.setEnabled(0); + if (remark != null && !remark.trim().isEmpty()) { + grant.setRemark(remark.trim()); + } + FlwUsageGrant saved = grantRepository.save(grant); + log.info("[usage-grant] 撤销 schId={} flowGuid={} appId={} operator={}(只拦新发起,在途不受影响)", + schId, flowGuid, appId, operator); + return saved; + } + + private JsonNode fetchRegistered(String appKey) { + if (appClient == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 应用注册通道未装配,拒绝授权"); + } + try { + JsonNode node = appClient.getAppByKey(appKey); + return node == null || node.isNull() ? null : node; + } catch (RuntimeException e) { + log.warn("[usage-grant] UserBase 注册解析失败 appKey={}:{}", appKey, e.toString()); + throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 应用注册解析不可用,请稍后重试"); + } + } + + private Integer readId(JsonNode registered) { + JsonNode idNode = registered.path("id"); + return idNode.isNumber() ? idNode.intValue() : null; + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java new file mode 100644 index 0000000..f8c8512 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java @@ -0,0 +1,96 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.flowcenter.entity.FlwUsageGrant; +import com.yunhai.flowcenter.repository.FlwUsageGrantRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiAppClient; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 应用使用许可闸门(2026-10-25 T01):新发起必须持有生效的 (school, flowGuid, appId) 许可。 + * + *

三层独立事实,缺一不可(enforce 开启时):

+ *
    + *
  1. 稳定主体:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用, + * 不是主体;主体解析不可达=fail-closed 拒绝);
  2. + *
  3. FL 本地使用许可:{@code flw_usage_grant} 生效行(独立于 flw_app 定义保存白名单 + * 与流程定义管理权);
  4. + *
  5. UB 学校开通:该应用在该校的平台开通事实(第二重证明,不因本地 grant 而豁免)。
  6. + *
+ * + *

撤权边界(P0-3):本闸只拦"新发起";已受理命令的回执恢复与在途实例不回读本表—— + * 恢复路径的"是否已受理"判定归命令协议(T04),调用方负责在恢复命中时跳过本闸。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FlowUsageGrantGuard { + + private final FlwUsageGrantRepository grantRepository; + private final UserBaseActivationClient activationClient; + + @Autowired(required = false) + private OpenApiAppClient appClient; + + /** enforce 关闭时直通(灰度开关,与 FC 同款迁移策略);开启后任何一关不过即拒 */ + public boolean isEnforce() { + return activationClient.isEnforce(); + } + + /** + * 新发起资格核验:返回稳定主体 appId(供命令快照/审计使用)。 + * + * @throws BizException enforce 开启时:主体解析不可用、无生效许可、未在该校开通、 + * UB 资格核验不可用——四类都拒绝,不猜不降级 + */ + public Integer requireNewApplyGrant(Long schId, String flowGuid, String appKey) { + if (!isEnforce()) { + return null; + } + if (schId == null || flowGuid == null || flowGuid.trim().isEmpty() + || appKey == null || appKey.trim().isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "缺少使用许可判定所需的学校/流程线/调用主体"); + } + Integer appId = resolveAppId(appKey.trim()); + if (appId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "调用应用主体解析不可用,已按未授权拒绝"); + } + FlwUsageGrant grant = grantRepository.findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId) + .orElse(null); + if (grant == null || !Integer.valueOf(1).equals(grant.getEnabled())) { + log.warn("[usage-grant] 拒绝新发起(无生效使用许可):schId={} flowGuid={} appId={} appKey={}", + schId, flowGuid, appId, appKey); + throw new BizException(ErrorCode.FORBIDDEN, + "该应用未获此流程线的使用许可(school=" + schId + " flow=" + flowGuid + ")"); + } + if (!activationClient.isAppActivatedAtSchool(appId, schId)) { + log.warn("[usage-grant] 拒绝新发起(应用未在该校开通):schId={} appId={}", schId, appId); + throw new BizException(ErrorCode.FORBIDDEN, + "该应用未在学校 " + schId + " 开通,不能发起新申报"); + } + return appId; + } + + /** appKey → UserBase 稳定主体主键;注册表不可达/查无/缺 id 一律 null(调用方拒绝) */ + private Integer resolveAppId(String appKey) { + if (appClient == null) { + return null; + } + try { + JsonNode registered = appClient.getAppByKey(appKey); + if (registered == null || registered.isNull()) { + return null; + } + JsonNode idNode = registered.path("id"); + return idNode.isNumber() ? idNode.intValue() : null; + } catch (RuntimeException e) { + log.warn("[usage-grant] UserBase 主体解析不可用 appKey={}:{}", appKey, e.toString()); + return null; + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java b/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java new file mode 100644 index 0000000..edd91ea --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java @@ -0,0 +1,175 @@ +package com.yunhai.flowcenter.service; + +import com.yunhai.flowcenter.entity.*; +import com.yunhai.flowcenter.model.FormalApplyModels; +import com.yunhai.flowcenter.repository.*; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Objects; + +/** Authoritative settled command facts; each accepted round gets a distinct engine instance. */ +@Service +@RequiredArgsConstructor +public class FormalApplyCommandService { + private final FlowSourcePrincipalResolver principals; + private final FormalPayloadCanonicalizer canonicalizer; + private final FlwCommandSourceRepository sources; + private final FlwBizCoordinateRepository coordinates; + private final FlwApplyCommandRepository commands; + private final FlwInstanceRepository instances; + private final FlwUsageGrantRepository grants; + private final UserBaseActivationClient activation; + private final OapiFlowService validation; + private final FlowInstanceService lifecycle; + private final TransactionTemplate transactions; + + @Value("${flow-center.formal-command.enabled:false}") + private boolean enabled; + + public FormalApplyModels.Receipt submit(Long school, String appKey, FormalApplyModels.Request request) { + requireEnabled(); + validateShape(school, request); + FlowSourcePrincipalResolver.Principal principal = principals.resolve(appKey); + String hash = canonicalizer.hash(request); + // A source mutex INSERT can race on the very first command. Retry only outside rollback. + for (int attempt = 0; ; attempt++) { + try { + return transactions.execute(tx -> accept(school, principal, request, hash)); + } catch (org.springframework.dao.TransientDataAccessException | DataIntegrityViolationException conflict) { + if (attempt >= 2) throw conflict; + } + } + } + + public FormalApplyModels.Receipt query(Long school, String appKey, String requestId, String expectedHash) { + requireEnabled(); + if (school == null || school <= 0 || !key(requestId, 64)) { + throw new BizException(ErrorCode.PARAM_ERROR, "学校/requestId 非法"); + } + FlowSourcePrincipalResolver.Principal principal = principals.resolve(appKey); + return transactions.execute(tx -> { + FlwApplyCommand command = commands.findBySchIdAndSourcePrincipalIdAndRequestId(school, principal.id, requestId) + .orElse(null); + if (command == null) { + FormalApplyModels.Receipt absent = new FormalApplyModels.Receipt(); + absent.setState("NOT_FOUND"); absent.setSchoolId(school); + absent.setSourcePrincipalId(principal.id); absent.setRequestId(requestId); + return absent; + } + if (expectedHash != null && !expectedHash.equals(command.getCanonicalHash())) throw conflict(); + return receipt(command); + }); + } + + private FormalApplyModels.Receipt accept(Long school, FlowSourcePrincipalResolver.Principal source, + FormalApplyModels.Request request, String hash) { + FlwCommandSource mutex = sources.lock(source.id).orElse(null); + if (mutex == null) { + mutex = new FlwCommandSource(); mutex.setId(source.id); mutex.setAppId(source.appId); + sources.saveAndFlush(mutex); + } else if (!source.appId.equals(mutex.getAppId())) { + throw new BizException(ErrorCode.FORBIDDEN, "来源 UUID 的 UserBase 主体绑定发生变化"); + } + FlwApplyCommand existing = commands.findBySchIdAndSourcePrincipalIdAndRequestId(school, source.id, request.getRequestId()) + .orElse(null); + if (existing != null) { + if (!hash.equals(existing.getCanonicalHash())) throw conflict(); + return receipt(existing); + } + FlwApplyCommand command = new FlwApplyCommand(); + command.setSchId(school); command.setSourcePrincipalId(source.id); command.setRequestId(request.getRequestId()); + command.setCanonicalHash(hash); command.setCanonicalVersion(1); command.setBizType(request.getBizType()); + command.setBizId(request.getBizId()); command.setFlowGuid(request.getFlowGuid()); + FlwBizCoordinate coordinate = coordinates.lock(school, source.id, request.getBizType(), request.getBizId()).orElse(null); + if (coordinate == null) { + coordinate = new FlwBizCoordinate(); coordinate.setSchId(school); coordinate.setSourcePrincipalId(source.id); + coordinate.setBizType(request.getBizType()); coordinate.setBizId(request.getBizId()); + coordinates.saveAndFlush(coordinate); + } + if (instances.findBySchIdAndBizTypeAndBizId(school, request.getBizType(), request.getBizId()).isPresent()) { + return reject(command, "LEGACY_COORDINATE_NOT_MIGRATED", "旧业务坐标尚未审核迁移"); + } + if (coordinate.getActiveInstanceId() != null) return reject(command, "IN_FLIGHT", "同一业务坐标已有在途实例"); + if (!Objects.equals(coordinate.getLastRound(), request.getExpectedPreviousRound()) + || !Objects.equals(coordinate.getLastInstanceId(), request.getExpectedPreviousInstanceId())) { + return reject(command, "PRECONDITION_FAILED", "首次申报须前轮为 0;重报须匹配前实例及轮次"); + } + if (coordinate.getLastInstanceId() != null) { + FlwInstance previous = instances.findFormalPreviousForUpdate(coordinate.getLastInstanceId()).orElseThrow(IllegalStateException::new); + if (!Integer.valueOf(30).equals(previous.getStatus()) && !Integer.valueOf(40).equals(previous.getStatus())) { + return reject(command, "RESUBMIT_NOT_ALLOWED", "仅符合既有业务资格的拒绝/撤回件可显式新申报"); + } + } + FlwUsageGrant grant = grants.findBySchIdAndFlowGuidAndAppId(school, request.getFlowGuid(), source.appId).orElse(null); + if (grant == null || !Integer.valueOf(1).equals(grant.getEnabled()) || !source.id.equals(grant.getSourcePrincipalId())) { + return reject(command, "USAGE_DENIED", "缺少与稳定来源绑定的流程使用许可"); + } + // Unavailability throws and rolls back. A definitive false is a settled rejection. + if (!activation.isAppActivatedStrict(source.appId, school)) return reject(command, "SCHOOL_NOT_ACTIVATED", "调用应用未在该校开通"); + FlowInstanceService.ApplyCommand prepared; + try { + prepared = validation.prepareFormal(school, source, request); + } catch (BizException denied) { + if (denied.getErrorCode() != ErrorCode.PARAM_ERROR && denied.getErrorCode() != ErrorCode.FORBIDDEN) throw denied; + return reject(command, "VALIDATION_REJECTED", denied.getMessage()); + } + // Never catch persistence/engine/event failures below: the entire admission must roll back. + prepared.payloadHash = hash; + int round = Math.addExact(coordinate.getLastRound(), 1); + FlwInstance instance = lifecycle.createFormalInstance(prepared, request.getRequestId(), round, source.id); + coordinate.setLastRound(round); coordinate.setLastInstanceId(instance.getId()); coordinate.setActiveInstanceId(instance.getId()); + coordinates.saveAndFlush(coordinate); + command.setState("ACCEPTED"); command.setRound(round); command.setInstanceId(instance.getId()); + command.setDefId(instance.getDefId()); command.setDefVersion(instance.getDefVersion()); commands.saveAndFlush(command); + lifecycle.startFormalInstance(instance, prepared); + return receipt(command); + } + + private FormalApplyModels.Receipt reject(FlwApplyCommand command, String code, String detail) { + command.setState("REJECTED"); command.setRejectCode(code); + command.setRejectDetail(detail == null ? null : detail.substring(0, Math.min(detail.length(), 500))); + commands.saveAndFlush(command); + return receipt(command); + } + + private FormalApplyModels.Receipt receipt(FlwApplyCommand command) { + FormalApplyModels.Receipt result = new FormalApplyModels.Receipt(); + BeanUtils.copyProperties(command, result); + result.setSchoolId(command.getSchId()); + if ("ACCEPTED".equals(command.getState())) { + FlwInstance instance = instances.findById(command.getInstanceId()).orElseThrow(IllegalStateException::new); + if (!command.getSchId().equals(instance.getSchId()) || !command.getSourcePrincipalId().equals(instance.getSourcePrincipalId()) + || !command.getBizType().equals(instance.getBizType()) || !command.getBizId().equals(instance.getBizId()) + || !command.getRequestId().equals(instance.getApplyRequestToken()) || !command.getRound().equals(instance.getApplyRound()) + || !command.getCanonicalHash().equals(instance.getApplyPayloadHash()) || !command.getDefVersion().equals(instance.getDefVersion()) + || !Objects.equals(command.getDefId(), instance.getDefId())) { + throw new IllegalStateException("命令与实例事实不一致,禁止返回成功回执"); + } + result.setStatus(instance.getStatus()); result.setVersion(instance.getVersion()); + } + return result; + } + + private void validateShape(Long school, FormalApplyModels.Request request) { + if (school == null || school <= 0 || request == null || request.getSubmitterId() == null + || !key(request.getRequestId(), 64) || !key(request.getBizType(), 32) + || !key(request.getBizId(), 64) || !key(request.getFlowGuid(), 32)) { + throw new BizException(ErrorCode.PARAM_ERROR, "正式命令缺少有效学校、requestId、业务坐标、流程线或提交人"); + } + } + // MySQL 5.6 VARCHAR collations ignore trailing spaces, even *_bin. Reject, never trim/alias raw keys. + private boolean key(String value, int length) { + return value != null && !value.isEmpty() && value.equals(value.trim()) && value.length() <= length; + } + private void requireEnabled() { + if (!enabled) throw new BizException(ErrorCode.FORBIDDEN, "正式命令模式尚未完成迁移启用"); + } + private BizException conflict() { return new BizException(ErrorCode.PARAM_ERROR, "REQUEST_ID_PAYLOAD_CONFLICT:原命令保持不变"); } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizer.java b/src/main/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizer.java new file mode 100644 index 0000000..d3f57b3 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizer.java @@ -0,0 +1,101 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.*; +import com.yunhai.flowcenter.model.FormalApplyModels; +import com.yunhai.flowcenter.model.OapiModels; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +/** v1 structural canonicalization, before definition lookup or server-side name enrichment. */ +@Component +@RequiredArgsConstructor +public class FormalPayloadCanonicalizer { + private final ObjectMapper mapper; + + public String hash(FormalApplyModels.Request request) { + try { + ObjectNode node = mapper.createObjectNode(); + node.set("flowGuid", mapper.valueToTree(request.getFlowGuid())); + node.set("bizType", mapper.valueToTree(request.getBizType())); + node.set("bizId", mapper.valueToTree(request.getBizId())); + node.set("bizTitle", mapper.valueToTree(request.getBizTitle())); + node.set("bizUrl", mapper.valueToTree(request.getBizUrl())); + node.set("submitterId", mapper.valueToTree(request.getSubmitterId())); + node.set("submitterName", mapper.valueToTree(request.getSubmitterName())); + node.set("formData", mapper.valueToTree(request.getFormData())); + node.set("expectedDefVersion", mapper.valueToTree(request.getExpectedDefVersion())); + node.set("expectedPreviousInstanceId", mapper.valueToTree(request.getExpectedPreviousInstanceId())); + node.set("expectedPreviousRound", mapper.valueToTree(request.getExpectedPreviousRound())); + node.set("bizSnapshot", jsonValue(request.getBizSnapshot())); + ObjectNode assigns = mapper.createObjectNode(); + if (request.getAssigns() != null) { + for (OapiModels.AssignItem item : request.getAssigns()) { + if (item == null || item.getNodeId() == null || assigns.has(item.getNodeId()) + || item.getUserIds() == null) { + throw invalid("指派节点缺失、重复或缺少人员集合"); + } + TreeSet users = new TreeSet<>(); + for (Integer uid : item.getUserIds()) { + if (uid == null || !users.add(uid)) { + throw invalid("指派人员为空或重复"); + } + } + assigns.set(item.getNodeId(), mapper.valueToTree(users)); + } + } + node.set("assigns", request.getAssigns() == null ? NullNode.instance : assigns); + String normalized = canonical(node); + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(normalized.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(64); + for (byte value : bytes) { + hex.append(Character.forDigit((value >>> 4) & 15, 16)); + hex.append(Character.forDigit(value & 15, 16)); + } + return hex.toString(); + } catch (BizException e) { + throw e; + } catch (Exception e) { + throw invalid("无法规范化命令载荷"); + } + } + + private JsonNode jsonValue(Object value) throws Exception { + if (value == null) return NullNode.instance; + if (value instanceof String) { + JsonNode decoded = mapper.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .readTree((String) value); + if (decoded == null) throw invalid("摘要 JSON 为空"); + return decoded; + } + return mapper.valueToTree(value); + } + + private String canonical(JsonNode node) throws Exception { + if (node.isObject()) { + SortedMap sorted = new TreeMap<>(); + node.fields().forEachRemaining(e -> sorted.put(e.getKey(), e.getValue())); + StringJoiner out = new StringJoiner(",", "{", "}"); + for (Map.Entry field : sorted.entrySet()) { + out.add(mapper.writeValueAsString(field.getKey()) + ":" + canonical(field.getValue())); + } + return out.toString(); + } + if (node.isArray()) { + StringJoiner out = new StringJoiner(",", "[", "]"); + for (JsonNode value : node) out.add(canonical(value)); + return out.toString(); + } + if (node.isNumber()) return node.decimalValue().stripTrailingZeros().toPlainString(); + return mapper.writeValueAsString(node); + } + + private BizException invalid(String message) { return new BizException(ErrorCode.PARAM_ERROR, message); } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index 0a6769b..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,6 +341,7 @@ public class OapiFlowService { // 事务边界外重读:另一个并发请求已用同一业务键建好办件 return fromConcurrent(schId, cmd, policy); } + requireNotConflict(outcome); FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2(); vo.setInstanceId(outcome.instanceId); vo.setDuplicated(outcome.duplicated); @@ -235,6 +359,137 @@ public class OapiFlowService { 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。 @@ -263,8 +518,16 @@ public class OapiFlowService { vo.setApplyRound(existing.getApplyRound()); vo.setStatus(existing.getStatus()); vo.setVersion(existing.getVersion()); - vo.setBranch(token != null && token.equals(existing.getApplyRequestToken()) - ? FlowApplyRetryModels.Branch.SAME_REQUEST : FlowApplyRetryModels.Branch.CONCURRENT_CREATED); + 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; @@ -298,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) { @@ -329,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<>(); @@ -355,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 { @@ -377,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()); @@ -432,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); } @@ -448,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() @@ -468,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); @@ -501,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; } @@ -557,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); } @@ -565,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"); @@ -673,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); @@ -686,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()) @@ -767,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)); @@ -1031,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()); @@ -1125,6 +1459,7 @@ public class OapiFlowService { 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/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java b/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java new file mode 100644 index 0000000..a958b08 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java @@ -0,0 +1,81 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.core.PlatformService; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * UserBase「应用×学校开通」事实核验客户端(2026-10-25 T01,flow-center 侧)。 + * + *

事实源=UserBase {@code sys_group_app_rel}(学校组单点开通,不沿祖先), + * 开放端点 {@code GET /oapi/app/activated?appId=&schoolId=}。enforce 开启时 + * 不可达/异常一律抛错(fail-closed)——"UserBase 不可达"不得降级为"仅本地 grant 存在即放行"。

+ */ +@Slf4j +@Component +public class UserBaseActivationClient { + + private static final String ACTIVATED_PATH = "/oapi/app/activated"; + + private final YschOpenCall openCall; + + /** + * 资格核验总开关(T01 灰度):false=不核(存量环境兼容);true=强制核验。 + * 生产开启前须确认 UserBase 已部署 {@code /oapi/app/activated} 且开通数据齐备。 + */ + @Value("${yh.flow.school-activation-enforce:false}") + private boolean enforce; + + public UserBaseActivationClient(@Autowired(required = false) YschOpenCall openCall) { + this.openCall = openCall; + } + + public boolean isEnforce() { + return enforce; + } + + /** + * @return true=已开通;false=明确未开通(调用方按"未获使用资格"拒绝) + * @throws BizException enforce 开启且 UserBase 不可达/返回异常——fail-closed + */ + public boolean isAppActivatedAtSchool(Integer appId, Long schoolId) { + if (!enforce) { + return true; + } + return isAppActivatedStrict(appId, schoolId); + } + + /** Formal commands never inherit the legacy bypass switch. */ + public boolean isAppActivatedStrict(Integer appId, Long schoolId) { + if (appId == null || schoolId == null || schoolId <= 0) { + throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验缺少主体或校域"); + } + if (openCall == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用(未配置出站通道)"); + } + Map query = new HashMap<>(); + query.put("appId", String.valueOf(appId)); + query.put("schoolId", String.valueOf(schoolId)); + JsonNode data; + try { + data = openCall.get(PlatformService.USERBASE.key(), ACTIVATED_PATH, query, JsonNode.class); + } catch (RuntimeException e) { + log.warn("[usage-grant] UserBase 开通资格核验失败 appId={} schoolId={}:{}", appId, schoolId, e.toString()); + throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用,请稍后重试"); + } + if (data == null || data.isNull() || !data.hasNonNull("activated")) { + log.warn("[usage-grant] UserBase 开通资格核验返回异常 appId={} schoolId={}", appId, schoolId); + throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用,请稍后重试"); + } + return data.path("activated").asBoolean(false); + } +} diff --git a/src/main/resources/sql/20261025_add_flw_def_line_offline.sql b/src/main/resources/sql/20261025_add_flw_def_line_offline.sql new file mode 100644 index 0000000..3c36ef7 --- /dev/null +++ b/src/main/resources/sql/20261025_add_flw_def_line_offline.sql @@ -0,0 +1,6 @@ +-- 2026-10-25 T03:流程线级停发标志(整线停新发起,不影响在途;与版本级 status 正交) +-- ddl-auto=update 会自动加列(NOT NULL DEFAULT 0);本脚本为生产备案 +ALTER TABLE `flw_def` + ADD COLUMN `line_offline` INT NOT NULL DEFAULT 0 COMMENT '线级停发:1=整线停新发起/预览(在途不受影响)' AFTER `version`; + +-- 已有生产索引不含 line_offline;新发起解析走 (sch_id, flow_guid, status, version) 既有索引后过滤,暂不加列索引 diff --git a/src/main/resources/sql/20261025_add_flw_instance_payload_hash.sql b/src/main/resources/sql/20261025_add_flw_instance_payload_hash.sql new file mode 100644 index 0000000..c09bd8c --- /dev/null +++ b/src/main/resources/sql/20261025_add_flw_instance_payload_hash.sql @@ -0,0 +1,4 @@ +-- 2026-10-25 T04:受理载荷指纹(同 requestToken 不同载荷=冲突,不伪装幂等) +-- ddl-auto=update 会自动加列;本脚本为生产备案。存量行保持 NULL=指纹核对缺省关。 +ALTER TABLE `flw_instance` + ADD COLUMN `apply_payload_hash` VARCHAR(64) NULL COMMENT '受理时规范载荷指纹 SHA-256(T04 同ID同指纹幂等/异载荷冲突)' AFTER `apply_round`; diff --git a/src/main/resources/sql/20261025_create_flw_state_event_and_intent.sql b/src/main/resources/sql/20261025_create_flw_state_event_and_intent.sql new file mode 100644 index 0000000..310bb24 --- /dev/null +++ b/src/main/resources/sql/20261025_create_flw_state_event_and_intent.sql @@ -0,0 +1,46 @@ +-- 2026-10-25 T05:状态事件事实(同事务落库)+ 待推进意图(办理事务内持久化) +-- 注:event_seq 为尽力单调(REQUIRES_NEW 推进拓扑下极端并发可能同值),非唯一键;严格全序待专用发号器 +-- ddl-auto=update 会自动建表;本脚本为生产备案 +CREATE TABLE IF NOT EXISTS `flw_state_event` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `event_id` VARCHAR(32) NOT NULL COMMENT '稳定事件身份(全订阅方/重试复用)', + `sch_id` BIGINT NOT NULL, + `instance_id` BIGINT NOT NULL, + `apply_round` INT NULL COMMENT '发布时冻结的申报轮次', + `event_seq` INT NOT NULL COMMENT '实例内单调序号', + `event_type` VARCHAR(32) NOT NULL, + `task_id` BIGINT NULL, + `operator_id` INT NULL, + `payload_json` LONGTEXT NOT NULL COMMENT '发生时载荷快照', + `dispatched` INT NOT NULL DEFAULT 0 COMMENT '0=待分发(崩溃窗口恢复扫描);1=已分发', + `create_by` VARCHAR(64) NULL, `create_time` DATETIME(6) NULL, + `update_by` VARCHAR(64) NULL, `update_time` DATETIME(6) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_state_event_id` (`event_id`), + KEY `idx_flw_state_event_instance` (`instance_id`), + KEY `idx_flw_state_event_dispatch` (`dispatched`, `create_time`), + KEY `idx_flw_state_event_seq` (`instance_id`, `event_seq`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '流程状态事件事实(T05 同事务 outbox)'; + +CREATE TABLE IF NOT EXISTS `flw_advance_intent` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `instance_id` BIGINT NOT NULL, + `node_id` VARCHAR(64) NOT NULL, + `edge_name` VARCHAR(128) NULL, + `assignee_id` INT NULL, + `assignee_name` VARCHAR(64) NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/DONE/EXHAUSTED', + `attempts` INT NOT NULL DEFAULT 0, + `next_retry_at` DATETIME(6) NULL, + `last_error` VARCHAR(255) NULL, + `create_by` VARCHAR(64) NULL, `create_time` DATETIME(6) NULL, + `update_by` VARCHAR(64) NULL, `update_time` DATETIME(6) NULL, + PRIMARY KEY (`id`), + KEY `idx_flw_advance_intent_due` (`status`, `next_retry_at`), + KEY `idx_flw_advance_intent_instance` (`instance_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '待推进意图(T05 办理事务内持久化+事务外 worker)'; + +-- T05 修正(2026-10-25 FlowEngineIT 会签并发实证):REPEATABLE READ 快照读 max+1 会撞 uk, +-- 事件序号改为实例行上计数器(行锁内 +1) +ALTER TABLE `flw_instance` + ADD COLUMN `event_seq_counter` INT NULL COMMENT '事件事实 eventSeq 发号器(T05 行锁内 +1)' AFTER `apply_payload_hash`; diff --git a/src/main/resources/sql/20261025_create_flw_usage_grant.sql b/src/main/resources/sql/20261025_create_flw_usage_grant.sql new file mode 100644 index 0000000..498928b --- /dev/null +++ b/src/main/resources/sql/20261025_create_flw_usage_grant.sql @@ -0,0 +1,21 @@ +-- 2026-10-25 T01:应用使用授权(学校×流程线×稳定应用主体) +-- ddl-auto=update 会自动建表;本脚本为生产备案(生产建议停自动 DDL 后手工执行) +-- 撤权语义:enabled=0 只拦新发起;已受理命令回执恢复与在途实例不回读本表。 +CREATE TABLE IF NOT EXISTS `flw_usage_grant` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `sch_id` BIGINT NOT NULL COMMENT '租户键', + `flow_guid` VARCHAR(64) NOT NULL COMMENT '流程线稳定身份(跨版本不变)', + `app_id` INT NOT NULL COMMENT '稳定应用主体主键(UserBase application_info.id)', + `app_key_snapshot` VARCHAR(64) NULL COMMENT '授权时 appKey 快照(审计回溯;判权不读)', + `app_name_snapshot` VARCHAR(128) NULL COMMENT '授权时应用名快照', + `enabled` INT NOT NULL DEFAULT 1 COMMENT '1=许可生效;0=撤销(只拦新发起)', + `remark` VARCHAR(255) NULL COMMENT '备注(人工审计线索)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME(6) NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME(6) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_flw_usage_grant` (`sch_id`, `flow_guid`, `app_id`), + KEY `idx_flw_usage_grant_sch_flow` (`sch_id`, `flow_guid`), + KEY `idx_flw_usage_grant_app` (`app_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '应用使用授权(T01 三权分立:使用许可≠定义管理权≠flw_app 白名单)'; diff --git a/src/main/resources/sql/20261025_drop_event_seq_uk.sql b/src/main/resources/sql/20261025_drop_event_seq_uk.sql new file mode 100644 index 0000000..ecb3094 --- /dev/null +++ b/src/main/resources/sql/20261025_drop_event_seq_uk.sql @@ -0,0 +1,3 @@ +-- 已在开发库按旧形态建过表的场合执行一次;新库直接用主脚本(无 uk) +ALTER TABLE `flw_state_event` DROP INDEX `uk_flw_state_event_seq`; +ALTER TABLE `flw_state_event` ADD INDEX `idx_flw_state_event_seq` (`instance_id`, `event_seq`); diff --git a/src/main/resources/sql/20261025_fix_flw_callback_log_uk.sql b/src/main/resources/sql/20261025_fix_flw_callback_log_uk.sql new file mode 100644 index 0000000..896e962 --- /dev/null +++ b/src/main/resources/sql/20261025_fix_flw_callback_log_uk.sql @@ -0,0 +1,12 @@ +-- 2026-10-25 R2(Codex 终审 D04):flw_callback_log 投递幂等键从 event_id 单列改为 (event_id, reg_id) 复合。 +-- +-- 背景:T05 起投递行 eventId 复用事实行稳定身份——同一事件对全部匹配订阅方各建一行(每 reg 一行), +-- 旧的单列 uk_flw_log_event(event_id) 会误拒第二订阅方的合法投递行(第二行插入即撞唯一键); +-- 同时并发"exists→insert"竞态不是幂等证明,改由复合 uk 兜底(DuplicateKeyException=另一路径已建行, +-- 调用方按幂等成功处理)。 +-- +-- 执行说明:仅在已按旧形态建过 uk 的库执行一次;新库由 ddl-auto=update / 建表脚本直接生成复合键。 +-- 本脚本不具幂等性(DROP INDEX 重复执行报 1091)——按 Codex D15 口径纳入发布前置检查清单, +-- 先 SHOW INDEX FROM flw_callback RESULTS 确认现存索引名再执行对应分支。 +ALTER TABLE `flw_callback_log` DROP INDEX `uk_flw_log_event`; +ALTER TABLE `flw_callback_log` ADD UNIQUE KEY `uk_flw_log_event_reg` (`event_id`, `reg_id`); diff --git a/src/main/resources/sql/20261026_d09_activation_intent_draft.sql b/src/main/resources/sql/20261026_d09_activation_intent_draft.sql new file mode 100644 index 0000000..ab0e7e3 --- /dev/null +++ b/src/main/resources/sql/20261026_d09_activation_intent_draft.sql @@ -0,0 +1,102 @@ +-- D09 DRAFT ONLY. Never run this file against production as part of application startup. +-- Quiesce handling, transfer, return, restart and advance workers before an audited legacy backfill. +-- Apply additive columns first, then deploy ALL D09 writers together; mixed old/new writers are unsafe. +-- The root task is an identity, not a completion-order comparison. Never backfill MAX(task_id). +ALTER TABLE flw_advance_intent + ADD COLUMN activation_task_id BIGINT NULL, + ADD COLUMN activation_task_ids LONGTEXT NULL, + ADD COLUMN handled_task_ids LONGTEXT NULL, + ADD COLUMN trigger_task_id BIGINT NULL, + ADD COLUMN activation_def_id BIGINT NULL, + ADD COLUMN activation_def_version INT NULL, + ADD COLUMN activation_apply_round INT NULL, + ADD COLUMN revision BIGINT NOT NULL DEFAULT 0, + ADD UNIQUE KEY uk_flw_advance_activation (activation_task_id); + +-- Inventory is a rollout gate, not an automatic guess of the historical cohort/last handler: +-- SELECT instance_id,node_id,id,status FROM flw_advance_intent WHERE activation_task_id IS NULL AND status IN ('PENDING','EXHAUSTED'); +-- NULL work without a registered successor is held EXHAUSTED, never executed or silently marked DONE. +-- For a still-active legacy node, an operator supplies the EXACT audited current task cohort. +-- If pending tasks remain, p_trigger must be NULL: their future real handling re-arms the new head. +-- If all tasks are already handled, p_trigger requires independent evidence of the LAST handler; +-- equal handle_time/ID order is NOT evidence. Ambiguous history stays held for an explicit audited resolution. +-- p_audit_ref identifies that evidence. A successor is inserted BEFORE NULL work is retired, atomically. +-- Test harness extracts the procedure below as ONE JDBC statement; mysql clients need DELIMITER $$ ... $$. +DELIMITER $$ +-- D09_BACKFILL_BEGIN +CREATE PROCEDURE d09_backfill_activation(IN p_instance BIGINT, IN p_node VARCHAR(64), IN p_tasks JSON, + IN p_trigger BIGINT, IN p_audit_ref VARCHAR(128)) +BEGIN + DECLARE v_root BIGINT; + DECLARE v_def BIGINT; + DECLARE v_version INT; + DECLARE v_round INT; + DECLARE v_status INT; + DECLARE v_nodes TEXT; + DECLARE v_pending INT; + DECLARE v_count INT; + DECLARE v_head BIGINT; + DECLARE v_members LONGTEXT; + DECLARE v_handled JSON; + DECLARE v_edge VARCHAR(128); + DECLARE v_user INT; + DECLARE v_name VARCHAR(64); + DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; + IF p_audit_ref IS NULL OR LENGTH(TRIM(p_audit_ref))=0 OR p_tasks IS NULL + OR JSON_TYPE(p_tasks)<>'ARRAY' OR JSON_LENGTH(p_tasks)=0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 audited cohort input is required'; + END IF; + IF EXISTS (SELECT 1 FROM JSON_TABLE(p_tasks,'$[*]' COLUMNS(ord FOR ORDINALITY,task_id BIGINT PATH '$')) j + WHERE JSON_TYPE(JSON_EXTRACT(p_tasks,CONCAT('$[',j.ord-1,']')))<>'INTEGER' OR j.task_id<=0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 task identities must be positive integers'; + END IF; + START TRANSACTION; + SELECT def_id,def_version,apply_round,status,current_nodes INTO v_def,v_version,v_round,v_status,v_nodes + FROM flw_instance WHERE id=p_instance FOR UPDATE; + IF v_status IS NULL OR v_status<>10 OR FIND_IN_SET(p_node,COALESCE(v_nodes,''))=0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 legacy node is not currently active'; + END IF; + IF EXISTS (SELECT 1 FROM flw_advance_intent WHERE instance_id=p_instance AND node_id=p_node AND activation_task_id IS NOT NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 registered successor exists; do not overwrite it'; + END IF; + SELECT COUNT(DISTINCT t.id),MIN(t.id) INTO v_count,v_root + FROM flw_task t JOIN JSON_TABLE(p_tasks,'$[*]' COLUMNS(task_id BIGINT PATH '$')) j ON j.task_id=t.id + WHERE t.instance_id=p_instance AND t.node_id=p_node AND t.status IN (0,1,3,5); + IF v_count<>JSON_LENGTH(p_tasks) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 cohort has duplicates, foreign or invalid tasks'; + END IF; + SET v_members=CONCAT(',',REPLACE(REPLACE(REPLACE(CAST(p_tasks AS CHAR),'[',''),']',''),' ',''),','); + IF EXISTS (SELECT 1 FROM flw_task WHERE instance_id=p_instance AND node_id=p_node AND status=0 + AND LOCATE(CONCAT(',',id,','),v_members)=0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 cohort omits current pending tasks'; + END IF; + SELECT COUNT(*) INTO v_pending FROM flw_task WHERE instance_id=p_instance AND node_id=p_node AND status=0; + IF (v_pending=0 AND p_trigger IS NULL) OR (v_pending>0 AND p_trigger IS NOT NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 pending cohort waits; completed cohort needs proven last handler'; + END IF; + IF p_trigger IS NOT NULL THEN + IF LOCATE(CONCAT(',',p_trigger,','),v_members)=0 + OR NOT EXISTS (SELECT 1 FROM flw_task WHERE id=p_trigger AND status=1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 last handler is not an approved cohort member'; + END IF; + SELECT edge_name,assignee_id,assignee_name INTO v_edge,v_user,v_name FROM flw_task WHERE id=p_trigger; + END IF; + SELECT JSON_ARRAYAGG(id) INTO v_handled FROM flw_task + WHERE instance_id=p_instance AND node_id=p_node AND status<>0 AND LOCATE(CONCAT(',',id,','),v_members)>0; + INSERT INTO flw_advance_intent(instance_id,node_id,activation_task_id,activation_task_ids,handled_task_ids, + trigger_task_id,activation_def_id,activation_def_version,activation_apply_round,revision, + edge_name,assignee_id,assignee_name,status,attempts,next_retry_at,last_error,create_time,update_time,create_by) + VALUES(p_instance,p_node,v_root,v_members, + IF(v_handled IS NULL,NULL,CONCAT(',',REPLACE(REPLACE(REPLACE(CAST(v_handled AS CHAR),'[',''),']',''),' ',''),',')), + p_trigger,v_def,v_version,v_round,0,v_edge,v_user,v_name,IF(p_trigger IS NULL,'DONE','PENDING'),0,NOW(), + LEFT(CONCAT('D09_AUDITED_BACKFILL: ',p_audit_ref),255),NOW(),NOW(),LEFT(p_audit_ref,64)); + SET v_head=LAST_INSERT_ID(); + UPDATE flw_advance_intent SET status='DONE',revision=revision+1,update_time=NOW(), + last_error=LEFT(CONCAT('D09_HANDOFF: successor=',v_head,' audit=',p_audit_ref),255) + WHERE instance_id=p_instance AND node_id=p_node AND activation_task_id IS NULL AND status IN ('PENDING','EXHAUSTED'); + COMMIT; +END +-- D09_BACKFILL_END +$$ +DELIMITER ; +-- No CALL is executed by this draft. Audit the cohort before an explicitly approved maintenance invocation. diff --git a/src/main/resources/sql/20261026_formal_commands_draft.sql b/src/main/resources/sql/20261026_formal_commands_draft.sql new file mode 100644 index 0000000..b8276cb --- /dev/null +++ b/src/main/resources/sql/20261026_formal_commands_draft.sql @@ -0,0 +1,45 @@ +-- REVIEW DRAFT. No production execution is authorized. +-- Stop ALL legacy writers and take a verified backup before the transactional model cutover. +-- MySQL 5.6: DDL auto-commits. Apply in a maintenance window, then validate indexes before enabling. +ALTER TABLE flw_instance ADD COLUMN source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + ADD COLUMN legacy_slot int NULL DEFAULT 1; +-- Keep every legacy row in slot 1. Do NOT infer its source UUID or fabricate command history. +UPDATE flw_instance SET legacy_slot=1 WHERE source_principal_id IS NULL; +ALTER TABLE flw_instance DROP INDEX uk_flw_instance_biz, + ADD UNIQUE KEY uk_flw_instance_legacy(sch_id,biz_type,biz_id,legacy_slot), + ADD UNIQUE KEY uk_flw_instance_round(sch_id,source_principal_id,biz_type,biz_id,apply_round); +ALTER TABLE flw_usage_grant ADD COLUMN source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NULL; +ALTER TABLE flw_callback_reg ADD COLUMN source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NULL; +-- UUID grants require explicit audited re-grant; subscriptions require authenticated re-registration. +CREATE TABLE flw_command_source ( + id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY, app_id int NOT NULL +) ENGINE=InnoDB; +CREATE TABLE flw_biz_coordinate ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, sch_id bigint NOT NULL, + source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + biz_type varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + biz_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + last_round int NOT NULL DEFAULT 0, last_instance_id bigint NULL, active_instance_id bigint NULL, +create_by varchar(255) NULL, create_time datetime NULL, update_by varchar(255) NULL, update_time datetime NULL, + UNIQUE KEY uk_flw_coord(sch_id,source_principal_id,biz_type,biz_id) +) ENGINE=InnoDB; +CREATE TABLE flw_apply_command ( + id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, sch_id bigint NOT NULL, + source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + request_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + canonical_hash varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + canonical_version int NOT NULL, + biz_type varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + biz_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + flow_guid varchar(32) NOT NULL, state varchar(16) NOT NULL, instance_id bigint NULL, + apply_round int NULL, def_id bigint NULL, def_version int NULL, reject_code varchar(64) NULL, reject_detail varchar(500) NULL, +create_by varchar(255) NULL, create_time datetime NULL, update_by varchar(255) NULL, update_time datetime NULL, + UNIQUE KEY uk_flw_command_request(sch_id,source_principal_id,request_id), + KEY idx_flw_command_instance(instance_id) +) ENGINE=InnoDB; +-- New formal comparisons require exact string identity. Review collation/index byte limits first. +-- On pre-existing case-insensitive columns, use the following explicit conversion (not an implicit code assumption): +ALTER TABLE flw_instance MODIFY biz_type varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL, + MODIFY biz_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL; +-- Do not enable formal-command.enabled until legacy coordinate migration policy is applied. +-- A clean/new coordinate is supported now; an overlapping legacy coordinate returns LEGACY_COORDINATE_NOT_MIGRATED. diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackConcurrentPushIT.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackConcurrentPushIT.java new file mode 100644 index 0000000..ffa3e1d --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackConcurrentPushIT.java @@ -0,0 +1,265 @@ +package com.yunhai.flowcenter.callback; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import com.yunhai.flowcenter.entity.FlwCallbackLog; +import com.yunhai.flowcenter.entity.FlwCallbackReg; +import com.yunhai.flowcenter.notify.UserMsgClient; +import com.yunhai.flowcenter.repository.FlwCallbackLogRepository; +import com.yunhai.flowcenter.repository.FlwCallbackRegRepository; +import com.yunhai.flowcenter.service.FlowCallbackAdminService; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +import javax.sql.DataSource; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +/** 两个独立本地集合、独立数据库会话,共用真 MySQL 8 和受屏障控制的真 HTTP 接收端。 */ +@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class CallbackConcurrentPushIT { + @Autowired FlwCallbackLogRepository logs; + @Autowired FlwCallbackRegRepository regs; + @Autowired CallbackLogStore store; + @Autowired DataSource dataSource; + @Autowired JdbcTemplate jdbc; + // 仅关闭与本类无关的扫描;竞争由测试屏障精确触发,不让调度器成为第三个执行者。 + @MockBean CallbackDispatcher dispatcher; + + HttpServer server; + ExecutorService serverThreads; + ExecutorService workers; + final List logIds=new ArrayList<>(), regIds=new ArrayList<>(); + final AtomicReference receiver=new AtomicReference<>(); + final AtomicInteger hits=new AtomicInteger(), active=new AtomicInteger(), maxActive=new AtomicInteger(); + final List eventIds=new CopyOnWriteArrayList<>(), bodies=new CopyOnWriteArrayList<>(); + CountingMessages messages; + CallbackProperties properties; + + interface Receiver { int respond(int sequence) throws Exception; } + + @BeforeEach void setup() throws Exception { + assertEquals(13429,jdbc.queryForObject("SELECT @@port",Integer.class)); + assertTrue(jdbc.queryForObject("SELECT @@version",String.class).startsWith("8.")); + assertEquals("c:/users/chenx/.zcode/tmp/fl-d09-zcode-mysql8/", + jdbc.queryForObject("SELECT @@datadir",String.class).replace('\\','/').toLowerCase()); + assertEquals("fl_d09_20261026",jdbc.queryForObject("SELECT DATABASE()",String.class)); + messages=new CountingMessages();properties=new CallbackProperties();properties.setAlertUserIds("999"); + workers=Executors.newFixedThreadPool(4);serverThreads=Executors.newCachedThreadPool(); + server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0);server.setExecutor(serverThreads); + receiver.set(n -> 200); + server.createContext("/callback",this::receive);server.start(); + } + + @AfterEach void cleanup() throws Exception { + if (workers!=null) {workers.shutdownNow();assertTrue(workers.awaitTermination(12,TimeUnit.SECONDS));} + if (server!=null) server.stop(0); + if (serverThreads!=null) serverThreads.shutdownNow(); + for (Long id:logIds) logs.deleteById(id); + for (Long id:regIds) regs.deleteById(id); + } + + void receive(HttpExchange exchange) { + int now=active.incrementAndGet();maxActive.accumulateAndGet(now,Math::max); + try { + int sequence=hits.incrementAndGet(); + eventIds.add(exchange.getRequestHeaders().getFirst("X-Flw-EventId")); + bodies.add(new String(org.springframework.util.StreamUtils.copyToByteArray(exchange.getRequestBody()),StandardCharsets.UTF_8)); + int status=receiver.get().respond(sequence); + byte[] body="{\"ok\":true}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status,body.length);exchange.getResponseBody().write(body); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } finally {active.decrementAndGet();exchange.close();} + } + + CallbackPushService executor(CallbackLogStore claimStore) { + return new CallbackPushService(logs,regs,properties,claimStore,messages,mock(ObjectProvider.class)); + } + + FlwCallbackLog row(String eventId) { + FlwCallbackReg reg=new FlwCallbackReg();reg.setSchId(1L);reg.setFlowGuid(UUID.randomUUID().toString().replace("-","")); + reg.setAppKey("100018");reg.setStatus(0);reg.setSecret("p22-secret"); + reg.setCallbackUrl("http://127.0.0.1:"+server.getAddress().getPort()+"/callback");reg=regs.saveAndFlush(reg);regIds.add(reg.getId()); + FlwCallbackLog row=new FlwCallbackLog();row.setSchId(1L);row.setRegId(reg.getId());row.setEventId(eventId); + row.setEvent("flow.submitted");row.setInstanceId(0L);row.setBizType("p22");row.setBizId(eventId); + row.setPayloadJson("{\"eventId\":\""+eventId+"\",\"event\":\"flow.submitted\"}"); + row.setNextRetryTime(LocalDateTime.now());row=logs.saveAndFlush(row);logIds.add(row.getId());return row; + } + FlwCallbackLog row() {return row(UUID.randomUUID().toString().replace("-",""));} + FlwCallbackLog current(Long id) {return logs.findById(id).orElseThrow(AssertionError::new);} + void gate(CountDownLatch latch) throws InterruptedException {assertTrue(latch.await(8,TimeUnit.SECONDS),"barrier timed out");} + + /** 每一轮真正同时争抢;胜者 HTTP 阻塞到落败执行者已返回,排除“恰巧串行”的假证明。 */ + void contest(FlwCallbackLog row, int result) throws Exception { + CallbackPushService a=executor(store),b=executor(store); + assertNotSame(ReflectionTestUtils.getField(a,"IN_FLIGHT"),ReflectionTestUtils.getField(b,"IN_FLIGHT")); + CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1),returned=new CountDownLatch(1); + receiver.set(n -> {entered.countDown();gate(release);return result;}); + CyclicBarrier start=new CyclicBarrier(2); + Future x=workers.submit(()->{start.await(5,TimeUnit.SECONDS);try {a.pushAsync(row.getId());} finally {returned.countDown();}return null;}); + Future y=workers.submit(()->{start.await(5,TimeUnit.SECONDS);try {b.pushAsync(row.getId());} finally {returned.countDown();}return null;}); + try { + gate(entered);gate(returned); + assertEquals(1,active.get());assertTrue(a.inFlight(row.getId()) ^ b.inFlight(row.getId())); + assertEquals(0,jdbc.queryForObject("SELECT count(*) FROM information_schema.innodb_trx WHERE trx_mysql_thread_id=" + + "IS_USED_LOCK(SHA2(CONCAT(LOWER(DATABASE()), ':flw_callback:', ?, ':', ?),256))",Integer.class,row.getEventId(),row.getRegId()), + "HTTP 期间不得持有数据库事务"); + } finally {release.countDown();} + x.get(12,TimeUnit.SECONDS);y.get(12,TimeUnit.SECONDS); + } + + @Test void independentExecutorsHaveExactlyOneEffectiveDispatcher() throws Exception { + FlwCallbackLog row=row();contest(row,200); + assertEquals(1,hits.get());assertEquals(1,maxActive.get());assertEquals(1,current(row.getId()).getStatus()); + assertEquals(0,current(row.getId()).getRetryCount()); + executor(store).pushAsync(row.getId());assertEquals(1,hits.get(),"重复认领成功行不应再发 HTTP"); + assertThat(eventIds).containsExactly(row.getEventId());assertThat(bodies).containsExactly(row.getPayloadJson()); + } + + @Test void competingFailuresIncrementOncePerValidAttemptAndAlertOnlyOnCommittedThreshold() throws Exception { + FlwCallbackLog row=row(); + List backoff=properties.getRetryBackoffMinutes(); + for (int attempt=1;attempt<=7;attempt++) { + LocalDateTime before=LocalDateTime.now();contest(row,503); + FlwCallbackLog saved=current(row.getId());long minutes=attempt<=backoff.size()?backoff.get(attempt-1):60; + assertEquals(attempt,saved.getRetryCount());assertEquals(attempt,hits.get());assertEquals(0,saved.getStatus()); + assertNotNull(saved.getLastError());assertThat(saved.getNextRetryTime()) + .isAfterOrEqualTo(before.plusMinutes(minutes)).isBeforeOrEqualTo(LocalDateTime.now().plusMinutes(minutes)); + assertEquals(attempt>=6?1:0,messages.hung.get()); + } + assertEquals(1,maxActive.get());assertThat(eventIds).allMatch(row.getEventId()::equals); + } + + void awaitExpired(FlwCallbackLog row) throws Exception { + // 直接等待数据库释放旧会话锁,不用 sleep,也不触碰旧连接以免续租。 + try (Connection connection=dataSource.getConnection();java.sql.PreparedStatement acquire=connection.prepareStatement( + "SELECT GET_LOCK(SHA2(CONCAT(LOWER(DATABASE()), ':flw_callback:', ?, ':', ?),256),6)")) { + acquire.setString(1,row.getEventId());acquire.setLong(2,row.getRegId()); + try(java.sql.ResultSet rs=acquire.executeQuery()){rs.next();assertEquals(1,rs.getInt(1),"expired owner must release its claim");} + try(java.sql.PreparedStatement release=connection.prepareStatement( + "SELECT RELEASE_LOCK(SHA2(CONCAT(LOWER(DATABASE()), ':flw_callback:', ?, ':', ?),256))")) { + release.setString(1,row.getEventId());release.setLong(2,row.getRegId());release.execute(); + } + } + } + + @ParameterizedTest @CsvSource({"503,200,true", "200,503,false", "503,502,false"}) + void lateOldResultCannotOverwriteNewSuccessOrNewFailure(int oldStatus, int newStatus, boolean sameExecutor) throws Exception { + FlwCallbackLog row=row();CallbackPushService old=executor(new CallbackLogStore(logs,dataSource,1)); + CountDownLatch entered=new CountDownLatch(1),releaseOld=new CountDownLatch(1); + receiver.set(n -> {if(n==1){entered.countDown();gate(releaseOld);return oldStatus;}return newStatus;}); + Future first=workers.submit(()->old.pushAsync(row.getId())); + try { + gate(entered);awaitExpired(row); + // 同一服务实例也须能在本地旧线程未退出时恢复;另一路验证独立执行者。 + (sameExecutor ? old : executor(store)).pushAsync(row.getId()); + FlwCallbackLog newer=current(row.getId()); + assertEquals(newStatus==200?1:0,newer.getStatus());assertEquals(newStatus==200?0:1,newer.getRetryCount()); + releaseOld.countDown();first.get(12,TimeUnit.SECONDS); + FlwCallbackLog after=current(row.getId()); + assertEquals(newer.getStatus(),after.getStatus());assertEquals(newer.getRetryCount(),after.getRetryCount()); + assertEquals(newer.getLastError(),after.getLastError());assertEquals(newer.getNextRetryTime(),after.getNextRetryTime()); + assertEquals(newer.getLastHttpStatus(),after.getLastHttpStatus());assertEquals(newer.getPushTime(),after.getPushTime()); + assertEquals(newer.getUpdateTime(),after.getUpdateTime());assertEquals(2,hits.get()); + assertThat(eventIds).containsExactly(row.getEventId(),row.getEventId()); + } finally {releaseOld.countDown();} + } + + @Test void abandonedAndAbortedClaimBothRecoverWithoutARecordMigration() throws Exception { + for (boolean abort:Arrays.asList(false,true)) { + FlwCallbackLog row=row(); + try (CallbackLogStore.Claim orphan=new CallbackLogStore(logs,dataSource,1).tryClaim(row.getId())) { + assertNotNull(orphan);assertTrue(orphan.active()); + assertNull(store.tryClaim(row.getId())); + if(abort)((Connection)ReflectionTestUtils.getField(orphan,"connection")).abort(Runnable::run); + awaitExpired(row);executor(store).pushAsync(row.getId()); + assertEquals(1,current(row.getId()).getStatus());assertFalse(orphan.failure("late failure",null,1)); + assertFalse(orphan.success(200));assertEquals(0,current(row.getId()).getRetryCount()); + } + } + assertEquals(2,hits.get()); + } + + @Test void aClaimCanSettleOnlyOnceAndReleasedSessionRestoresItsTimeout() throws Exception { + FlwCallbackLog row=row(); + try (CallbackLogStore.Claim claim=store.tryClaim(row.getId())) { + assertNotNull(claim);assertTrue(((Connection)ReflectionTestUtils.getField(claim,"connection")).getAutoCommit()); + assertTrue(claim.failure("first", "503",1));assertFalse(claim.failure("duplicate",null,5));assertFalse(claim.success(200)); + } + assertEquals(1,current(row.getId()).getRetryCount());assertEquals("first",current(row.getId()).getLastError()); + try (Connection connection=dataSource.getConnection();java.sql.Statement st=connection.createStatement(); + java.sql.ResultSet rs=st.executeQuery("SELECT @@session.wait_timeout")) { + rs.next();assertEquals(jdbc.queryForObject("SELECT @@global.wait_timeout",Long.class),Long.valueOf(rs.getLong(1))); + } + } + + @Test void differentSubscriptionsShareEventIdentityButHaveIndependentDispatchRights() throws Exception { + FlwCallbackLog a=row(),b=row(a.getEventId()); + CountDownLatch entered=new CountDownLatch(2),release=new CountDownLatch(1); + receiver.set(n -> {entered.countDown();gate(release);return 200;}); + Future first=workers.submit(()->executor(store).pushAsync(a.getId())); + Future second=workers.submit(()->executor(store).pushAsync(b.getId())); + try {gate(entered);assertEquals(2,active.get());}finally {release.countDown();} + first.get(12,TimeUnit.SECONDS);second.get(12,TimeUnit.SECONDS); + assertEquals(1,current(a.getId()).getStatus());assertEquals(1,current(b.getId()).getStatus()); + assertThat(eventIds).containsExactlyInAnyOrder(a.getEventId(),a.getEventId()); + FlwCallbackLog duplicate=new FlwCallbackLog();org.springframework.beans.BeanUtils.copyProperties(a,duplicate,"id"); + assertThrows(org.springframework.dao.DataIntegrityViolationException.class,()->logs.saveAndFlush(duplicate)); + } + + @Test void manualActionsCannotResetOrOverwriteAnActiveOrSuccessfulAttempt() throws Exception { + FlwCallbackLog row=row();CallbackPushService worker=executor(store); + FlowCallbackAdminService admin=new FlowCallbackAdminService(regs,logs,worker,properties,store); + CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1); + receiver.set(n -> {entered.countDown();gate(release);return 200;}); + Future sending=workers.submit(()->worker.pushAsync(row.getId())); + try { + gate(entered); + assertThrows(BizException.class,()->admin.rePush(1L,Collections.singletonList(row.getId()))); + assertThrows(BizException.class,()->admin.markDead(1L,row.getId())); + assertEquals(0,current(row.getId()).getRetryCount()); + } finally {release.countDown();} + sending.get(12,TimeUnit.SECONDS);assertEquals(1,current(row.getId()).getStatus()); + assertThrows(BizException.class,()->worker.markDead(row,"stale admin snapshot")); + admin.rePush(1L,Collections.singletonList(row.getId()));assertEquals(1,hits.get()); + } + + @Test void batchManualReplayCommitsBeforeHttpAndKeepsSuccessfulRowsUntouched() { + FlwCallbackLog a=row(),b=row();CallbackPushService worker=executor(store); + worker.markDead(a,"manual");worker.pushAsync(b.getId()); + FlowCallbackAdminService admin=new FlowCallbackAdminService(regs,logs,worker,properties,store); + receiver.set(n -> {assertEquals(0,current(a.getId()).getStatus());assertEquals(0,current(a.getId()).getRetryCount());return 200;}); + admin.rePush(1L,Arrays.asList(a.getId(),b.getId())); + assertEquals(2,hits.get());assertEquals(1,current(a.getId()).getStatus());assertEquals(1,current(b.getId()).getStatus()); + } + + static class CountingMessages extends UserMsgClient { + final AtomicInteger hung=new AtomicInteger(); + CountingMessages(){super(null,null,false,"");} + @Override public void sendUserMsg(Integer sch,List users,Integer type,String bizType,String bizId, + String title,String content,String path,String app,String number,Integer expiry) { + if(number!=null&&number.startsWith("hang:"))hung.incrementAndGet(); + } + } +} 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/callback/CallbackFactDispatchIT.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackFactDispatchIT.java new file mode 100644 index 0000000..12b5683 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackFactDispatchIT.java @@ -0,0 +1,382 @@ +package com.yunhai.flowcenter.callback; + +import com.yunhai.flowcenter.engine.FlowEvent; +import com.yunhai.flowcenter.entity.FlwCallbackLog; +import com.yunhai.flowcenter.entity.FlwCallbackReg; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.support.FlowTestSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.AopTestUtils; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mockingDetails; + +/** + * C01: real business commit -> real AFTER_COMMIT -> independent fact commit, plus the real sweep entry. + * Run alone on the existing P2-2 MySQL 8 fixture (13429/fl_d09_20261026), with ddl-auto=validate. + * No test-managed transaction. Only HTTP transport is mocked: all facts/deliveries use real repositories. + */ +@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=validate") +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class CallbackFactDispatchIT extends FlowTestSupport { + + @Autowired private DataSource dataSource; + @Autowired private JdbcTemplate jdbc; + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private ApplicationEventPublisher publisher; + @PersistenceContext private EntityManager entityManager; + @SpyBean private CallbackDispatcher dispatcher; + @SpyBean private CallbackFactStore factStore; + @SpyBean private CallbackLogStore logStore; + @MockBean private CallbackPushService pushService; + @MockBean private TaskScheduler scheduler; + + private ExecutorService worker; + private final AtomicBoolean skipNormalDispatch = new AtomicBoolean(); + private final AtomicBoolean failFactCommit = new AtomicBoolean(); + private final AtomicBoolean failSecondDelivery = new AtomicBoolean(); + private final AtomicInteger deliveryAttempts = new AtomicInteger(); + private final AtomicInteger flushedButRolledBack = new AtomicInteger(); + private final AtomicInteger factTransactions = new AtomicInteger(); + private final AtomicReference businessEntityManager = new AtomicReference<>(); + private final List pushed = new CopyOnWriteArrayList<>(); + private final AtomicBoolean pushSawTransaction = new AtomicBoolean(); + + @BeforeEach + void setUp() throws Exception { + // Guard BEFORE cleanDb or any test DML. Reuse the original P2-2 verifier's physical identity. + assertThat(jdbc.queryForObject("SELECT @@version", String.class)).startsWith("8."); + assertThat(jdbc.queryForObject("SELECT @@port", Integer.class)).isEqualTo(13429); + assertThat(jdbc.queryForObject("SELECT DATABASE()", String.class)).isEqualTo("fl_d09_20261026"); + String datadir = jdbc.queryForObject("SELECT @@datadir", String.class); + assertThat(datadir.replace('\\', '/').replaceAll("/+", "/").replaceAll("/$", "").toLowerCase(java.util.Locale.ROOT)) + .isEqualTo("c:/users/chenx/.zcode/tmp/fl-d09-zcode-mysql8"); + cleanDb(); + + worker = Executors.newSingleThreadExecutor(); + // Inject only the executor, never invoke private dispatchFact: sweep itself selects and submits work. + // Typed local first: an inline generic argument would resolve to the setField(Class,...) overload. + Object dispatcherTarget = AopTestUtils.getUltimateTargetObject(dispatcher); + ReflectionTestUtils.setField(dispatcherTarget, "pushExecutor", worker); + skipNormalDispatch.set(false); + failFactCommit.set(false); + failSecondDelivery.set(false); + deliveryAttempts.set(0); + flushedButRolledBack.set(0); + factTransactions.set(0); + pushed.clear(); + pushSawTransaction.set(false); + businessEntityManager.set(null); + + doAnswer(call -> skipNormalDispatch.get() ? null : call.callRealMethod()) + .when(dispatcher).onFlowEvent(any(FlowEvent.class)); + + assertThat(AopUtils.isAopProxy(factStore)).as("bookkeeping must cross a Spring transaction proxy").isTrue(); + CallbackFactStore factTarget = AopTestUtils.getUltimateTargetObject(factStore); + assertThat(mockingDetails(factTarget).isSpy()).isTrue(); + doAnswer(call -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isTrue(); + assertThat(entityManager.getDelegate()).isNotSameAs(businessEntityManager.get()); + factTransactions.incrementAndGet(); + call.callRealMethod(); + if (failFactCommit.get()) { + // Real SQL is flushed, then the real REQUIRES_NEW transaction must roll it back. + entityManager.flush(); + String eventId = call.getArgument(0); + assertThat(jdbc.queryForObject("SELECT dispatched FROM flw_state_event WHERE event_id=?", + Integer.class, eventId)).isEqualTo(1); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", eventId)).isZero(); + flushedButRolledBack.incrementAndGet(); + throw new DataAccessResourceFailureException("C01 test: failure after fact UPDATE flush"); + } + return null; + }).when(factTarget).markDispatched(anyString()); + + CallbackLogStore logTarget = AopTestUtils.getUltimateTargetObject(logStore); + assertThat(mockingDetails(logTarget).isSpy()).isTrue(); + doAnswer(call -> { + if (failSecondDelivery.get() && deliveryAttempts.incrementAndGet() == 2) { + throw new DataAccessResourceFailureException("C01 test: second delivery persistence failed"); + } + return call.callRealMethod(); + }).when(logTarget).persist(any(FlwCallbackLog.class)); + + doAnswer(call -> { + pushed.add(call.getArgument(0)); + if (TransactionSynchronizationManager.isActualTransactionActive()) pushSawTransaction.set(true); + return null; + }).when(pushService).pushAsync(anyLong()); + } + + @AfterEach + void tearDown() throws Exception { + if (worker != null) { + worker.shutdown(); + assertThat(worker.awaitTermination(10, TimeUnit.SECONDS)).as("no work may leak into the next cleanup").isTrue(); + } + } + + @Test + void normalCommitPersistsBookkeepingAndFrozenDelivery() throws Exception { + FlowEvent event = publish(1, false); + drain(); + assertThat(factTransactions.get()).isEqualTo(1); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isEqualTo(1); + assertDeliveries(event, 1); + assertThat(pushed).containsExactly(deliveryIds(event).get(0)); + assertThat(pushSawTransaction.get()).isFalse(); + } + + @Test + void zeroSubscribersCommitBookkeepingWithoutInventingDeliveries() throws Exception { + FlowEvent event = publish(0, false); + drain(); + assertThat(factTransactions.get()).isEqualTo(1); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isEqualTo(1); + assertDeliveries(event, 0); + assertThat(pushed).isEmpty(); + } + + @Test + void bookkeepingFailureRollsBackAndSweepReusesCommittedDelivery() throws Exception { + failFactCommit.set(true); + FlowEvent event = publish(1, false); + drain(); + assertThat(flushedButRolledBack.get()).isEqualTo(1); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isZero(); + assertThat(readInt("SELECT count(*) FROM flw_instance WHERE id=?", event.getInstanceId())).isEqualTo(1); + assertDeliveries(event, 1); + List original = deliveryIds(event); + + failFactCommit.set(false); + age(event, 120); + sweep(); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isEqualTo(1); + assertThat(deliveryIds(event)).containsExactlyElementsOf(original); + sweep(); + assertThat(deliveryIds(event)).containsExactlyElementsOf(original); + assertThat(pushed).containsExactly(original.get(0)); + } + + @Test + void sweepHonorsBufferThenPersistsFrozenDeliveriesIdempotently() throws Exception { + skipNormalDispatch.set(true); // Simulate missed AFTER_COMMIT delivery, not a mocked repository. + FlowEvent event = publish(2, false); + age(event, 0); + sweep(); + assertDeliveries(event, 0); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isZero(); + + age(event, 120); + sweep(); + assertDeliveries(event, 2); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isEqualTo(1); + List original = deliveryIds(event); + sweep(); + assertThat(deliveryIds(event)).containsExactlyElementsOf(original); + assertThat(pushed).containsExactlyInAnyOrderElementsOf(original); + assertThat(pushSawTransaction.get()).isFalse(); + } + + @Test + void sweepZeroSubscribersCommitsBookkeeping() throws Exception { + skipNormalDispatch.set(true); + FlowEvent event = publish(0, false); + age(event, 120); + sweep(); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isEqualTo(1); + assertDeliveries(event, 0); + assertThat(pushed).isEmpty(); + } + + @Test + void normalPartialPersistenceFailureCannotMarkDoneAndSweepCompletes() throws Exception { + failSecondDelivery.set(true); + FlowEvent event = publish(2, false); + drain(); + assertThat(factTransactions.get()).isZero(); // dispatch failed before bookkeeping could run. + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isZero(); + assertDeliveries(event, 1); + Long original = deliveryIds(event).get(0); + failSecondDelivery.set(false); + age(event, 120); + sweep(); + assertDeliveries(event, 2); + assertThat(deliveryIds(event)).contains(original); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isEqualTo(1); + } + + @Test + void sweepPartialPersistenceFailureRetainsFactUntilAllDeliveriesExist() throws Exception { + skipNormalDispatch.set(true); + FlowEvent event = publish(2, false); + failSecondDelivery.set(true); + age(event, 120); + sweep(); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isZero(); + assertDeliveries(event, 1); + Long original = deliveryIds(event).get(0); + failSecondDelivery.set(false); + sweep(); + assertDeliveries(event, 2); + assertThat(deliveryIds(event)).contains(original); + assertThat(readInt("SELECT dispatched FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isEqualTo(1); + List completed = deliveryIds(event); + sweep(); + assertThat(deliveryIds(event)).containsExactlyElementsOf(completed); + } + + @Test + void rolledBackBusinessTransactionDoesNotDispatchOrLeaveFact() throws Exception { + FlowEvent event = publish(1, true); + drain(); + assertThat(readInt("SELECT count(*) FROM flw_state_event WHERE event_id=?", event.getStableEventId())).isZero(); + assertThat(readInt("SELECT count(*) FROM flw_instance WHERE id=?", event.getInstanceId())).isZero(); + assertDeliveries(event, 0); + assertThat(factTransactions.get()).isZero(); + assertThat(pushed).isEmpty(); + } + + private FlowEvent publish(int subscriptions, boolean rollback) { + return new TransactionTemplate(transactionManager).execute(tx -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isTrue(); + businessEntityManager.set(entityManager.getDelegate()); + FlwDef def = saveAndPublishDef("c01", "C01", "{\"start\":\"s\",\"nodes\":[{\"id\":\"s\",\"type\":\"START\"}],\"edges\":[]}"); + FlwInstance instance = new FlwInstance(); + instance.setSchId(SCH); + instance.setDefId(def.getId()); + instance.setDefCode(def.getCode()); + instance.setDefVersion(def.getVersion()); + instance.setBizType("c01"); + instance.setBizId(UUID.randomUUID().toString().replace("-", "")); + instance.setBizTitle("C01 fixture"); + instance.setSubmitterId(201); + instance.setSubmitterName("C01 submitter"); + instance.setSubmitTime(LocalDateTime.now()); + instance.setApplyRound(1); + instanceRepository.saveAndFlush(instance); + for (int i = 0; i < subscriptions; i++) { + FlwCallbackReg reg = new FlwCallbackReg(); + reg.setSchId(SCH); + reg.setFlowGuid(def.getFlowGuid()); + reg.setAppKey("c01-" + i); + reg.setSecret("local-test-only"); + reg.setStatus(0); + reg.setEventTypes(FlowEvent.Type.SUBMITTED.getCode()); + reg.setCallbackUrl("http://127.0.0.1:9/c01-never-sent"); + regRepository.saveAndFlush(reg); + } + FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, instance.getId()); + publisher.publishEvent(event); // Real identity/recorder/listener registration, no manual onFlowEvent call. + entityManager.flush(); + assertThat(event.getStableEventId()).isNotNull(); + assertThat(jdbc.queryForObject("SELECT dispatched FROM flw_state_event WHERE event_id=?", + Integer.class, event.getStableEventId())).isZero(); + assertThat(jdbc.queryForObject("SELECT count(*) FROM flw_callback_log WHERE event_id=?", + Integer.class, event.getStableEventId())).isZero(); + if (rollback) tx.setRollbackOnly(); + return event; + }); + } + + private void age(FlowEvent event, int seconds) { + assertThat(jdbc.update("UPDATE flw_state_event SET create_time=DATE_SUB(NOW(), INTERVAL ? SECOND) WHERE event_id=?", + seconds, event.getStableEventId())).isEqualTo(1); + } + + private void sweep() throws Exception { + dispatcher.sweepUndispatchedFacts(); + drain(); + } + + private void drain() throws Exception { + // FIFO barriers include sweep work and its subsequently queued initial pushes; no timing sleep. + for (int i = 0; i < 2; i++) worker.submit(() -> { }).get(10, TimeUnit.SECONDS); + } + + private int readInt(String sql, Object value) throws Exception { + try (Connection connection = dataSource.getConnection(); PreparedStatement query = connection.prepareStatement(sql)) { + assertThat(connection.getAutoCommit()).isTrue(); + query.setObject(1, value); + try (ResultSet rows = query.executeQuery()) { + assertThat(rows.next()).isTrue(); + return rows.getInt(1); + } + } + } + + private List deliveryIds(FlowEvent event) throws Exception { + List ids = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); PreparedStatement query = connection.prepareStatement( + "SELECT id FROM flw_callback_log WHERE event_id=? ORDER BY id")) { + query.setString(1, event.getStableEventId()); + try (ResultSet rows = query.executeQuery()) { while (rows.next()) ids.add(rows.getLong(1)); } + } + return ids; + } + + private void assertDeliveries(FlowEvent event, int expected) throws Exception { + try (Connection connection = dataSource.getConnection(); PreparedStatement query = connection.prepareStatement( + "SELECT l.event_id,l.payload_json,l.status,l.retry_count,r.flow_guid,r.event_types,l.reg_id " + + "FROM flw_callback_log l JOIN flw_callback_reg r ON r.id=l.reg_id WHERE l.event_id=?")) { + query.setString(1, event.getStableEventId()); + int count = 0; + java.util.Set regIds = new java.util.HashSet<>(); + try (ResultSet rows = query.executeQuery()) { + while (rows.next()) { + count++; + assertThat(rows.getString(1)).isEqualTo(event.getStableEventId()); + assertThat(rows.getString(2)).isEqualTo(event.getStablePayloadJson()); + assertThat(rows.getInt(3)).isZero(); + assertThat(rows.getInt(4)).isZero(); + assertThat(rows.getString(5)).isEqualTo(new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(event.getStablePayloadJson()).path("flowGuid").asText()); + assertThat(rows.getString(6)).isEqualTo(FlowEvent.Type.SUBMITTED.getCode()); + assertThat(regIds.add(rows.getLong(7))).as("one row per event/subscription").isTrue(); + } + } + assertThat(count).isEqualTo(expected); + } + } +} diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackRecoveryDeliveryTest.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackRecoveryDeliveryTest.java new file mode 100644 index 0000000..6a606cd --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackRecoveryDeliveryTest.java @@ -0,0 +1,428 @@ +package com.yunhai.flowcenter.callback; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowEvent; +import com.yunhai.flowcenter.entity.FlwCallbackLog; +import com.yunhai.flowcenter.entity.FlwCallbackReg; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwStateEvent; +import com.yunhai.flowcenter.repository.FlwCallbackLogRepository; +import com.yunhai.flowcenter.repository.FlwCallbackRegRepository; +import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwStateEventRepository; +import com.yunhai.platform.flowcallback.FlowCallbackEventStore; +import com.yunhai.platform.flowcallback.FlowCallbackHandler; +import com.yunhai.platform.flowcallback.FlowCallbackProperties; +import com.yunhai.platform.flowcallback.FlowCallbackReceiver; +import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * R2(Codex 终审 D04)投递可靠性契约单测: + * + *
    + *
  • 统一冻结信封:正常链复用事实冻结体;恢复链对冻结体原样复用、对 R2 前存量行 + * 幂等补齐 eventId/eventSeq/flowGuid——两条路径消息体同构;
  • + *
  • 恢复消息实际到达真实 Receiver:用 BasePlatform starter 的 + * {@link FlowCallbackReceiver}(非替身)验证正常体/恢复体均被接收、落事件行并推进 + * Handler;修复前的残缺恢复体(消息缺 eventId)被按脏消息丢弃作反例对照;
  • + *
  • 未可靠建行不记账:任一订阅方建行失败→事实保持 dispatched=0;全部建行 + * (含 uk 并发兜底 DuplicateKeyException=已建行)→才标记 dispatched=1。
  • + *
+ */ +@DisplayName("事件投递恢复契约(R2:冻结信封/真实 Receiver/未建行不记账)") +class CallbackRecoveryDeliveryTest { + + private static final long SCH = 1L; + private static final String FLOW_GUID = "fg-r2-recovery"; + + private final ObjectMapper mapper = new ObjectMapper(); + + private FlwInstanceRepository instanceRepository; + private FlwDefRepository defRepository; + private FlwCallbackRegRepository regRepository; + private FlwCallbackLogRepository logRepository; + private CallbackLogStore logStore; + private FlwStateEventRepository stateEventRepository; + private CallbackDispatcher dispatcher; + + @BeforeEach + void setUp() { + instanceRepository = mock(FlwInstanceRepository.class); + defRepository = mock(FlwDefRepository.class); + regRepository = mock(FlwCallbackRegRepository.class); + logRepository = mock(FlwCallbackLogRepository.class); + logStore = mock(CallbackLogStore.class); + stateEventRepository = mock(FlwStateEventRepository.class); + dispatcher = new CallbackDispatcher(instanceRepository, defRepository, regRepository, + logRepository, logStore, mock(CallbackPushService.class), new CallbackProperties(), + mapper, stateEventRepository, new CallbackFactStore(stateEventRepository)); + org.springframework.test.util.ReflectionTestUtils.setField(dispatcher, "pushExecutor", + mock(ExecutorService.class)); + + FlwInstance instance = new FlwInstance(); + instance.setId(900L); + instance.setSchId(SCH); + instance.setDefId(55L); + instance.setVersion(2); + instance.setApplyRound(3); + instance.setBizType("svc_item"); + instance.setBizId("r2-42"); + when(instanceRepository.findById(900L)).thenReturn(Optional.of(instance)); + + FlwDef def = new FlwDef(); + def.setId(55L); + def.setSchId(SCH); + def.setFlowGuid(FLOW_GUID); + when(defRepository.findById(55L)).thenReturn(Optional.of(def)); + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)).thenReturn(new ArrayList<>()); + + when(logRepository.existsByEventIdAndRegId(anyString(), org.mockito.ArgumentMatchers.anyLong())) + .thenReturn(false); + when(logStore.persist(any(FlwCallbackLog.class))).thenAnswer(c -> { + FlwCallbackLog row = c.getArgument(0); + row.setId(System.nanoTime()); + return row.getId(); + }); + } + + private FlwCallbackReg reg(long id, String eventTypes) { + FlwCallbackReg reg = new FlwCallbackReg(); + reg.setId(id); + reg.setSchId(SCH); + reg.setFlowGuid(FLOW_GUID); + reg.setEventTypes(eventTypes); + reg.setStatus(0); + return reg; + } + + /** 经真实 Recorder 冻结的事实行(信封与身份同源) */ + private FlwStateEvent frozenFact() { + FlwStateEventRecorder recorder = new FlwStateEventRecorder(stateEventRepository, + instanceRepository, defRepository, mapper); + when(instanceRepository.nextEventSeqForUpdate(900L)).thenReturn(7); + when(instanceRepository.setEventSeqCounter(eq(900L), org.mockito.ArgumentMatchers.anyInt())) + .thenReturn(1); + when(stateEventRepository.save(any(FlwStateEvent.class))).thenAnswer(c -> c.getArgument(0)); + FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L); + event.captureRoundIdentity(3, "tok-r2"); + recorder.record(event); + FlwStateEvent fact = new FlwStateEvent(); + fact.setId(11L); + fact.setEventId(event.getStableEventId()); + fact.setSchId(SCH); + fact.setInstanceId(900L); + fact.setApplyRound(3); + fact.setEventSeq(event.getStableEventSeq()); + fact.setEventType("flow.completed"); + fact.setPayloadJson(event.getStablePayloadJson()); + fact.setDispatched(0); + return fact; + } + + @SuppressWarnings("unchecked") + private void dispatchFact(FlwStateEvent fact) { + org.springframework.test.util.ReflectionTestUtils.invokeMethod(dispatcher, "dispatchFact", fact); + } + + // ==================== 冻结信封统一性 ==================== + + @Test + @DisplayName("正常链复用事实冻结体:投递行消息体=冻结信封原文(含 eventId/eventSeq/flowGuid)") + void normalDispatchUsesFrozenEnvelope() throws Exception { + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)) + .thenReturn(Arrays.asList(reg(7L, null))); + FlwStateEvent fact = frozenFact(); + when(stateEventRepository.findByEventId(fact.getEventId())).thenReturn(Optional.of(fact)); + FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L); + event.assignStableIdentity(fact.getEventId(), fact.getEventSeq()); + event.assignStablePayload(fact.getPayloadJson()); + + dispatcher.onFlowEvent(event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwCallbackLog.class); + verify(logStore).persist(captor.capture()); + assertThat(captor.getValue().getPayloadJson()).isEqualTo(fact.getPayloadJson()); + JsonNode body = mapper.readTree(captor.getValue().getPayloadJson()); + assertThat(body.path("eventId").asText()).isEqualTo(fact.getEventId()); + assertThat(body.path("eventSeq").asInt()).isEqualTo(7); + assertThat(body.path("flowGuid").asText()).isEqualTo(FLOW_GUID); + // 正常链全建行成功 → 事实记账 dispatched=1 + verify(stateEventRepository).save(org.mockito.ArgumentMatchers.argThat( + f -> Integer.valueOf(1).equals(f.getDispatched()))); + } + + @Test + @DisplayName("恢复链(R2 冻结体):原样复用事实载荷,与正常链消息体逐字节一致") + void recoveryReusesFrozenEnvelopeVerbatim() throws Exception { + FlwStateEvent fact = frozenFact(); + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)) + .thenReturn(Arrays.asList(reg(7L, null))); + + dispatchFact(fact); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwCallbackLog.class); + verify(logStore).persist(captor.capture()); + assertThat(captor.getValue().getPayloadJson()) + .as("恢复链必须原样复用冻结体(不可变)").isEqualTo(fact.getPayloadJson()); + assertThat(fact.getDispatched()).isEqualTo(1); + } + + @Test + @DisplayName("恢复链(R2 前存量事实行,消息体缺 eventId/eventSeq/flowGuid):按列值幂等补齐后投出") + void recoveryRepairsLegacyFactBody() throws Exception { + // 构造修复前的存量形态:载荷=老 basePayload(无 eventId/eventSeq/flowGuid) + Map legacyBody = new HashMap<>(); + legacyBody.put("event", "flow.completed"); + legacyBody.put("schId", SCH); + legacyBody.put("instanceId", 900L); + legacyBody.put("bizType", "svc_item"); + legacyBody.put("bizId", "r2-42"); + legacyBody.put("occurredTime", "2026-10-25 00:00:00"); + FlwStateEvent fact = frozenFact(); + fact.setPayloadJson(mapper.writeValueAsString(legacyBody)); + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)) + .thenReturn(Arrays.asList(reg(7L, null))); + + dispatchFact(fact); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwCallbackLog.class); + verify(logStore).persist(captor.capture()); + JsonNode body = mapper.readTree(captor.getValue().getPayloadJson()); + assertThat(body.path("eventId").asText()).isEqualTo(fact.getEventId()); + assertThat(body.path("eventSeq").asInt()).isEqualTo(fact.getEventSeq()); + assertThat(body.path("flowGuid").asText()).isEqualTo(FLOW_GUID); + assertThat(fact.getDispatched()).isEqualTo(1); + } + + // ==================== 恢复消息实际到达真实 Receiver ==================== + + @Test + @DisplayName("恢复消息到达真实 FlowCallbackReceiver:事件行落库、Handler 推进、eventId 幂等") + void recoveryMessageReachesRealReceiver() throws Exception { + FlwStateEvent fact = frozenFact(); + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)) + .thenReturn(Arrays.asList(reg(7L, null))); + dispatchFact(fact); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwCallbackLog.class); + verify(logStore).persist(captor.capture()); + String recoveryBody = captor.getValue().getPayloadJson(); + + CapturingStore store = new CapturingStore(); + CapturingHandler handler = new CapturingHandler(); + FlowCallbackReceiver receiver = newReceiver(store, handler); + + // Outcome 是 starter 包私有枚举,用名字断言(CONSUMED=接收端 200,RETRY=5xx 重试) + Object outcome = receiver.receive(recoveryBody.getBytes( + java.nio.charset.StandardCharsets.UTF_8)); + assertThat(String.valueOf(outcome)).isEqualTo("CONSUMED"); + assertThat(handler.events).as("恢复消息必须被真实接收端消费推进,而非按脏消息丢弃") + .hasSize(1); + assertThat(handler.events.get(0).getEventId()).isEqualTo(fact.getEventId()); + assertThat(handler.events.get(0).getBizId()).isEqualTo("r2-42"); + assertThat(store.savedEventIds).containsExactly(fact.getEventId()); + + // 同一恢复体重投:eventId 幂等(已消费,不再推进 Handler) + Object dup = receiver.receive(recoveryBody.getBytes( + java.nio.charset.StandardCharsets.UTF_8)); + assertThat(String.valueOf(dup)).isEqualTo("CONSUMED"); + assertThat(handler.events).hasSize(1); + } + + @Test + @DisplayName("正常链消息同样到达真实 Receiver;修复前残缺恢复体被丢弃作反例对照") + void normalMessageReachesRealReceiver_andBrokenBodyDropped() throws Exception { + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)) + .thenReturn(Arrays.asList(reg(7L, null))); + FlwStateEvent fact = frozenFact(); + FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L); + event.assignStableIdentity(fact.getEventId(), fact.getEventSeq()); + event.assignStablePayload(fact.getPayloadJson()); + dispatcher.onFlowEvent(event); + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwCallbackLog.class); + verify(logStore).persist(captor.capture()); + String normalBody = captor.getValue().getPayloadJson(); + + CapturingStore store = new CapturingStore(); + CapturingHandler handler = new CapturingHandler(); + FlowCallbackReceiver receiver = newReceiver(store, handler); + Object consumed = receiver.receive(normalBody.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertThat(String.valueOf(consumed)).isEqualTo("CONSUMED"); + assertThat(handler.events).hasSize(1); + + // 反例:R2 修复前的恢复消息体(eventId 只在 HTTP 头/列,消息体缺失)→ 真实接收端 + // 按"缺必填字段"脏消息 200 丢弃——这正是 D04 关闭的缺陷形态 + JsonNode broken = mapper.readTree(normalBody); + ((com.fasterxml.jackson.databind.node.ObjectNode) broken).remove("eventId"); + CapturingStore store2 = new CapturingStore(); + CapturingHandler handler2 = new CapturingHandler(); + FlowCallbackReceiver receiver2 = newReceiver(store2, handler2); + Object dropped = receiver2.receive(mapper.writeValueAsBytes(broken)); + assertThat(String.valueOf(dropped)).isEqualTo("CONSUMED"); + assertThat(handler2.events).as("缺 eventId 的消息体不应推进业务").isEmpty(); + assertThat(store2.savedEventIds).isEmpty(); + } + + // ==================== 未可靠建行不记账 ==================== + + @Test + @DisplayName("恢复链任一订阅方建行失败:事实保持 dispatched=0,重试轮补齐后才记账") + void recoveryKeepsUndispatchedOnPartialFailure() throws Exception { + FlwStateEvent fact = frozenFact(); + FlwCallbackReg ok = reg(7L, null); + FlwCallbackReg bad = reg(8L, null); + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)) + .thenReturn(Arrays.asList(ok, bad)); + + // 第一轮:reg=8 建行抛错(模拟持久化失败)→ 不标记 dispatched + when(logStore.persist(any(FlwCallbackLog.class))) + .thenReturn(101L) + .thenThrow(new RuntimeException("db down")); + dispatchFact(fact); + assertThat(fact.getDispatched()).as("部分建行失败不得标记已分发").isEqualTo(0); + + // 第二轮(重扫):reg=7 的行已在(exists=true 跳过),reg=8 补建成功 → 记账 + when(logStore.persist(any(FlwCallbackLog.class))).thenReturn(102L); + when(logRepository.existsByEventIdAndRegId(fact.getEventId(), 7L)).thenReturn(true); + dispatchFact(fact); + assertThat(fact.getDispatched()).isEqualTo(1); + } + + @Test + @DisplayName("正常链建行失败:不标记事实已分发(恢复扫描稍后补建)") + void normalDispatchFailureSkipsDispatchedMarking() throws Exception { + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)) + .thenReturn(Arrays.asList(reg(7L, null))); + FlwStateEvent fact = frozenFact(); + FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L); + event.assignStableIdentity(fact.getEventId(), fact.getEventSeq()); + event.assignStablePayload(fact.getPayloadJson()); + when(stateEventRepository.findByEventId(fact.getEventId())).thenReturn(Optional.of(fact)); + when(logStore.persist(any(FlwCallbackLog.class))).thenThrow(new RuntimeException("db down")); + // frozenFact() 经真实 recorder 落过一次事实(save 已被调用)——清掉夹具期调用, + // 只断言分发失败后不再有"记账 save" + org.mockito.Mockito.clearInvocations(stateEventRepository); + + dispatcher.onFlowEvent(event); + + verify(stateEventRepository, never()).save(any(FlwStateEvent.class)); + assertThat(fact.getDispatched()).isEqualTo(0); + } + + @Test + @DisplayName("并发撞 uk(event_id, reg_id):DuplicateKeyException=已建行,按幂等成功记账") + void duplicateKeyTreatedAsIdempotentSuccess() throws Exception { + FlwStateEvent fact = frozenFact(); + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)) + .thenReturn(Arrays.asList(reg(7L, null))); + when(logStore.persist(any(FlwCallbackLog.class))) + .thenThrow(new DuplicateKeyException("uk_flw_log_event_reg")); + + dispatchFact(fact); + + assertThat(fact.getDispatched()).as("uk 兜底=另一路径已建行,视为全部建行").isEqualTo(1); + } + + @Test + @DisplayName("两个订阅方订阅同一事件:各建一行(同 eventId 不同 regId),全部成功才记账") + void twoSubscribersEachGetDeliveryRow() throws Exception { + FlwStateEvent fact = frozenFact(); + when(regRepository.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)) + .thenReturn(Arrays.asList(reg(7L, null), reg(8L, "flow.completed,flow.rejected"))); + + dispatchFact(fact); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwCallbackLog.class); + verify(logStore, times(2)).persist(captor.capture()); + assertThat(captor.getAllValues().get(0).getRegId()).isEqualTo(7L); + assertThat(captor.getAllValues().get(1).getRegId()).isEqualTo(8L); + assertThat(captor.getAllValues().get(0).getEventId()) + .isEqualTo(captor.getAllValues().get(1).getEventId()); + assertThat(fact.getDispatched()).isEqualTo(1); + } + + // ==================== 真实 Receiver 装配夹具 ==================== + + private FlowCallbackReceiver newReceiver(CapturingStore store, CapturingHandler handler) { + return new FlowCallbackReceiver(store, handler, + new TransactionTemplate(NOOP_TX_MANAGER), mapper, new FlowCallbackProperties()); + } + + private static final PlatformTransactionManager NOOP_TX_MANAGER = new PlatformTransactionManager() { + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) { + return new SimpleTransactionStatus(); + } + + @Override + public void commit(TransactionStatus status) { + } + + @Override + public void rollback(TransactionStatus status) { + } + }; + + /** 事件留痕/幂等存储内存实现(同事务语义由 noop 事务管理器退化,DupKey 语义保留) */ + private static class CapturingStore implements FlowCallbackEventStore { + final List savedEventIds = new ArrayList<>(); + + @Override + public boolean existsByEventId(String eventId) { + return savedEventIds.contains(eventId); + } + + @Override + public void save(FlowCallbackEvent event, String payloadJson) { + if (savedEventIds.contains(event.getEventId())) { + throw new DuplicateKeyException("uk_event_id"); + } + savedEventIds.add(event.getEventId()); + } + + @Override + public Integer maxInstanceVersion(String bizType, String bizId, String excludeEventId) { + return null; + } + } + + private static class CapturingHandler implements FlowCallbackHandler { + final List events = new ArrayList<>(); + + @Override + public void onEvent(FlowCallbackEvent event) { + events.add(event); + } + } +} diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java new file mode 100644 index 0000000..5dcebcc --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java @@ -0,0 +1,36 @@ +package com.yunhai.flowcenter.callback; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.yunhai.flowcenter.entity.*; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.service.FlowSourcePrincipalResolver; +import org.junit.jupiter.api.Test; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CallbackSourcePolicyTest { + @Test void selfPolicyRejectsForeignSubscriptionsReusedCredentialsAndCorruptEnvelopes() throws Exception { + String source="11111111-1111-4111-8111-111111111111"; + ObjectMapper mapper=new ObjectMapper();FlwInstanceRepository instances=mock(FlwInstanceRepository.class); + FlowSourcePrincipalResolver resolver=mock(FlowSourcePrincipalResolver.class); + CallbackSourcePolicy policy=new CallbackSourcePolicy(resolver,instances,mapper); + FlwInstance instance=new FlwInstance();instance.setId(7L);instance.setSchId(1L);instance.setBizType("svc-hall");instance.setBizId("7"); + instance.setDefVersion(1);instance.setSourcePrincipalId(source);instance.setApplyRequestToken("r");instance.setApplyRound(2);instance.setApplyPayloadHash("hash"); + when(instances.findById(7L)).thenReturn(Optional.of(instance)); + FlwCallbackReg reg=new FlwCallbackReg();reg.setAppKey("100018");reg.setSourcePrincipalId(source); + when(resolver.resolve("100018")).thenReturn(new FlowSourcePrincipalResolver.Principal(source,18,"100018","svc-hall")); + String json="{\"eventId\":\"e\",\"event\":\"flow.completed\",\"sourcePrincipalId\":\""+source+"\",\"applyRound\":2,\"instanceId\":7,\"schId\":1,\"bizType\":\"svc-hall\",\"bizId\":\"7\",\"data\":{\"defVersion\":1,\"sourcePrincipalId\":\""+source+"\",\"requestId\":\"r\",\"canonicalHash\":\"hash\",\"applyRound\":2}}"; + assertTrue(policy.permits(reg,7L,json)); + for(String field:new String[]{"sourcePrincipalId","requestId","canonicalHash","applyRound"}){ + ObjectNode corrupt=(ObjectNode)mapper.readTree(json);((ObjectNode)corrupt.get("data")).put(field,"wrong"); + assertFalse(policy.permits(reg,7L,mapper.writeValueAsString(corrupt)),field); + } + ObjectNode corrupt=(ObjectNode)mapper.readTree(json);corrupt.put("sourcePrincipalId","foreign");assertFalse(policy.permits(reg,7L,corrupt.toString())); + FlwCallbackLog delivery=new FlwCallbackLog();delivery.setInstanceId(7L);delivery.setPayloadJson(json);delivery.setEventId("other");delivery.setEvent("flow.completed");delivery.setBizType("svc-hall");delivery.setBizId("7"); + assertFalse(policy.permits(reg,delivery));delivery.setEventId("e");assertTrue(policy.permits(reg,delivery)); + reg.setSourcePrincipalId("foreign");assertFalse(policy.matches(reg,instance));reg.setSourcePrincipalId(source); + when(resolver.resolve("100018")).thenReturn(new FlowSourcePrincipalResolver.Principal("replacement",18,"100018","svc-hall")); + assertFalse(policy.matches(reg,instance)); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/callback/FlStateEventRecoveryTest.java b/src/test/java/com/yunhai/flowcenter/callback/FlStateEventRecoveryTest.java new file mode 100644 index 0000000..4a247fd --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/callback/FlStateEventRecoveryTest.java @@ -0,0 +1,192 @@ +package com.yunhai.flowcenter.callback; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowEvent; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwStateEvent; +import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwStateEventRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 状态事件事实(T05;2026-10-25 R2 返修增补)规则单测: + * + *
    + *
  • 事实与状态同事务落库(同步监听器语义)并为事件赋稳定身份;
  • + *
  • eventId 全链复用(事实行=投递行=重试);同事实重复发布不重复落行;
  • + *
  • 同事务事实写失败=状态事务一并失败(不吞);
  • + *
  • URGE 站内信不入事件流;
  • + *
  • R2:事实载荷=完整冻结信封(含 eventId/eventSeq/flowGuid),并随身份冻结到事件对象—— + * 正常分发与崩溃恢复共用同一冻结体。
  • + *
+ */ +@DisplayName("状态事件事实 FlwStateEventRecorder(T05/R2)") +class FlStateEventRecoveryTest { + + private FlwStateEventRepository eventRepository; + private FlwInstanceRepository instanceRepository; + private FlwDefRepository defRepository; + private FlwStateEventRecorder recorder; + + @BeforeEach + void setUp() { + eventRepository = mock(FlwStateEventRepository.class); + instanceRepository = mock(FlwInstanceRepository.class); + defRepository = mock(FlwDefRepository.class); + recorder = new FlwStateEventRecorder(eventRepository, instanceRepository, defRepository, + new ObjectMapper()); + when(eventRepository.save(any())).thenAnswer(c -> { + FlwStateEvent e = c.getArgument(0); + e.setId(1L); + return e; + }); + + FlwInstance instance = new FlwInstance(); + instance.setId(900L); + instance.setSchId(27L); + instance.setDefId(55L); + instance.setApplyRound(2); + instance.setBizType("svc-hall"); + instance.setBizId("42"); + when(instanceRepository.findById(900L)).thenReturn(Optional.of(instance)); + when(instanceRepository.nextEventSeqForUpdate(900L)).thenReturn(5); + when(instanceRepository.setEventSeqCounter(Mockito.eq(900L), Mockito.anyInt())).thenReturn(1); + + FlwDef def = new FlwDef(); + def.setId(55L); + def.setFlowGuid("fg-r2-envelope"); + when(defRepository.findById(55L)).thenReturn(Optional.of(def)); + } + + @Test + @DisplayName("同步监听:事实落库并赋稳定身份(eventId+实例内 seq=max+1)") + void recordAssignsStableIdentity() { + FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, 900L); + + recorder.record(event); + + Mockito.verify(eventRepository).save(any(FlwStateEvent.class)); + assertThat(event.getStableEventId()).isNotBlank(); + assertThat(event.getStableEventSeq()).isEqualTo(5); + } + + @Test + @DisplayName("同事件对象重复发布:不重复落事实、身份不变") + void duplicatePublishKeepsIdentity() { + FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L); + recorder.record(event); + String first = event.getStableEventId(); + + recorder.record(event); + + Mockito.verify(eventRepository, Mockito.times(1)).save(any(FlwStateEvent.class)); + assertThat(event.getStableEventId()).isEqualTo(first); + } + + @Test + @DisplayName("URGE 站内信:不入事件流(既有口径不变)") + void urgeSkipped() { + recorder.record(FlowEvent.of(FlowEvent.Type.URGE, 900L)); + Mockito.verify(eventRepository, never()).save(any(FlwStateEvent.class)); + } + + @Test + @DisplayName("事实载荷快照含轮次身份(旧事件不被 AFTER_COMMIT 重读贴新轮标签)") + void payloadCarriesRoundIdentity() throws Exception { + FlowEvent event = FlowEvent.of(FlowEvent.Type.REJECTED, 900L); + event.captureRoundIdentity(2, "tok-1"); + recorder.record(event); + Mockito.verify(eventRepository).save(Mockito.argThat(f -> { + try { + com.fasterxml.jackson.databind.JsonNode payload = + new ObjectMapper().readTree(f.getPayloadJson()); + return payload.path("applyRound").asInt() == 2 + && "tok-1".equals(payload.path("data").path("requestToken").asText()); + } catch (Exception e) { + return false; + } + })); + } + + @Test + @DisplayName("R2:事实载荷=完整冻结信封——eventId/eventSeq/flowGuid/接收端必填四件套全部在体") + void payloadIsFullFrozenEnvelope() throws Exception { + FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, 900L); + recorder.record(event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwStateEvent.class); + verify(eventRepository).save(captor.capture()); + com.fasterxml.jackson.databind.JsonNode payload = + new ObjectMapper().readTree(captor.getValue().getPayloadJson()); + // 接收端 FlowCallbackReceiver 必填:eventId/event/bizType/bizId(缺一按脏消息 200 丢弃) + assertThat(payload.path("eventId").asText()).isEqualTo(event.getStableEventId()); + assertThat(payload.path("event").asText()).isEqualTo("flow.submitted"); + assertThat(payload.path("bizType").asText()).isEqualTo("svc-hall"); + assertThat(payload.path("bizId").asText()).isEqualTo("42"); + // R2 新冻结字段:稳定序号 + flowGuid(原实现只在 HTTP 头/列里,消息体缺失) + assertThat(payload.path("eventSeq").asInt()).isEqualTo(5); + assertThat(payload.path("flowGuid").asText()).isEqualTo("fg-r2-envelope"); + // 既有信封字段不回退 + assertThat(payload.has("instanceId")).isTrue(); + assertThat(payload.has("instanceVersion")).isTrue(); + assertThat(payload.has("occurredTime")).isTrue(); + } + + @Test + @DisplayName("R2:冻结信封随身份一并赋给事件对象——分发器正常链直接复用冻结体(与恢复链同构)") + void frozenEnvelopeAssignedToEvent() throws Exception { + FlowEvent event = FlowEvent.of(FlowEvent.Type.COMPLETED, 900L); + recorder.record(event); + + assertThat(event.getStablePayloadJson()).isNotBlank(); + com.fasterxml.jackson.databind.JsonNode payload = + new ObjectMapper().readTree(event.getStablePayloadJson()); + assertThat(payload.path("eventId").asText()).isEqualTo(event.getStableEventId()); + assertThat(payload.path("flowGuid").asText()).isEqualTo("fg-r2-envelope"); + + // 重复发布不换冻结体(幂等) + String first = event.getStablePayloadJson(); + recorder.record(event); + assertThat(event.getStablePayloadJson()).isEqualTo(first); + } + + @Test + @DisplayName("R2:定义已删(def 缺席)——信封不带 flowGuid,不炸记录器(该场合分发器同样不投递)") + void missingDefStillRecordsWithoutFlowGuid() throws Exception { + FlwInstance instance = new FlwInstance(); + instance.setId(901L); + instance.setSchId(27L); + instance.setDefId(404L); + instance.setBizType("svc-hall"); + instance.setBizId("43"); + when(instanceRepository.findById(901L)).thenReturn(Optional.of(instance)); + when(defRepository.findById(404L)).thenReturn(Optional.empty()); + when(instanceRepository.nextEventSeqForUpdate(901L)).thenReturn(1); + when(instanceRepository.setEventSeqCounter(Mockito.eq(901L), Mockito.anyInt())).thenReturn(1); + + FlowEvent event = FlowEvent.of(FlowEvent.Type.SUBMITTED, 901L); + recorder.record(event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FlwStateEvent.class); + verify(eventRepository).save(captor.capture()); + com.fasterxml.jackson.databind.JsonNode payload = + new ObjectMapper().readTree(captor.getValue().getPayloadJson()); + assertThat(payload.has("flowGuid")).isFalse(); + assertThat(payload.path("eventId").asText()).isEqualTo(event.getStableEventId()); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/engine/AssigneeResolverEligibilityTest.java b/src/test/java/com/yunhai/flowcenter/engine/AssigneeResolverEligibilityTest.java new file mode 100644 index 0000000..5b3271e --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/engine/AssigneeResolverEligibilityTest.java @@ -0,0 +1,228 @@ +package com.yunhai.flowcenter.engine; + +import com.yunhai.flowcenter.entity.FlwDeptMember; +import com.yunhai.flowcenter.service.FlowDeptAdminService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiUserClient; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** D11 人员资格终审:存在/启用/校域三查 + 部门成员逐人核验 + NOTIFY 宽容口径。 */ +@DisplayName("D11 人员资格终审(AssigneeResolver)") +class AssigneeResolverEligibilityTest { + + private static final long SCH = 1L; + + @Test + @DisplayName("固定 USERS 全部合法:逐人返回姓名快照") + void fixedUsersAllEligiblePasses() { + AssigneeResolver resolver = resolver(userClient(101, 1, 1, 102, 1, 1), mock(FlowDeptAdminService.class)); + List refs = resolver.resolve(SCH, usersNode(101, 102)); + assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101, 102); + assertThat(refs).extracting(AssigneeRef::getUserName).containsExactly("用户101", "用户102"); + } + + @Test + @DisplayName("固定 USERS 含已停用人员:整体拒绝,不返回部分合法结果") + void fixedUsersAnyDisabledRejectsWholeConfig() { + AssigneeResolver resolver = resolver(userClient(101, 1, 1, 9001, 0, 1), mock(FlowDeptAdminService.class)); + assertThatThrownBy(() -> resolver.resolve(SCH, usersNode(101, 9001))) + .isInstanceOf(BizException.class) + .hasMessageContaining("禁用") + .hasMessageContaining("9001"); + } + + @Test + @DisplayName("固定 USERS 含外校人员:整体拒绝") + void fixedUsersAnyCrossSchoolRejectsWholeConfig() { + AssigneeResolver resolver = resolver(userClient(101, 1, 1, 9002, 1, 2), mock(FlowDeptAdminService.class)); + assertThatThrownBy(() -> resolver.resolve(SCH, usersNode(101, 9002))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于该学校"); + } + + @Test + @DisplayName("固定 USERS 含不存在人员:整体拒绝") + void fixedUsersAnyMissingRejectsWholeConfig() { + OpenApiUserClient client = userClient(101, 1, 1); + when(client.getUserById("9999")).thenReturn(null); + AssigneeResolver resolver = resolver(client, mock(FlowDeptAdminService.class)); + assertThatThrownBy(() -> resolver.resolve(SCH, usersNode(101, 9999))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不存在"); + } + + @Test + @DisplayName("启动前固定人预检:扫描全部审批节点,后续节点非法也整体拒绝") + void fixedPreflightScansEveryApprovalNode() { + AssigneeResolver resolver = resolver(userClient(101, 1, 1, 9002, 1, 2), mock(FlowDeptAdminService.class)); + FlowDefModel model = new FlowDefModel(); + FlowDefModel.Node first = usersNode(101); + first.id = "n1"; + FlowDefModel.Node second = usersNode(9002); + second.id = "n2"; + model.nodes = Arrays.asList(first, second); + model.edges = new ArrayList<>(); + assertThatThrownBy(() -> resolver.requireEligibleFixedAssignees(SCH, model)) + .isInstanceOf(BizException.class) + .hasMessageContaining("n2") + .hasMessageContaining("不属于该学校"); + } + + @Test + @DisplayName("部门成员:合法保留、停用/外校剔除(科室启用不等于成员合格)") + void deptMembersAreCheckedOneByOne() { + FlowDeptAdminService dept = mock(FlowDeptAdminService.class); + when(dept.resolveEnabled(SCH, "d1")).thenReturn(Arrays.asList( + member(101, "甲"), member(9001, "停用"), member(9002, "外校"))); + AssigneeResolver resolver = resolver(userClient(101, 1, 1, 9001, 0, 1, 9002, 1, 2), dept); + + List refs = resolver.resolve(SCH, deptNode("d1")); + assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101); + } + + @Test + @DisplayName("部门成员全部不合格:明确拒绝,不产生无人可办的任务") + void deptAllIneligibleRejects() { + FlowDeptAdminService dept = mock(FlowDeptAdminService.class); + when(dept.resolveEnabled(SCH, "d1")).thenReturn(Collections.singletonList(member(9001, "停用"))); + AssigneeResolver resolver = resolver(userClient(9001, 0, 1), dept); + + assertThatThrownBy(() -> resolver.resolve(SCH, deptNode("d1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("均不具备办理资格"); + } + + @Test + @DisplayName("部门解析为空:既有“无可用办理人”错误") + void deptEmptyRejects() { + FlowDeptAdminService dept = mock(FlowDeptAdminService.class); + when(dept.resolveEnabled(SCH, "d1")).thenReturn(Collections.emptyList()); + AssigneeResolver resolver = resolver(userClient(101, 1, 1), dept); + + assertThatThrownBy(() -> resolver.resolve(SCH, deptNode("d1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("无可用办理人"); + } + + @Test + @DisplayName("UserBase 不可达:不可判定按失败传播,不降级成“成员不合格”") + void deptUserBaseUnavailablePropagates() { + FlowDeptAdminService dept = mock(FlowDeptAdminService.class); + when(dept.resolveEnabled(SCH, "d1")).thenReturn(Collections.singletonList(member(101, "甲"))); + OpenApiUserClient client = mock(OpenApiUserClient.class); + when(client.getUserById(anyString())).thenThrow(new IllegalStateException("down")); + AssigneeResolver resolver = new AssigneeResolver(dept, client); + + assertThatThrownBy(() -> resolver.resolve(SCH, deptNode("d1"))) + .isInstanceOf(BizException.class) + .satisfies(e -> assertThat(((BizException) e).getErrorCode()) + .isEqualTo(ErrorCode.INTERNAL_SERVER_ERROR)); + } + + @Test + @DisplayName("NOTIFY receivers 保持宽容:停用/外校/不存在均不阻塞,姓名可空") + void notifyReceiversStayTolerant() { + OpenApiUserClient client = userClient(9001, 0, 1, 9002, 1, 2); + when(client.getUserById("9999")).thenReturn(null); + AssigneeResolver resolver = new AssigneeResolver(mock(FlowDeptAdminService.class), client); + + FlowDefModel.Node notify = new FlowDefModel.Node(); + notify.id = "cc1"; + notify.type = FlowDefModel.TYPE_NOTIFY; + notify.receivers = new FlowDefModel.Assignee(); + notify.receivers.type = FlowDefModel.ASSIGNEE_USERS; + notify.receivers.userIds = Arrays.asList(9001, 9002, 9999); + + List refs = resolver.resolveTolerant(SCH, notify); + assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(9001, 9002, 9999); + assertThat(refs.get(2).getUserName()).isEmpty(); + } + + @Test + @DisplayName("人员无学校归属:fail-closed 拒绝") + void noSchoolRejected() { + AssigneeResolver resolver = resolver(userClient(101, 1, null), mock(FlowDeptAdminService.class)); + assertThatThrownBy(() -> resolver.requireEligibleAssignee(SCH, 101)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于该学校"); + } + + @Test + @DisplayName("人员不存在:拒绝") + void missingUserRejected() { + OpenApiUserClient client = mock(OpenApiUserClient.class); + when(client.getUserById(anyString())).thenReturn(null); + AssigneeResolver resolver = new AssigneeResolver(mock(FlowDeptAdminService.class), client); + assertThatThrownBy(() -> resolver.requireEligibleAssignee(SCH, 9999)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不存在"); + } + + // ==================== fixtures ==================== + + private static AssigneeResolver resolver(OpenApiUserClient client, FlowDeptAdminService dept) { + return new AssigneeResolver(dept, client); + } + + /** userClient(101,1,1, 9001,0,1) = 三元组(userId,status,schoolId),schoolId=null 表示无学校。 */ + private static OpenApiUserClient userClient(Object... triples) { + OpenApiUserClient client = mock(OpenApiUserClient.class); + when(client.getUserById(anyString())).thenAnswer(inv -> { + int id = Integer.parseInt(inv.getArgument(0)); + for (int i = 0; i < triples.length; i += 3) { + if ((Integer) triples[i] == id) { + BoaUser user = new BoaUser(id, "user" + id, "用户" + id); + user.setStatus((Integer) triples[i + 1]); + Integer school = (Integer) triples[i + 2]; + user.setSchool(school == null ? null : new BoaGroup(school, "学校" + school)); + return user; + } + } + return null; + }); + return client; + } + + private static FlowDefModel.Node usersNode(Integer... userIds) { + FlowDefModel.Node node = new FlowDefModel.Node(); + node.id = "n1"; + node.type = FlowDefModel.TYPE_APPROVAL; + node.assignee = new FlowDefModel.Assignee(); + node.assignee.type = FlowDefModel.ASSIGNEE_USERS; + node.assignee.userIds = Arrays.asList(userIds); + return node; + } + + private static FlowDefModel.Node deptNode(String deptKey) { + FlowDefModel.Node node = new FlowDefModel.Node(); + node.id = "n1"; + node.type = FlowDefModel.TYPE_APPROVAL; + node.assignee = new FlowDefModel.Assignee(); + node.assignee.type = FlowDefModel.ASSIGNEE_DEPT_KEY; + node.assignee.deptKey = deptKey; + return node; + } + + private static FlwDeptMember member(Integer userId, String userName) { + FlwDeptMember member = new FlwDeptMember(); + member.setUserId(userId); + member.setUserName(userName); + member.setStatus(0); + return member; + } +} 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/FlAdvanceIntentRecoveryTest.java b/src/test/java/com/yunhai/flowcenter/engine/FlAdvanceIntentRecoveryTest.java new file mode 100644 index 0000000..92e6637 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/engine/FlAdvanceIntentRecoveryTest.java @@ -0,0 +1,134 @@ +package com.yunhai.flowcenter.engine; + +import com.yunhai.flowcenter.entity.*; +import com.yunhai.flowcenter.repository.*; +import org.junit.jupiter.api.*; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** The six original recovery guarantees remain asserted against the real settlement service. */ +class FlAdvanceIntentRecoveryTest { + FlowAdvanceService advanceService; + FlwAdvanceIntentRepository intents; + FlwInstanceRepository instances; + FlwTaskRepository tasks; + FlowEngine engine; + FlowNodeAdvanceListener listener; + FlwAdvanceIntent head; + FlwInstance instance; + FlwTask trigger; + + @BeforeEach void setup() { + intents=mock(FlwAdvanceIntentRepository.class);instances=mock(FlwInstanceRepository.class); + tasks=mock(FlwTaskRepository.class);engine=mock(FlowEngine.class); + FlwDefRepository definitions=mock(FlwDefRepository.class); + advanceService=new FlowAdvanceService(instances,definitions,tasks,engine,intents); + ReflectionTestUtils.setField(advanceService,"entityManager",mock(javax.persistence.EntityManager.class)); + listener=new FlowNodeAdvanceListener(advanceService,intents); + head=new FlwAdvanceIntent();head.setId(1L);head.setInstanceId(900L);head.setNodeId("n1"); + head.setActivationTaskId(33L);head.addTask(33L);head.addTask(34L);head.addHandledTask(33L); + head.setTriggerTaskId(33L);head.setActivationDefId(7L);head.setActivationDefVersion(1); + instance=new FlwInstance();instance.setId(900L);instance.setStatus(10);instance.setCurrentNodes("n1");instance.setDefId(7L);instance.setDefVersion(1); + trigger=new FlwTask();trigger.setId(33L);trigger.setInstanceId(900L);trigger.setNodeId("n1"); + trigger.setStatus(1);trigger.setEdgeName("agree");trigger.setAssigneeId(201);trigger.setAssigneeName("A"); + when(intents.save(any())).thenAnswer(inv->inv.getArgument(0)); + when(intents.findByIdForUpdate(1L)).thenReturn(Optional.of(head)); + when(intents.findByInstanceIdAndNodeIdOrderByIdDesc(900L,"n1")).thenReturn(Collections.singletonList(head)); + when(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(900L,"n1")).thenReturn(Optional.of(head)); + when(intents.findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(anyString(),any())).thenReturn(Collections.singletonList(head)); + when(instances.findById(900L)).thenReturn(Optional.of(instance)); + when(definitions.findById(7L)).thenReturn(Optional.of(new FlwDef())); + when(tasks.findById(33L)).thenReturn(Optional.of(trigger)); + when(tasks.findByInstanceIdAndStatus(900L,0)).thenReturn(Collections.emptyList()); + doAnswer(inv->{instance.setCurrentNodes("n2");return null;}).when(engine).continueAdvance(any(),any(),anyString(),any(),any()); + } + + @Test void persistIntentCapturesAdvanceWork() { + head.setHandledTaskIds(null);head.setTriggerTaskId(null); + listener.persistIntent(event(33L,"agree",201)); + assertThat(head.getEdgeName()).isEqualTo("agree");assertThat(head.getAssigneeId()).isEqualTo(201); + assertThat(head.getInstanceId()).isEqualTo(900L);assertThat(head.getNodeId()).isEqualTo("n1"); + assertThat(head.getTriggerTaskId()).isEqualTo(33L);assertThat(head.handledTask(33L)).isTrue(); + verify(intents).save(head); + } + + @Test void duplicateIntentSuppressed() { + head.setEdgeName("latest");listener.persistIntent(event(33L,"old duplicate",202)); + assertThat(head.getEdgeName()).isEqualTo("latest"); + verify(intents,never()).save(any()); + } + + @Test void successfulAdvanceMarksIntentsDone() { + listener.onTaskHandled(event(33L,"agree",201)); + assertThat(head.getStatus()).isEqualTo("DONE"); + verify(instances).saveAndFlush(instance); + } + + @Test void exhaustedRetriesDeferInsteadOfDropping() { + conflict();listener.onTaskHandled(event(33L,"agree",201)); + assertThat(head.getStatus()).isEqualTo("PENDING");assertThat(head.getAttempts()).isEqualTo(1); + assertThat(head.getNextRetryAt()).isAfter(LocalDateTime.now());assertThat(head.getLastError()).contains("sweep"); + verify(engine,times(3)).continueAdvance(any(),any(),anyString(),any(),any()); + } + + @Test void sweepExecutesAndExhausts() { + conflict();for(int i=0;i<5;i++)listener.sweepIntents(); + assertThat(head.getStatus()).isEqualTo("PENDING");assertThat(head.getAttempts()).isEqualTo(5); + listener.sweepIntents();assertThat(head.getStatus()).isEqualTo("EXHAUSTED");assertThat(head.getAttempts()).isEqualTo(6); + verify(engine,times(6)).continueAdvance(any(),any(),anyString(),any(),any()); + } + + @Test void sweepRecoversPendingIntent() { + listener.sweepIntents();assertThat(head.getStatus()).isEqualTo("DONE");assertThat(head.getLastError()).isNull(); + } + + @Test void w_lastHandlerWinsEvenWhenItsTaskIdIsSmaller() { + head.setHandledTaskIds(null); + listener.persistIntent(event(34L,"first",202));listener.persistIntent(event(33L,"last",201)); + trigger.setEdgeName("last");listener.sweepIntents(); + assertThat(head.getActivationTaskId()).isEqualTo(33L);assertThat(head.getTriggerTaskId()).isEqualTo(33L); + verify(engine).continueAdvance(eq(instance),any(),eq("n1"),eq("last"),argThat(op->op.getUserId()==201)); + } + + @Test void v_oldActivationCannotConsumeOrAdvanceItsSuccessor() { + FlwAdvanceIntent next=new FlwAdvanceIntent();next.setId(2L);next.setTriggerTaskId(99L); + when(intents.findByIdForUpdate(2L)).thenReturn(Optional.of(next)); + when(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(900L,"n1")).thenReturn(Optional.of(next)); + listener.sweepIntents();assertThat(head.getStatus()).isEqualTo("DONE");assertThat(head.getLastError()).contains("STALE_ACTIVATION"); + assertThat(next.getStatus()).isEqualTo("PENDING");assertThat(instance.getCurrentNodes()).isEqualTo("n1"); + verify(engine,never()).continueAdvance(any(),any(),anyString(),any(),any()); + } + + @Test void staleRevisionCannotAdvanceDeferOrOverwriteRefreshedIntent() { + head.setRevision(2L);head.setEdgeName("fresh"); + assertThat(advanceService.tryAdvance(1L,1L)).isFalse();advanceService.defer(1L,1L,"old failure"); + assertThat(head.getEdgeName()).isEqualTo("fresh");assertThat(head.getStatus()).isEqualTo("PENDING"); + assertThat(head.getAttempts()).isZero();verify(intents,never()).save(any()); + } + + @Test void unanchoredLegacyWorkIsHeldForAuditedBackfillNotSilentlyConsumed() { + head.setActivationTaskId(null); + when(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(900L,"n1")).thenReturn(Optional.empty()); + listener.sweepIntents();assertThat(head.getStatus()).isEqualTo("EXHAUSTED");assertThat(head.getLastError()).contains("UNANCHORED"); + assertThat(instance.getCurrentNodes()).isEqualTo("n1");verify(engine,never()).continueAdvance(any(),any(),anyString(),any(),any()); + } + + @Test void missingMembershipRollsBackHandlingInsteadOfGuessingANewGeneration() { + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class,()->listener.persistIntent(event(99L,"x",201))); + verify(intents,never()).save(any()); + } + + private void conflict() { + doThrow(new ObjectOptimisticLockingFailureException(FlwAdvanceIntent.class,1L)).when(engine).continueAdvance(any(),any(),anyString(),any(),any()); + } + private FlowEvent event(Long task,String edge,Integer user) { + Map data=new HashMap<>();data.put("nodeId","n1");data.put("edgeName",edge);data.put("assigneeId",user);data.put("assigneeName","A"); + return FlowEvent.of(FlowEvent.Type.TASK_HANDLED,900L,task,data); + } +} 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 f112b8b..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,6 +47,9 @@ class OapiFlowTasksByBizTest extends FlowTestSupport { @Autowired private OapiFlowService flowService; + @Autowired + private FlowTaskService taskService; + private OapiSignedClient client; @Autowired @@ -143,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/D11AssigneeEligibilityIT.java b/src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java new file mode 100644 index 0000000..2f886d1 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java @@ -0,0 +1,453 @@ +package com.yunhai.flowcenter.service; + +import com.yunhai.flowcenter.entity.FlwCc; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwTask; +import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.model.OapiModels; +import com.yunhai.flowcenter.stub.StubUserClient; +import com.yunhai.flowcenter.support.FlowTestSupport; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * D11 人员资格终审业务入口 IT:固定 USERS / 手选指派 / 部门解析 / 移交 / 退回重入, + * 并证明 NOTIFY receivers 的既有宽容口径未被收紧。 + * + *

真实 MySQL 隔离库(同 D09/D10 门禁:8.x / 13429 / fl_d09_20261026);每例清表。

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("D11 人员资格终审(业务入口)") +class D11AssigneeEligibilityIT extends FlowTestSupport { + + @Autowired + private OapiFlowService oapiFlowService; + @Autowired + private FlowTaskService taskService; + @Autowired + private JdbcTemplate jdbc; + @Autowired + private MutableStubUserClient users; + + @org.springframework.boot.test.mock.mockito.MockBean + private org.springframework.scheduling.TaskScheduler controlledScheduler; + + @TestConfiguration + static class StubConfig { + @Bean + @Primary + public MutableStubUserClient stubUserClient() { + return new MutableStubUserClient(); + } + } + + /** 可在用例中动态停用某人的 UserBase 桩(模拟流程启动后人员状态变化)。 */ + public static class MutableStubUserClient extends StubUserClient { + private final Set disabled = ConcurrentHashMap.newKeySet(); + + void disable(Integer userId) { + disabled.add(userId); + } + + void reset() { + disabled.clear(); + } + + @Override + public BoaUser getUserById(String userId) { + BoaUser user = super.getUserById(userId); + if (user != null && disabled.contains(Integer.valueOf(userId))) { + user.setStatus(0); + } + return user; + } + + @Override + public BoaUser getUserInfo(int userId) { + BoaUser user = super.getUserInfo(userId); + if (user != null && disabled.contains(userId)) { + user.setStatus(0); + } + return user; + } + } + + @BeforeEach + void setUp() { + assertThat(jdbc.queryForObject("SELECT @@version", String.class)).startsWith("8."); + assertThat(jdbc.queryForObject("SELECT @@port", Integer.class)).isEqualTo(13429); + assertThat(jdbc.queryForObject("SELECT DATABASE()", String.class)).isEqualTo("fl_d09_20261026"); + cleanDb(); + users.reset(); + } + + // ==================== 固定 USERS ==================== + + @Test + @DisplayName("固定 USERS 全部合法:正常建实例与任务") + void fixedUsersAllEligibleCreatesTasks() { + FlwDef def = saveAndPublishDef("d11a", "固定人合法", usersDef(101, 102)); + Long instanceId = apply(def, "d11-fixed-ok").getInstanceId(); + + assertThat(instanceRepository.findById(instanceId).get().getStatus()) + .isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode()); + List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); + assertThat(pending).extracting(FlwTask::getAssigneeId).containsExactlyInAnyOrder(101, 102); + } + + @Test + @DisplayName("固定 USERS 含不存在人员:整体拒绝,零实例零任务") + void fixedUsersMissingRejectsZeroState() { + assertFixedRejected(usersDef(101, 9999), "d11-fixed-missing", "不存在"); + } + + @Test + @DisplayName("固定 USERS 含已停用人员:整体拒绝,零实例零任务") + void fixedUsersDisabledRejectsZeroState() { + assertFixedRejected(usersDef(101, 9001), "d11-fixed-disabled", "禁用"); + } + + @Test + @DisplayName("固定 USERS 含外校人员:整体拒绝,零实例零任务") + void fixedUsersCrossSchoolRejectsZeroState() { + assertFixedRejected(usersDef(101, 9002), "d11-fixed-cross", "不属于该学校"); + } + + @Test + @DisplayName("固定 USERS 合法与非法混排:整体 fail-closed,不静默过滤") + void fixedUsersMixedRejectsWholeConfig() { + assertFixedRejected(usersDef(101, 102, 9001), "d11-fixed-mixed", "禁用"); + assertThat(taskRepository.findAll()).isEmpty(); + } + + @Test + @DisplayName("固定人非法位于后续节点:启动前预检即整体拒绝,不留下卡单") + void fixedUsersInvalidAtLaterNodeRejectedAtStart() { + FlwDef def = saveAndPublishDef("d11b", "后续固定人非法", twoFixedNodesDef(101, 9001)); + assertThatThrownBy(() -> apply(def, "d11-fixed-later")) + .isInstanceOf(BizException.class) + .hasMessageContaining("n2") + .hasMessageContaining("禁用"); + assertZeroState("d11-fixed-later"); + } + + @Test + @DisplayName("v3 受理边界:prepareFormal 对非法固定 USERS 预检拒绝,零实例零任务") + void formalPrepareRejectsFixedUsersBeforeInstanceCreation() { + FlwDef def = saveAndPublishDef("d11k", "正式固定人拒绝", usersDef(101, 9001)); + com.yunhai.flowcenter.model.FormalApplyModels.Request request = + new com.yunhai.flowcenter.model.FormalApplyModels.Request(); + request.setRequestId("req-d11-fixed"); + request.setFlowGuid(def.getFlowGuid()); + request.setBizType("d11"); + request.setBizId("d11-formal-fixed"); + request.setSubmitterId(201); + request.setSubmitterName("提交人"); + FlowSourcePrincipalResolver.Principal source = new FlowSourcePrincipalResolver.Principal( + "11111111-1111-4111-8111-111111111111", 18, "100018", "svc-hall"); + + assertThatThrownBy(() -> oapiFlowService.prepareFormal(SCH, source, request)) + .isInstanceOf(BizException.class) + .hasMessageContaining("9001") + .hasMessageContaining("禁用"); + assertZeroState("d11-formal-fixed"); + } + + // ==================== 手选指派 ==================== + + @Test + @DisplayName("手选合法人员:落快照并只给被指派人建待办") + void manualAssignEligiblePasses() { + mapDept("d11", 101, "甲"); + FlwDef def = saveAndPublishDef("d11c", "手选合法", assignableDeptDef("d11")); + OapiModels.ApplyResultVo result = apply(def, "d11-manual-ok", 101); + + FlwInstance instance = instanceRepository.findById(result.getInstanceId()).get(); + assertThat(instance.getAssignJson()).contains("\"userId\":101"); + List pending = taskRepository.findByInstanceIdAndStatus(result.getInstanceId(), 0); + assertThat(pending).hasSize(1); + assertThat(pending.get(0).getAssigneeId()).isEqualTo(101); + assertThat(pending.get(0).getAssigneeName()).isEqualTo("用户101"); + } + + @Test + @DisplayName("手选已停用/外校/不存在人员:逐人资格终审拒绝,零实例") + void manualAssignIneligibleRejected() { + mapDept("d11", 101, "甲"); + mapDept("d11", 9001, "停用"); + mapDept("d11", 9002, "外校"); + FlwDef def = saveAndPublishDef("d11d", "手选拒绝", assignableDeptDef("d11")); + + assertThatThrownBy(() -> apply(def, "d11-manual-disabled", 9001)) + .isInstanceOf(BizException.class).hasMessageContaining("禁用"); + assertZeroState("d11-manual-disabled"); + assertThatThrownBy(() -> apply(def, "d11-manual-cross", 9002)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于该学校"); + assertZeroState("d11-manual-cross"); + assertThatThrownBy(() -> apply(def, "d11-manual-missing", 9999)) + .isInstanceOf(BizException.class).hasMessageContaining("不存在"); + assertZeroState("d11-manual-missing"); + } + + // ==================== 部门解析 ==================== + + @Test + @DisplayName("部门含非法成员:合法成员保留、非法成员不建任务") + void deptFiltersIneligibleMembers() { + mapDept("d11", 101, "甲"); + mapDept("d11", 9001, "停用"); + mapDept("d11", 9002, "外校"); + FlwDef def = saveAndPublishDef("d11e", "部门过滤", deptDef("d11")); + + Long instanceId = apply(def, "d11-dept-partial").getInstanceId(); + List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); + assertThat(pending).extracting(FlwTask::getAssigneeId).containsExactly(101); + } + + @Test + @DisplayName("部门成员全部不合格:明确拒绝,零实例零任务") + void deptAllIneligibleRejected() { + mapDept("d11", 9001, "停用"); + mapDept("d11", 9002, "外校"); + FlwDef def = saveAndPublishDef("d11f", "部门全非法", deptDef("d11")); + + assertThatThrownBy(() -> apply(def, "d11-dept-all-bad")) + .isInstanceOf(BizException.class) + .hasMessageContaining("均不具备办理资格"); + assertZeroState("d11-dept-all-bad"); + } + + @Test + @DisplayName("部门解析为空:沿用既有“无可用办理人”拒绝") + void deptEmptyRejected() { + FlwDef def = saveAndPublishDef("d11g", "部门为空", deptDef("nobody")); + assertThatThrownBy(() -> apply(def, "d11-dept-empty")) + .isInstanceOf(BizException.class).hasMessageContaining("无可用办理人"); + assertZeroState("d11-dept-empty"); + } + + // ==================== 移交 ==================== + + @Test + @DisplayName("移交目标非法:原任务保持待办、不产生替换待办;合法目标才完成移交") + void transferRejectsIneligibleTargetWithoutHalfState() { + mapDept("d11", 101, "甲"); + FlwDef def = saveAndPublishDef("d11h", "移交终审", deptDef("d11")); + Long instanceId = apply(def, "d11-transfer").getInstanceId(); + FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); + int beforeCount = taskRepository.findByInstanceIdOrderByIdAsc(instanceId).size(); + + for (Integer bad : Arrays.asList(9001, 9002, 9999)) { + assertThatThrownBy(() -> taskService.transfer(SCH, task.getId(), bad, "bad", 101)) + .isInstanceOf(BizException.class); + assertThat(taskRepository.findById(task.getId()).get().getStatus()) + .isEqualTo(FlwEnums.TaskStatus.PENDING.getCode()); + assertThat(taskRepository.findByInstanceIdAndStatus(instanceId, 0)).hasSize(1); + assertThat(taskRepository.findByInstanceIdOrderByIdAsc(instanceId)).hasSize(beforeCount); + assertThat(instanceRepository.findById(instanceId).get().getCurrentNodes()).isEqualTo("n1"); + } + + taskService.transfer(SCH, task.getId(), 102, "合法移交", 101); + assertThat(taskRepository.findById(task.getId()).get().getStatus()) + .isEqualTo(FlwEnums.TaskStatus.TRANSFERRED.getCode()); + List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); + assertThat(pending).hasSize(1); + assertThat(pending.get(0).getAssigneeId()).isEqualTo(102); + } + + // ==================== 退回重入 ==================== + + @Test + @DisplayName("退回目标固定人已停用:退回整体回滚,原任务仍待办") + void returnBackToNowIneligibleFixedUserRollsBack() { + FlwDef def = saveAndPublishDef("d11i", "退回资格终审", + linearThreeFixedDef(101, 102, 101)); + Long instanceId = apply(def, "d11-return").getInstanceId(); + FlwTask n1 = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); + taskService.handle(SCH, n1.getId(), true, "ok", 101); + FlwTask n2 = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); + assertThat(n2.getNodeId()).isEqualTo("n2"); + int beforeCount = taskRepository.findByInstanceIdOrderByIdAsc(instanceId).size(); + users.disable(101); + + assertThatThrownBy(() -> taskService.returnBack(SCH, n2.getId(), "n1", "重审", 102)) + .isInstanceOf(BizException.class).hasMessageContaining("禁用"); + assertThat(taskRepository.findById(n2.getId()).get().getStatus()) + .isEqualTo(FlwEnums.TaskStatus.PENDING.getCode()); + assertThat(taskRepository.findByInstanceIdOrderByIdAsc(instanceId)).hasSize(beforeCount); + assertThat(taskRepository.findByInstanceIdAndStatus(instanceId, 0)).hasSize(1); + assertThat(instanceRepository.findById(instanceId).get().getCurrentNodes()).isEqualTo("n2"); + } + + // ==================== NOTIFY 宽容 ==================== + + @Test + @DisplayName("NOTIFY receivers 停用/外校/不存在:照常落抄送行并推进,不被审批资格校验收紧") + void notifyReceiversRemainTolerant() { + mapDept("d11", 101, "甲"); + FlwDef def = saveAndPublishDef("d11j", "抄送宽容", notifyInvalidUsersDef("d11")); + Long instanceId = apply(def, "d11-notify").getInstanceId(); + + List ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId); + assertThat(ccs).extracting(FlwCc::getUserId).containsExactly(9001, 9002, 9999); + assertThat(ccs.get(2).getUserName()).isEmpty(); + assertThat(instanceRepository.findById(instanceId).get().getCurrentNodes()).isEqualTo("n1"); + assertThat(taskRepository.findByInstanceIdAndStatus(instanceId, 0)).hasSize(1); + } + + // ==================== fixtures ==================== + + private void assertFixedRejected(String contentJson, String bizId, String messagePart) { + FlwDef def = saveAndPublishDef("d11-" + bizId, "固定人拒绝", contentJson); + assertThatThrownBy(() -> apply(def, bizId)) + .isInstanceOf(BizException.class) + .hasMessageContaining(messagePart); + assertZeroState(bizId); + } + + private void assertZeroState(String bizId) { + assertThat(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, "d11", bizId)).isEmpty(); + assertThat(taskRepository.findAll()).isEmpty(); + } + + private OapiModels.ApplyResultVo apply(FlwDef def, String bizId) { + return apply(def, bizId, (Integer[]) null); + } + + private OapiModels.ApplyResultVo apply(FlwDef def, String bizId, Integer... userIds) { + OapiModels.ApplyBody body = new OapiModels.ApplyBody(); + body.setFlowGuid(def.getFlowGuid()); + body.setBizType("d11"); + body.setBizId(bizId); + body.setBizTitle("D11 " + bizId); + body.setSubmitterId(201); + body.setSubmitterName("提交人"); + if (userIds != null && userIds.length > 0) { + OapiModels.AssignItem item = new OapiModels.AssignItem(); + item.setNodeId("n1"); + item.setUserIds(Arrays.asList(userIds)); + body.setAssigns(Collections.singletonList(item)); + } + return oapiFlowService.apply(SCH, null, body); + } + + private String usersDef(Integer... userIds) { + return "{" + + "\"code\":\"d11\",\"name\":\"D11 固定人\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\"," + + "\"assignee\":{\"type\":\"USERS\",\"userIds\":" + ids(userIds) + "},\"countersign\":\"ALL\"}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + private String twoFixedNodesDef(Integer n1User, Integer n2User) { + return "{" + + "\"code\":\"d11\",\"name\":\"D11 后续固定人\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"首审\"," + + "\"assignee\":{\"type\":\"USERS\",\"userIds\":[" + n1User + "]},\"countersign\":\"ALL\"}," + + "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复审\"," + + "\"assignee\":{\"type\":\"USERS\",\"userIds\":[" + n2User + "]},\"countersign\":\"ALL\"}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"n2\"}," + + "{\"from\":\"n2\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + private String linearThreeFixedDef(Integer n1User, Integer n2User, Integer n3User) { + return "{" + + "\"code\":\"d11\",\"name\":\"D11 三节点\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"首审\"," + + "\"assignee\":{\"type\":\"USERS\",\"userIds\":[" + n1User + "]},\"countersign\":\"ALL\"}," + + "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复审\"," + + "\"assignee\":{\"type\":\"USERS\",\"userIds\":[" + n2User + "]},\"countersign\":\"ALL\"}," + + "{\"id\":\"n3\",\"type\":\"APPROVAL\",\"name\":\"终审\"," + + "\"assignee\":{\"type\":\"USERS\",\"userIds\":[" + n3User + "]},\"countersign\":\"ALL\"}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"n2\"}," + + "{\"from\":\"n2\",\"to\":\"n3\"},{\"from\":\"n3\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + private String deptDef(String tag) { + return "{" + + "\"code\":\"d11\",\"name\":\"D11 部门\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\"," + + "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + dk(tag) + "\"},\"countersign\":\"ALL\"}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + private String assignableDeptDef(String tag) { + return "{" + + "\"code\":\"d11\",\"name\":\"D11 手选\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\"," + + "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + dk(tag) + "\"}," + + "\"countersign\":\"ANY\",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":2}}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + private String notifyInvalidUsersDef(String tag) { + return "{" + + "\"code\":\"d11\",\"name\":\"D11 抄送宽容\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"cc1\",\"type\":\"NOTIFY\",\"name\":\"抄送\"," + + "\"receivers\":{\"type\":\"USERS\",\"userIds\":[9001,9002,9999]}}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\"," + + "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + dk(tag) + "\"},\"countersign\":\"ALL\"}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"cc1\"},{\"from\":\"cc1\",\"to\":\"n1\"}," + + "{\"from\":\"n1\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + private static String ids(Integer... userIds) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < userIds.length; i++) { + if (i > 0) sb.append(','); + sb.append(userIds[i]); + } + return sb.append(']').toString(); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlActorContextGateTest.java b/src/test/java/com/yunhai/flowcenter/service/FlActorContextGateTest.java new file mode 100644 index 0000000..2d71401 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlActorContextGateTest.java @@ -0,0 +1,66 @@ +package com.yunhai.flowcenter.service; + +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * SDK 快捷审批 ActorContext 门禁单测(2026-10-25 T07 建立;同日 R1 返修重写): + * 仅凭 appKey/operatorId 不构成用户授权——用户证明(Codex C.2 ActorContext 委托)落地前 + * 快捷审批无条件不放行:不存在放行配置(原 require-user-proof 灰度已废除, + * 未配置/false 同样拒绝)。标准 iframe/CAS 人工办理不经本门。 + */ +@DisplayName("SDK 快捷审批 ActorContext 门禁(T07/R1 fail-closed)") +class FlActorContextGateTest { + + private static void assertRejected(Long schId, Long taskId, Integer operatorId) { + assertThatThrownBy(() -> new FlowActorContextGate().requireActorProof(schId, taskId, operatorId)) + .as("schId=%s taskId=%s operatorId=%s 应被拒绝", schId, taskId, operatorId) + .isInstanceOf(BizException.class) + .hasMessageContaining("快捷审批未放行") + .hasMessageContaining("appKey/operatorId") + .hasMessageContaining("人工办理页"); + } + + @Test + @DisplayName("无配置(默认构造):oapi 审批(自报 operatorId)一律拒绝并指明 CAS 迁移路径") + void unconfiguredGate_rejectsOperatorOnlyApprovals() { + assertRejected(1L, 33L, 201); + } + + @Test + @DisplayName("伪造/任意 operatorId(含真实办理人 ID):同样拒绝——知道办理人 ID 不等于该用户本人在操作") + void forgedOrGenuineOperatorId_bothRejected() { + assertRejected(1L, 33L, 999); // 伪造他人 + assertRejected(1L, 33L, 201); // 即便自报的就是真实 assigneeId + assertRejected(1L, 33L, null); // 缺参不绕过(fail-closed 优先于参数校验) + } + + @Test + @DisplayName("任务/学校不匹配或缺席:拒绝口径一致——门禁先于任务校验,不泄露任务存在性") + void mismatchedTaskOrSchool_stillRejected() { + assertRejected(2L, 404L, 201); // 跨校/不存在任务 + assertRejected(null, null, null); + } + + @Test + @DisplayName("门禁无放行分支:不存在任何入参组合通过(CAS 会话路径不经本门,保持人工办理可用)") + void noPassThroughBranch_exists() { + FlowActorContextGate gate = new FlowActorContextGate(); + long[] schIds = {1L, 2L, 0L}; + Long[] taskIds = {1L, 33L, 999L}; + Integer[] operators = {1, 101, 999, null}; + for (long sch : schIds) { + for (Long task : taskIds) { + for (Integer op : operators) { + assertThatThrownBy(() -> gate.requireActorProof(sch, task, op)) + .as("sch=%s task=%s op=%s", sch, task, op) + .isInstanceOf(BizException.class); + } + } + } + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java b/src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java new file mode 100644 index 0000000..180a7e6 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java @@ -0,0 +1,319 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowDefModel; +import com.yunhai.flowcenter.model.OapiModels; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiUserClient; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * T06 发起终审规则单测:required 指派全覆盖(空 assigns 不绕过)、禁用账号拒绝、 + * EXPRESSION 变量缺失/显式 null 零实例。 + */ +@DisplayName("发起终审:required 指派覆盖 + 禁用人员 + 变量缺失(T06)") +class FlApplyAssignAndVariableTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + // ==================== required 覆盖 ==================== + + @Test + @DisplayName("空 assigns + 存在必选指派节点:全部缺失(空 assigns 不绕过 required)") + void emptyAssignsWithRequiredNodesIsMissing() throws Exception { + FlowDefModel model = FlowDefModel.parse(mapper, defJson(true, false)); + List missing = OapiFlowService.missingRequiredAssignNodes(model, null); + assertThat(missing).containsExactly("n-assign-1"); + assertThat(OapiFlowService.missingRequiredAssignNodes(model, Collections.emptyList())) + .containsExactly("n-assign-1"); + } + + @Test + @DisplayName("提交项只覆盖部分必选节点:缺失的逐一点名(不只遍历请求里出现的项)") + void partialCoverageIsMissing() throws Exception { + FlowDefModel model = FlowDefModel.parse(mapper, defJson(true, true)); + OapiModels.AssignItem onlyFirst = new OapiModels.AssignItem(); + onlyFirst.setNodeId("n-assign-1"); + onlyFirst.setUserIds(java.util.Collections.singletonList(101)); + List missing = OapiFlowService.missingRequiredAssignNodes(model, java.util.Collections.singletonList(onlyFirst)); + assertThat(missing).containsExactly("n-assign-2"); + } + + @Test + @DisplayName("无必选节点 + 空 assigns:放行(既有宽松回退 D3 不变)") + void noRequiredNodesEmptyAssignsPasses() throws Exception { + FlowDefModel model = FlowDefModel.parse(mapper, defJson(false, false)); + assertThat(OapiFlowService.missingRequiredAssignNodes(model, null)).isEmpty(); + } + + // ==================== 变量终审 ==================== + + @Test + @DisplayName("EXPRESSION 引用的业务变量缺失:拒绝(零实例,不等运行期 nil)") + void missingExpressionVariableRejected() throws Exception { + ApplyVariableValidator validator = new ApplyVariableValidator(mapper); + FlowDefModel model = modelWithCondition("flowAmount > 100"); + assertThat(validator.referencedBusinessKeys(model)).containsExactly("flowAmount"); + + assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"other\":1}")) + .isInstanceOf(BizException.class) + .hasMessageContaining("flowAmount"); + } + + @Test + @DisplayName("变量为显式 null:拒绝(缺失键与 null 是同一种'没有值')") + void nullVariableRejected() throws Exception { + ApplyVariableValidator validator = new ApplyVariableValidator(mapper); + FlowDefModel model = modelWithCondition("flowAmount > 100"); + + assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"flowAmount\":null}")) + .isInstanceOf(BizException.class) + .hasMessageContaining("flowAmount"); + } + + @Test + @DisplayName("变量齐备:放行;flow.* 内置键不参与本查") + void presentVariablesPassAndBuiltinsIgnored() throws Exception { + ApplyVariableValidator validator = new ApplyVariableValidator(mapper); + FlowDefModel model = modelWithCondition("flowAmount > 100 && flow.submitterId != null"); + + validator.requireVariablesPresent(model, "{\"flowAmount\":200}"); + validator.requireVariablesPresent(model, null); + } + + // ==================== 禁用人员终审 ==================== + + private FlowDefModel contract(String declarations) { + return FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[],\"variables\":" + declarations + "}"); + } + + @Test + void requiredUnknownAndOptionalNullRules() { + ApplyVariableValidator validator = new ApplyVariableValidator(mapper); + FlowDefModel m = contract("[{\"key\":\"reason\",\"type\":\"string\",\"required\":true}," + + "{\"key\":\"optional\",\"type\":\"number\"}]"); + validator.requireVariablesPresent(m, "{\"reason\":\"ok\",\"optional\":null}"); + for (String bad : java.util.Arrays.asList("{}", "{\"reason\":null}", "{\"reason\":\"\"}", + "{\"reason\":\" \\t\"}", "{\"reason\":\"ok\",\"extra\":1}", + "{\"reason\":\"ok\",\"optional\":\"\"}", "[]", "null", "invalid")) { + assertThatThrownBy(() -> validator.requireVariablesPresent(m, bad)).isInstanceOf(BizException.class); + } + validator.requireVariablesPresent(contract("[]"), null); + assertThatThrownBy(() -> validator.requireVariablesPresent(contract("[]"), "{\"x\":1}")) + .isInstanceOf(BizException.class); + validator.requireVariablesPresent(contract("[{\"key\":\"text\",\"type\":\"string\"}]"), "{\"text\":\"\"}"); + validator.requireVariablesPresent(contract("[{\"key\":\"date\",\"type\":\"date\"}," + + "{\"key\":\"grade\",\"type\":\"string\",\"enum\":[\"g1\"]}]"), "{\"date\":\"\",\"grade\":\"\"}"); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource(delimiter = '|', value = { + "number|12.5|\"12.5\"", "money|0.01|\"0.01\"", "boolean|true|\"true\"", + "date|\"2024-02-29\"|\"2025-02-29\"", + "datetime|\"2026-10-26T12:30:00\"|\"2026-10-26 12:30:00\"", + "datetime|\"2026-10-26T12:30:00+08:00\"|\"2026-10-26T25:30:00Z\"", + "string|\"ok\"|123", "stringArray|[\"x\"]|[1]", + "fileArray|[{\"id\":1,\"fileName\":\"a.pdf\",\"key\":\"form/a\"}]|[{\"id\":1}]", + "userArray|[{\"id\":101,\"name\":\"A\"}]|[101]" + }) + void realJsonShapesAreCheckedWithoutCoercion(String type, String good, String bad) { + ApplyVariableValidator validator = new ApplyVariableValidator(mapper); + FlowDefModel model = contract("[{\"key\":\"value\",\"type\":\"" + type + "\",\"required\":true}]"); + validator.requireVariablesPresent(model, "{\"value\":" + good + "}"); + for (String invalid : java.util.Arrays.asList(bad, "\"\"", "null", "[]")) { + assertThatThrownBy(() -> validator.requireVariablesPresent(model, "{\"value\":" + invalid + "}")) + .isInstanceOf(BizException.class); + } + } + + @Test + void optionalReferencedVariableStillNeedsValueAndLegacyKeepsOldBehavior() throws Exception { + ApplyVariableValidator validator = new ApplyVariableValidator(mapper); + FlowDefModel m = modelWithCondition("flowAmount > 100"); + validator.requireVariablesPresent(m, "{\"flowAmount\":\"\",\"extra\":1}"); // 历史行为 + m.variables = contract("[{\"key\":\"flowAmount\",\"type\":\"number\",\"required\":false}]").variables; + for (String bad : java.util.Arrays.asList("{}", "{\"flowAmount\":null}", "{\"flowAmount\":\"\"}")) { + assertThatThrownBy(() -> validator.requireVariablesPresent(m, bad)).isInstanceOf(BizException.class); + } + validator.requireVariablesPresent(m, "{\"flowAmount\":200}"); + } + + @Test + void scalarAndArrayEnumUseFrozenValues() { + ApplyVariableValidator validator = new ApplyVariableValidator(mapper); + for (String type : java.util.Arrays.asList("string", "stringArray")) { + FlowDefModel model = contract("[{\"key\":\"grade\",\"type\":\"" + type + "\",\"enum\":[\"g1\",\"g2\"]}]"); + validator.requireVariablesPresent(model, "{\"grade\":" + (type.equals("string") ? "\"g1\"" : "[\"g1\",\"g2\"]") + "}"); + assertThatThrownBy(() -> validator.requireVariablesPresent(model, + "{\"grade\":" + (type.equals("string") ? "\"g9\"" : "[\"g1\",\"g9\"]") + "}")) + .isInstanceOf(BizException.class); + } + } + + @Test + void v2SameTokenUsesAcceptedDefinitionBeforeCurrentContractAndVersionChecks() { + com.yunhai.flowcenter.repository.FlwDefRepository defs = mock(com.yunhai.flowcenter.repository.FlwDefRepository.class); + com.yunhai.flowcenter.repository.FlwInstanceRepository instances = mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class); + com.yunhai.flowcenter.engine.FlowEngine engine = mock(com.yunhai.flowcenter.engine.FlowEngine.class); + FlowInstanceService lifecycle = new FlowInstanceService(instances, defs, + mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class), + mock(com.yunhai.flowcenter.repository.FlwBranchRepository.class), engine, + mock(org.springframework.context.ApplicationEventPublisher.class)); + OapiFlowService service = new OapiFlowService(defs, instances, + mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class), + mock(com.yunhai.flowcenter.repository.FlwCcRepository.class), + mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class), lifecycle, + mock(FlowTaskService.class), engine, mock(com.yunhai.flowcenter.engine.AssigneeResolver.class), mapper, + mock(OpenApiUserClient.class), mock(com.yunhai.platform.openapi.base.OpenApiAppClient.class), + mock(DiagramTokenService.class), mock(org.springframework.beans.factory.ObjectProvider.class)); + com.yunhai.flowcenter.entity.FlwDef old = new com.yunhai.flowcenter.entity.FlwDef(); + old.setId(9L); old.setFlowGuid("g"); old.setVersion(1); old.setContentJson("{\"nodes\":[],\"edges\":[],\"variables\":[]}"); + com.yunhai.flowcenter.entity.FlwDef current = new com.yunhai.flowcenter.entity.FlwDef(); + current.setId(10L); current.setFlowGuid("g"); current.setVersion(2); + current.setContentJson("{\"nodes\":[],\"edges\":[],\"variables\":[{\"key\":\"newRequired\",\"type\":\"string\",\"required\":true}]}"); + when(defs.findById(9L)).thenReturn(java.util.Optional.of(old)); + when(defs.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(1L,"g",1)).thenReturn(java.util.Optional.of(current)); + com.yunhai.flowcenter.entity.FlwInstance row = new com.yunhai.flowcenter.entity.FlwInstance(); + row.setId(500L); row.setDefId(9L); row.setSchId(1L); row.setStatus(30); + row.setSubmitterId(201); row.setApplyAppKey("app"); row.setApplyRequestToken("token"); row.setApplyRound(1); + FlowInstanceService.ApplyCommand original = new FlowInstanceService.ApplyCommand(1L, old, "biz", "7", null, null, + null, 201, null, null, "app", null, null); + row.setApplyPayloadHash(ReflectionTestUtils.invokeMethod(service, "payloadHash", original)); + when(instances.findBySchIdAndBizTypeAndBizId(1L,"biz","7")).thenReturn(java.util.Optional.of(row)); + when(instances.findById(500L)).thenReturn(java.util.Optional.of(row)); + com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2 request = new com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2(); + request.setFlowGuid("g"); request.setBizType("biz"); request.setBizId("7"); request.setSubmitterId(201); + request.setRequestToken("token"); request.setExpectedDefVersion(1); + assertThat(service.applyV2(1L,"app",request).getBranch()).isEqualTo("SAME_REQUEST"); + request.setBizTitle("different payload"); + assertThatThrownBy(() -> service.applyV2(1L,"app",request)).isInstanceOf(BizException.class); + request.setBizTitle(null); request.setFlowGuid("other"); + assertThatThrownBy(() -> service.applyV2(1L,"app",request)).isInstanceOf(BizException.class); + Mockito.verify(instances, Mockito.never()).save(Mockito.any()); + Mockito.verifyNoInteractions(engine); + Mockito.verify(defs, Mockito.never()).findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(Mockito.any(),Mockito.any(),Mockito.any()); + // 新请求仍过当前契约;v1/v2 均在任何实例/任务写入之前拒绝,手工构造也不能跳过校验。 + request.setFlowGuid("g"); request.setRequestToken("fresh"); request.setExpectedDefVersion(null); + assertThatThrownBy(() -> service.applyV2(1L,"app",request)) + .isInstanceOf(BizException.class).hasMessageContaining("newRequired"); + assertThatThrownBy(() -> service.apply(1L,"app",request)) + .isInstanceOf(BizException.class).hasMessageContaining("newRequired"); + Mockito.verify(instances, Mockito.never()).save(Mockito.any()); + Mockito.verifyNoInteractions(engine); + } + + @Test + @DisplayName("禁用账号(status=0):拒绝(存在≠可办理)") + void disabledUserRejected() { + com.yunhai.flowcenter.engine.AssigneeResolver resolver = + new com.yunhai.flowcenter.engine.AssigneeResolver( + mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), + userClient(0)); + assertThatThrownBy(() -> resolver.requireEligibleAssignee(1L, 101)) + .isInstanceOf(BizException.class) + .hasMessageContaining("禁用"); + } + + @Test + @DisplayName("status=null(存量下发未知态):fail-closed 拒绝") + void unknownStatusUserRejected() { + com.yunhai.flowcenter.engine.AssigneeResolver resolver = + new com.yunhai.flowcenter.engine.AssigneeResolver( + mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), + userClient(null)); + assertThatThrownBy(() -> resolver.requireEligibleAssignee(1L, 101)) + .isInstanceOf(BizException.class) + .hasMessageContaining("禁用"); + } + + @Test + @DisplayName("正常账号(status=1)且属于本校:放行") + void activeUserPasses() { + com.yunhai.flowcenter.engine.AssigneeResolver resolver = + new com.yunhai.flowcenter.engine.AssigneeResolver( + mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), + userClient(1)); + assertThat(resolver.requireEligibleAssignee(1L, 101).getName()).isEqualTo("甲"); + } + + @Test + @DisplayName("正常账号但属于外校:拒绝(校域终审)") + void crossSchoolUserRejected() { + OpenApiUserClient client = mock(OpenApiUserClient.class); + BoaUser user = new BoaUser(); + user.setId(101); + user.setName("甲"); + user.setStatus(1); + user.setSchool(new com.yunhai.platform.utils.model.BoaGroup(2, "外校")); + when(client.getUserById(Mockito.anyString())).thenReturn(user); + com.yunhai.flowcenter.engine.AssigneeResolver resolver = + new com.yunhai.flowcenter.engine.AssigneeResolver( + mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), client); + assertThatThrownBy(() -> resolver.requireEligibleAssignee(1L, 101)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于该学校"); + } + + // ==================== fixtures ==================== + + private OpenApiUserClient userClient(Integer status) { + OpenApiUserClient client = mock(OpenApiUserClient.class); + BoaUser user = new BoaUser(); + user.setId(101); + user.setName("甲"); + user.setStatus(status); + user.setSchool(new com.yunhai.platform.utils.model.BoaGroup(1, "测试学校")); + when(client.getUserById(Mockito.anyString())).thenReturn(user); + return client; + } + + /** 一个 START + required 指派节点(one/two 个)+ END 的最小定义 */ + private String defJson(boolean oneRequired, boolean twoRequired) { + StringBuilder sb = new StringBuilder(); + sb.append("{\"name\":\"t\",\"start\":\"n-start\",\"nodes\":["); + sb.append("{\"id\":\"n-start\",\"type\":\"START\",\"name\":\"开始\"},"); + sb.append("{\"id\":\"n-assign-1\",\"type\":\"APPROVAL\",\"name\":\"审批一\",") + .append("\"assignee\":{\"type\":\"USERS\",\"userIds\":[101]},") + .append("\"assignPolicy\":{\"mode\":\"MULTI\",\"required\":").append(oneRequired).append("}},"); + if (twoRequired) { + sb.append("{\"id\":\"n-assign-2\",\"type\":\"APPROVAL\",\"name\":\"审批二\",") + .append("\"assignee\":{\"type\":\"USERS\",\"userIds\":[102]},") + .append("\"assignPolicy\":{\"mode\":\"MULTI\",\"required\":true}},"); + } + sb.append("{\"id\":\"n-end\",\"type\":\"END\",\"name\":\"结束\"}],"); + sb.append("\"edges\":[{\"from\":\"n-start\",\"to\":\"n-assign-1\",\"name\":\"提交\"},") + .append("{\"from\":\"n-assign-1\",\"to\":\"") + .append(twoRequired ? "n-assign-2" : "n-end").append("\",\"name\":\"通过\"}"); + if (twoRequired) { + sb.append(",{\"from\":\"n-assign-2\",\"to\":\"n-end\",\"name\":\"通过\"}"); + } + sb.append("]}"); + return sb.toString(); + } + + /** START → CONDITION(EXPRESSION) → END,条件含给定表达式 */ + private FlowDefModel modelWithCondition(String condition) throws Exception { + String json = "{\"name\":\"t\",\"start\":\"n-start\",\"nodes\":[" + + "{\"id\":\"n-start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n-cond\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"}," + + "{\"id\":\"n-end\",\"type\":\"END\",\"name\":\"结束\"}]," + + "\"edges\":[" + + "{\"from\":\"n-start\",\"to\":\"n-cond\",\"name\":\"提交\"}," + + "{\"from\":\"n-cond\",\"to\":\"n-end\",\"name\":\"高额\",\"condition\":\"" + condition + "\"}" + + "]}"; + return FlowDefModel.parse(mapper, json); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java index 0b2558e..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 @@ -306,7 +354,9 @@ class FlApplyRecoveryTest { 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); + 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); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java new file mode 100644 index 0000000..76bbf34 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java @@ -0,0 +1,259 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.AssigneeResolver; +import com.yunhai.flowcenter.engine.FlowEngine; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.model.FlowApplyRetryModels; +import com.yunhai.flowcenter.model.OapiModels; +import com.yunhai.flowcenter.repository.FlwCallbackRegRepository; +import com.yunhai.flowcenter.repository.FlwCcRepository; +import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwTaskRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiAppClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 线级停发与发布封存的单测(2026-10-25 T03): + * + *
    + *
  • 线级停发:整线停止新发起/预览;不存在"停发后回退旧版"(停发不是降级); + * 在途实例不经新发起解析路径,天然不受影响;恢复只归零标志,不改发布内容。
  • + *
  • 发布封存:updateMeta 对非草稿版本只改行内目录元数据,content_json 保持发布时字节 + * (审计 E02 关闭:已发布文档不再被原地改写)。
  • + *
+ */ +@DisplayName("线级停发/恢复 + 发布封存(T03)") +class FlDefLineOfflineTest { + + private static final Long SCH = 1L; + private static final String GUID = "g-line"; + + private FlwDefRepository defRepository; + private FlwDefAdminServiceSpy adminService; + private OapiFlowService oapiService; + private FlwInstanceRepository instances; + private com.yunhai.flowcenter.service.FlowInstanceService instanceService; + + /** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */ + static class FlwDefAdminServiceSpy { + final FlwDefRepository defRepository; + FlwDefAdminServiceSpy(FlwDefRepository defRepository) { + this.defRepository = defRepository; + } + int offlineLine(Long schId, String flowGuid) { + if (flowGuid == null || flowGuid.trim().isEmpty() + || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim()) + .isPresent()) { + throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, + "流程线不存在:" + flowGuid); + } + return defRepository.updateLineOffline(schId, flowGuid.trim(), 1); + } + int enableLine(Long schId, String flowGuid) { + if (flowGuid == null || flowGuid.trim().isEmpty() + || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim()) + .isPresent()) { + throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, + "流程线不存在:" + flowGuid); + } + return defRepository.updateLineOffline(schId, flowGuid.trim(), 0); + } + } + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + defRepository = mock(FlwDefRepository.class); + adminService = new FlwDefAdminServiceSpy(defRepository); + instances = mock(FlwInstanceRepository.class); + instanceService = mock(FlowInstanceService.class); + oapiService = new OapiFlowService(defRepository, instances, + mock(FlwTaskRepository.class), mock(FlwCcRepository.class), + mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class), + mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(), + mock(OpenApiUserClient.class), mock(OpenApiAppClient.class), + mock(DiagramTokenService.class), mock(ObjectProvider.class)); + when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(eq(SCH), eq(GUID))) + .thenReturn(Optional.of(def(9, 3, 0))); + } + + // ==================== 线级停发/恢复(管理面委托) ==================== + + @Test + @DisplayName("offlineLine:批量置 1;enableLine:批量归零") + void lineOfflineDelegatesBulkUpdate() { + when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3); + assertThat(adminService.offlineLine(SCH, GUID)).isEqualTo(3); + verify(defRepository).updateLineOffline(SCH, GUID, 1); + + when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3); + assertThat(adminService.enableLine(SCH, GUID)).isEqualTo(3); + verify(defRepository).updateLineOffline(SCH, GUID, 0); + } + + @Test + @DisplayName("不存在的流程线:拒绝且不出更新") + void unknownLineRejected() { + assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope")) + .isInstanceOf(BizException.class) + .hasMessageContaining("流程线不存在"); + verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt()); + } + + // ==================== 新发起/预览的线级守卫 ==================== + + @Test + @DisplayName("线停发:applyV2 新发起被拒且不建实例(不回退旧版)") + void applyRejectedWhenLineOffline() { + FlwDef offline = def(9, 3, 1); + when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( + eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode()))) + .thenReturn(Optional.of(offline)); + + FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2(); + body.setFlowGuid(GUID); + body.setBizType("svc-hall"); + body.setBizId("42"); + body.setSubmitterId(7); + + assertThatThrownBy(() -> oapiService.applyV2(SCH, "100018", body)) + .isInstanceOf(BizException.class) + .hasMessageContaining("停发"); + verify(instanceService, never()).applyGuarded(any(), any()); + } + + @Test + @DisplayName("线在线:新发起照常解析最高已发布版") + void applyPassesWhenLineOnline() { + FlwDef online = def(9, 3, 0); + when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( + eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode()))) + .thenReturn(Optional.of(online)); + when(instanceService.applyGuarded(any(), any())) + .thenReturn(new FlowInstanceService.GuardedOutcome( + 500L, false, FlowApplyRetryModels.Branch.CREATED, 1)); + + FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2(); + body.setFlowGuid(GUID); + body.setBizType("svc-hall"); + body.setBizId("42"); + body.setSubmitterId(7); + + FlowApplyRetryModels.ApplyResultV2 vo = oapiService.applyV2(SCH, "100018", body); + assertThat(vo.getInstanceId()).isEqualTo(500L); + ArgumentCaptor cmd = + ArgumentCaptor.forClass(FlowInstanceService.ApplyCommand.class); + verify(instanceService).applyGuarded(cmd.capture(), any()); + assertThat(cmd.getValue().def.getId()).isEqualTo(9L); + } + + @Test + @DisplayName("预览同口径:线停发拒绝(选人预览不是新发起旁路)") + void previewRejectedWhenLineOffline() { + FlwDef offline = def(9, 3, 1); + when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( + eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode()))) + .thenReturn(Optional.of(offline)); + + assertThatThrownBy(() -> oapiService.applyPreview(SCH, GUID, 7)) + .isInstanceOf(BizException.class) + .hasMessageContaining("停发"); + } + + // ==================== 发布封存(updateMeta) ==================== + + @Test + @DisplayName("updateMeta 对已发布版本:行 name/appKey 可改,content_json 保持发布字节") + void updateMetaFreezesPublishedDocument() throws Exception { + FlowDefAdminService service = newFlowDefAdminService(); + FlwDef published = def(9, 3, FlwEnums.DefStatus.PUBLISHED.getCode()); + published.setName("旧名"); + String originalJson = published.getContentJson(); + when(defRepository.findById(9L)).thenReturn(Optional.of(published)); + when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0)); + ReflectionTestUtils.setField(service, "appRepository", appRepo(true)); + + FlwDef saved = service.updateMeta(SCH, 9L, "新名", null); + + assertThat(saved.getName()).isEqualTo("新名"); + assertThat(saved.getContentJson()).isEqualTo(originalJson); + } + + @Test + @DisplayName("updateMeta 对草稿:content_json 顶层 name 照旧同步(草稿文档可变)") + void updateMetaStillSyncsDraftDocument() throws Exception { + FlowDefAdminService service = newFlowDefAdminService(); + FlwDef draft = def(9, 3, 0); + draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); + draft.setName("旧名"); + draft.setContentJson("{\"name\":\"旧名\",\"nodes\":[],\"edges\":[]}"); + when(defRepository.findById(9L)).thenReturn(Optional.of(draft)); + when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0)); + ReflectionTestUtils.setField(service, "appRepository", appRepo(true)); + + FlwDef saved = service.updateMeta(SCH, 9L, "新名", null); + + assertThat(saved.getContentJson()).contains("\"新名\""); + } + + // ==================== fixtures ==================== + + private FlowDefAdminService newFlowDefAdminService() { + // 依赖协作全部 mock(构造器形参多,反射装配与生产 Spring 无关,只测本类逻辑) + return new FlowDefAdminService( + defRepository, + mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class), + mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class), + appRepo(true), + mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class), + mock(FlowDeptAdminService.class), + mock(FlowEngine.class), + new ObjectMapper(), + mock(FormCenterSchemaClient.class)); + } + + private com.yunhai.flowcenter.repository.FlwAppRepository appRepo(boolean enabled) { + com.yunhai.flowcenter.repository.FlwAppRepository repo = + mock(com.yunhai.flowcenter.repository.FlwAppRepository.class); + when(repo.isEnabledApp(anyLong(), anyString())).thenReturn(enabled); + return repo; + } + + private FlwDef def(int id, int version, int lineOffline) { + FlwDef def = new FlwDef(); + def.setId((long) id); + def.setSchId(SCH); + def.setFlowGuid(GUID); + def.setCode("leave"); + def.setName("请假"); + def.setVersion(version); + def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); + def.setLineOffline(lineOffline); + def.setContentJson("{\"nodes\":[], \"edges\":[], \"start\":\"n1\"}"); + return def; + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java index ba696e3..c68c859 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java @@ -127,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; } @@ -328,6 +329,7 @@ class FlFormBindingTest { @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); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java b/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java new file mode 100644 index 0000000..b0437ae --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java @@ -0,0 +1,259 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwUsageGrant; +import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.repository.FlwUsageGrantRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiAppClient; +import com.yunhai.platform.openapi.core.YschOpenCall; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 应用使用许可闸与恢复感知判定的单测(2026-10-25 T01)。 + * + *

钉住的不变量:

+ *
    + *
  • 新发起必须有生效许可:无行/已撤销/主体解析失败/未在学校开通/核验不可达——五路全拒;
  • + *
  • 已受理命令回执可恢复:同 token、retryOnly 探询、在途/通过幂等、陈旧轮次四类 + * 回执分支不触许可闸(P0-3 收口顺序:认证 → 回执 → 未命中才查资格);
  • + *
  • appKey 不是主体:appKey 可重置/复用,许可与审计挂 UserBase 稳定主键 appId。
  • + *
+ */ +@DisplayName("应用使用许可闸 FlowUsageGrantGuard + apply 恢复感知判定(T01)") +class FlUsageGrantTest { + + private static final long SCH = 27L; + private static final String FLOW = "fg-1"; + private static final String APP_KEY = "100018"; + + private FlwUsageGrantRepository grantRepository; + private OpenApiAppClient appClient; + private YschOpenCall openCall; + private UserBaseActivationClient activationClient; + private FlowUsageGrantGuard guard; + + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + grantRepository = mock(FlwUsageGrantRepository.class); + appClient = mock(OpenApiAppClient.class); + openCall = mock(YschOpenCall.class); + activationClient = new UserBaseActivationClient(openCall); + guard = new FlowUsageGrantGuard(grantRepository, activationClient); + ReflectionTestUtils.setField(guard, "appClient", appClient); + ReflectionTestUtils.setField(activationClient, "enforce", true); + } + + // ==================== Guard:新发起资格 ==================== + + @Test + @DisplayName("enforce 关闭:直通且不出站(灰度期存量行为不变)") + void enforceOffPassesThrough() { + ReflectionTestUtils.setField(activationClient, "enforce", false); + + assertThat(guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isNull(); + verify(grantRepository, never()).findBySchIdAndFlowGuidAndAppId(anyLong(), anyString(), anyInt()); + } + + @Test + @DisplayName("无许可行:拒绝新发起") + void noGrantRowRejected() { + when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001)); + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) + .isInstanceOf(BizException.class) + .hasMessageContaining("使用许可"); + } + + @Test + @DisplayName("许可已撤销(enabled=0):拒绝新发起") + void revokedGrantRejected() { + when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001)); + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)) + .thenReturn(Optional.of(grant(5001, 0))); + + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) + .isInstanceOf(BizException.class) + .hasMessageContaining("使用许可"); + } + + @Test + @DisplayName("有生效许可且该校开通:放行并返回稳定主体 appId") + void grantedAndActivatedPasses() { + when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001)); + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)) + .thenReturn(Optional.of(grant(5001, 1))); + stubActivation(5001, true); + + assertThat(guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isEqualTo(5001); + } + + @Test + @DisplayName("持本地许可但未在学校开通(平台 schId=0 平台资源同口径):拒绝") + void grantedButNotActivatedRejected() { + when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001)); + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)) + .thenReturn(Optional.of(grant(5001, 1))); + stubActivation(5001, false); + + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) + .isInstanceOf(BizException.class) + .hasMessageContaining("开通"); + } + + @Test + @DisplayName("UserBase 资格核验不可达:fail-closed 拒绝(不降级为仅本地 grant 即放行)") + void activationUnreachableFailClosed() { + when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001)); + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)) + .thenReturn(Optional.of(grant(5001, 1))); + when(openCall.get(anyString(), anyString(), any(), any())) + .thenThrow(new RuntimeException("connection refused")); + + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不可用"); + } + + @Test + @DisplayName("appKey 解析不出稳定主体:拒绝(凭据索引不构成主体)") + void unresolvablePrincipalRejected() { + when(appClient.getAppByKey(APP_KEY)).thenReturn(null); + + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) + .isInstanceOf(BizException.class) + .hasMessageContaining("主体"); + } + + // ==================== apply 恢复感知判定(OapiFlowService 纯函数) ==================== + + @Test + @DisplayName("v1 判定:在途/通过=回执放行;无实例/终态件=需许可") + void legacyApplyReceiptDecision() { + assertThat(OapiFlowService.legacyApplyIsReceiptOnly(null)).isFalse(); + assertThat(OapiFlowService.legacyApplyIsReceiptOnly( + instance(FlwEnums.InstanceStatus.APPROVING.getCode()))).isTrue(); + assertThat(OapiFlowService.legacyApplyIsReceiptOnly( + instance(FlwEnums.InstanceStatus.COMPLETED.getCode()))).isTrue(); + assertThat(OapiFlowService.legacyApplyIsReceiptOnly( + instance(FlwEnums.InstanceStatus.REJECTED.getCode()))).isFalse(); + assertThat(OapiFlowService.legacyApplyIsReceiptOnly( + instance(FlwEnums.InstanceStatus.WITHDRAWN.getCode()))).isFalse(); + } + + @Test + @DisplayName("v2 判定:同 token/retryOnly/在途/陈旧轮次=回执;无实例或更高轮次=新申报") + void guardedApplyReceiptDecision() { + FlwInstance terminal = instance(FlwEnums.InstanceStatus.REJECTED.getCode()); + terminal.setApplyRequestToken("tok-a"); + terminal.setApplyRound(2); + + assertThat(OapiFlowService.guardedApplyIsReceiptOnly(null, "tok-a", 3, false)).isFalse(); + assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, "tok-a", 9, false)).isTrue(); + assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, "tok-b", 9, true)).isTrue(); + assertThat(OapiFlowService.guardedApplyIsReceiptOnly( + instance(FlwEnums.InstanceStatus.APPROVING.getCode()), "tok-b", 9, false)).isTrue(); + assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, "tok-b", 2, false)).isTrue(); + assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, "tok-b", 3, false)).isFalse(); + assertThat(OapiFlowService.guardedApplyIsReceiptOnly(terminal, null, null, false)).isTrue(); + } + + // ==================== 管理面:授予/撤销 ==================== + + @Test + @DisplayName("授予:appKey 当场解析成稳定主体落库;重申复用同一行") + void grantResolvesPrincipalAndUpserts() { + // 本测主体解析与 upsert,不开资格闸(开通闸已由 Guard 用例钉住) + ReflectionTestUtils.setField(activationClient, "enforce", false); + FlowUsageGrantAdminService admin = + new FlowUsageGrantAdminService(grantRepository, activationClient); + ReflectionTestUtils.setField(admin, "appClient", appClient); + when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(5001)); + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)).thenReturn(Optional.empty()); + when(grantRepository.save(any())).thenAnswer(c -> c.getArgument(0)); + + FlwUsageGrant saved = admin.grant(SCH, FLOW, APP_KEY, "首次授权", "admin"); + + assertThat(saved.getAppId()).isEqualTo(5001); + assertThat(saved.getEnabled()).isEqualTo(1); + assertThat(saved.getAppKeySnapshot()).isEqualTo(APP_KEY); + } + + @Test + @DisplayName("撤销:enabled=0 行保留(审计与幂等判据不删)") + void revokeKeepsRow() { + FlowUsageGrantAdminService admin = + new FlowUsageGrantAdminService(grantRepository, activationClient); + FlwUsageGrant existing = grant(5001, 1); + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)) + .thenReturn(Optional.of(existing)); + when(grantRepository.save(any())).thenAnswer(c -> c.getArgument(0)); + + FlwUsageGrant saved = admin.revoke(SCH, FLOW, 5001, "违规使用", "admin"); + + assertThat(saved.getEnabled()).isEqualTo(0); + assertThat(saved.getId()).isNotNull(); + } + + // ==================== fixtures ==================== + + private JsonNode registration(int appId) { + Map row = new HashMap<>(); + row.put("id", appId); + row.put("appKey", APP_KEY); + row.put("appName", "测试应用"); + return mapper.valueToTree(row); + } + + private void stubActivation(int appId, boolean activated) { + Map vo = new HashMap<>(); + vo.put("appId", appId); + vo.put("schoolId", SCH); + vo.put("activated", activated); + Mockito.when(openCall.get(anyString(), anyString(), any(), any())) + .thenReturn(mapper.valueToTree(vo)); + } + + private FlwUsageGrant grant(int appId, int enabled) { + FlwUsageGrant g = new FlwUsageGrant(); + g.setId(71L); + g.setSchId(SCH); + g.setFlowGuid(FLOW); + g.setAppId(appId); + g.setAppKeySnapshot(APP_KEY); + g.setEnabled(enabled); + return g; + } + + private FlwInstance instance(int status) { + FlwInstance i = new FlwInstance(); + i.setId(900L); + i.setSchId(SCH); + i.setStatus(status); + return i; + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java index ee0ce89..4d6d172 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java @@ -202,16 +202,124 @@ class FlowDefAdminServicePublishContractTest { } @Test - void legacyDefWithoutFormUuidSkipsSnapshotEntirely() { + void newUnboundVersionCannotOmitVariables() { when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft(null))); - - FlwDef published = service.publish(SCH, 9L); - - assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus()); - assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动"); + assertTrue(assertThrows(BizException.class, () -> service.publish(SCH, 9L)) + .getMessage().contains("必须声明 variables")); + verify(defRepository, never()).save(any()); verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any()); } + @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()); + } + + @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"))); 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 必失败场景默认重跑仍失败挂起(逃生门入口即为此)"); } - @Test - void useLatestDefSwitchesToV2AndRefreshesSnapshotWithTrace() { - 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)); + // ==================== T03 在途锁版(2026-10-25):useLatestDef 旁路停用 ==================== - service.reroute(SCH, 500L, true); + @Test + void useLatestDefIsRejectedByDesign() { + // 在途实例固定原版本(主方案 §1.3 / T03):新发布版只影响新申报,不再提供"切最新版续走"旁路; + // 挂起件按原定义重跑,修复后的表达式按原版本重新发布生效 + FlwDef v1 = def(11L, 1, V1); + parkedInstance(v1); + + 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 useLatestDefRejectionHappensBeforeAnyLookup() { + // 显式拒绝发生在任何查询之前:即使办件 id 不存在也是同一个"已停用"错(不泄存在性、不误查) + BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 99999L, true)); + assertTrue(e.getMessage().contains("在途换版已停用")); + verify(instanceRepository, never()).findById(any()); + } + + @Test + void defaultRerouteStillWorksAfterLockdown() { + // 旁路停用不影响默认路径:按原版本快照重跑照常 + FlwDef v1 = def(11L, 1, V1); + FlwInstance instance = parkedInstance(v1); + + service.reroute(SCH, 500L, false); 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); - } - - @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(), "已是最新版不落留痕行、不刷新"); - } - - @Test - void useLatestDefRejectsWhenNodeMissingInNewDef() { - 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)); - - BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 500L, true)); - assertTrue(e.getMessage().contains("最新已发布定义中不存在"), "节点在新定义缺失要报人类可读错,不是笼统的无需重跑"); - verify(flowEngine, never()).rerouteCondition(any(), any(), any()); + assertEquals(11L, defCap.getValue().getId(), "锁版后默认重跑仍按原版本(v1)"); + assertEquals(11L, instance.getDefId(), "定义快照零刷新"); } } diff --git a/src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java b/src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java new file mode 100644 index 0000000..6f6c7e3 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java @@ -0,0 +1,280 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.callback.FlwStateEventRecorder; +import com.yunhai.flowcenter.engine.FlowEvent; +import com.yunhai.flowcenter.entity.*; +import com.yunhai.flowcenter.model.FormalApplyModels; +import com.yunhai.flowcenter.repository.*; +import com.yunhai.flowcenter.stub.StubUserClient; +import com.yunhai.flowcenter.support.FlowTestSupport; +import com.yunhai.flowcenter.support.OapiSignedClient; +import com.yunhai.platform.openapi.base.OpenApiAppClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real MySQL constraints/transactions/concurrency and signed FL HTTP; UB is an explicit offline boundary. */ +@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT,properties="flow-center.formal-command.enabled=true") +@ActiveProfiles("test") +class FormalApplyCommandIT extends FlowTestSupport { + static final String A="11111111-1111-4111-8111-111111111111", B="22222222-2222-4222-8222-222222222222"; + @Autowired FormalApplyCommandService service; + @Autowired FormalPayloadCanonicalizer hashes; + @Autowired FlwApplyCommandRepository commands; + @Autowired FlwBizCoordinateRepository coordinates; + @Autowired FlwCommandSourceRepository sources; + @Autowired FlwUsageGrantRepository grants; + @Autowired FlwStateEventRepository facts; + @Autowired FlowInstanceService lifecycle; + @Autowired FlowDefAdminService definitions; + @MockBean FormCenterSchemaClient schemaClient; + @Autowired TransactionTemplate tx; + @Autowired ObjectMapper mapper; + @Autowired JdbcTemplate jdbc; + @Autowired ApplicationEventPublisher events; + @MockBean OpenApiAppClient apps; + @MockBean OpenApiUserClient users; + @MockBean UserBaseActivationClient activation; + @SpyBean FlwStateEventRecorder recorder; + @LocalServerPort int port; + FlwDef def; + + @BeforeEach void setup() throws Exception { + int dbPort = jdbc.queryForObject("SELECT @@port",Integer.class); + assertTrue(dbPort == 13326 || dbPort == 13429,"Only dedicated isolated database ports are allowed"); + if (dbPort == 13429) { + assertTrue(jdbc.queryForObject("SELECT @@version",String.class).startsWith("8.")); + assertEquals("c:/users/chenx/.zcode/tmp/fl-d09-zcode-mysql8/", + jdbc.queryForObject("SELECT @@datadir",String.class).replace('\\','/').toLowerCase()); + assertEquals("fl_d09_20261026",jdbc.queryForObject("SELECT DATABASE()",String.class)); + } + commands.deleteAll();coordinates.deleteAll();sources.deleteAll();grants.deleteAll();facts.deleteAll();cleanDb(); + when(apps.getAppByKey(anyString())).thenAnswer(inv -> mapper.readTree("{\"id\":"+("100018".equals(inv.getArgument(0))?18:19)+",\"sourcePrincipalId\":\""+("100018".equals(inv.getArgument(0))?A:B)+"\",\"appCode\":\"svc-hall\"}")); + when(users.getUserById(anyString())).thenAnswer(inv -> new StubUserClient().getUserById(inv.getArgument(0))); + when(activation.isAppActivatedStrict(anyInt(),anyLong())).thenReturn(true); + mapDept("a",101,"A");def=saveAndPublishDef("formal","formal",linearDef("a","Review")); + grant(A,18);grant(B,19); + } + void grant(String source,int app) { + FlwUsageGrant g=new FlwUsageGrant();g.setSchId(1L);g.setFlowGuid(def.getFlowGuid());g.setAppId(app); + g.setSourcePrincipalId(source);g.setEnabled(1);grants.saveAndFlush(g); + } + FormalApplyModels.Request request(String id) { + FormalApplyModels.Request r=new FormalApplyModels.Request();r.setRequestId(id);r.setFlowGuid(def.getFlowGuid()); + r.setBizType("svc-hall");r.setBizId("7");r.setSubmitterId(201);r.setSubmitterName("User");r.setExpectedPreviousRound(0);return r; + } + + FlwDef publishContract(boolean bound, String declarations) { + FlwDef next = new FlwDef(); + org.springframework.beans.BeanUtils.copyProperties(def, next, "id"); + next.setVersion(def.getVersion()+1); next.setStatus(0); + next.setContentJson(linearDef("a","Review").replaceFirst("\\{", "{\"variables\":"+declarations+",")); + if (bound) { + next.setFormUuid("d10-form"); next.setFormVersion(1); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("formUuid","d10-form"); snapshot.put("formVersion",1); + List> fields = new ArrayList<>(); + Map amount = new LinkedHashMap<>(); amount.put("key","amount"); amount.put("type","number"); amount.put("required",true); + Map grade = new LinkedHashMap<>(); grade.put("key","grade"); grade.put("type","select"); grade.put("enum",Arrays.asList("g1","g2")); + fields.add(amount); fields.add(grade); snapshot.put("fields",fields); + when(schemaClient.fetchSchema(1L,"d10-form",1)).thenReturn(snapshot); + } else { next.setFormUuid(null); next.setFormVersion(null); } + next=defRepository.saveAndFlush(next); + def=definitions.publish(1L,next.getId()); + return def; + } + static final String D10_VARS="[{\"key\":\"amount\",\"type\":\"number\",\"required\":true},{\"key\":\"grade\",\"type\":\"string\",\"enum\":[\"g1\",\"g2\"]}]"; + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void d10ValidInputsUsePublishedUnboundAndFcContracts(boolean bound) throws Exception { + FlwDef published=publishContract(bound,D10_VARS); + FormalApplyModels.Request r=request("valid");r.setFormData(mapper.readValue("{\"amount\":12.5,\"grade\":\"g1\"}",Map.class)); + String hash=hashes.hash(r); + FormalApplyModels.Receipt accepted=service.submit(1L,"100018",r); + assertEquals("ACCEPTED",accepted.getState());assertEquals(published.getVersion(),accepted.getDefVersion()); + assertEquals(hash,accepted.getCanonicalHash());assertEquals(1,accepted.getCanonicalVersion()); + assertEquals(1,instanceRepository.count());assertEquals(2,taskRepository.count()); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"{}","{\"amount\":null}","{\"amount\":\"\"}","{\"amount\":\" \"}", + "{\"amount\":\"12\"}","{\"amount\":true}","{\"amount\":12,\"grade\":\"g9\"}","{\"amount\":12,\"extra\":1}"}) + void d10ValidationRejectionIsDurableWithZeroInstancesOrTasks(String data) throws Exception { + publishContract(false,D10_VARS); + FormalApplyModels.Request r=request("invalid");r.setFormData(mapper.readValue(data,Map.class)); + FormalApplyModels.Receipt rejected=service.submit(1L,"100018",r); + assertEquals("VALIDATION_REJECTED",rejected.getRejectCode());assertEquals("REJECTED",rejected.getState()); + assertEquals(0,instanceRepository.count());assertEquals(0,taskRepository.count());assertEquals(1,commands.count()); + // 新定义放宽后,原拒绝仍保持原事实,不重新受理。 + publishContract(false,"[]"); + doThrow(new IllegalStateException("UB unavailable")).when(users).getUserById(anyString()); + FormalApplyModels.Receipt replay=service.submit(1L,"100018",r); + assertEquals(rejected.getRejectCode(),replay.getRejectCode());assertEquals(rejected.getRejectDetail(),replay.getRejectDetail()); + assertEquals(rejected.getCanonicalHash(),replay.getCanonicalHash());assertEquals(0,instanceRepository.count());assertEquals(0,taskRepository.count()); + r.setBizTitle("changed"); + assertThrows(RuntimeException.class,()->service.submit(1L,"100018",r));assertEquals(1,commands.count()); + } + + @Test void d10AcceptedReplaySurvivesNewRequiredVariableAndFingerprintStaysFrozen() throws Exception { + FlwDef original=publishContract(false,D10_VARS); + FormalApplyModels.Request r=request("stable");r.setFormData(mapper.readValue("{\"amount\":12,\"grade\":\"g1\"}",Map.class)); + FormalApplyModels.Receipt accepted=service.submit(1L,"100018",r); + def=original; + publishContract(false,"[{\"key\":\"newRequired\",\"type\":\"boolean\",\"required\":true}]"); + FormalApplyModels.Receipt replay=service.submit(1L,"100018",r); + assertEquals(accepted.getInstanceId(),replay.getInstanceId());assertEquals(original.getVersion(),replay.getDefVersion()); + assertEquals(accepted.getCanonicalHash(),replay.getCanonicalHash());assertEquals(1,replay.getCanonicalVersion()); + assertEquals(1,commands.count());assertEquals(1,instanceRepository.count());assertEquals(2,taskRepository.count()); + } + + @Test void d10ConcurrentValidAndInvalidInputCannotBypassContract() throws Exception { + publishContract(false,D10_VARS); + FormalApplyModels.Request valid=request("valid");valid.setFormData(mapper.readValue("{\"amount\":12}",Map.class)); + FormalApplyModels.Request invalid=request("invalid");invalid.setBizId("8"); + ExecutorService pool=Executors.newFixedThreadPool(2);CountDownLatch go=new CountDownLatch(1); + try { + Future a=pool.submit(()->{go.await();return service.submit(1L,"100018",valid);}); + Future b=pool.submit(()->{go.await();return service.submit(1L,"100018",invalid);});go.countDown(); + assertEquals("ACCEPTED",a.get(20,TimeUnit.SECONDS).getState());assertEquals("VALIDATION_REJECTED",b.get(20,TimeUnit.SECONDS).getRejectCode()); + assertEquals(1,instanceRepository.count());assertEquals(2,taskRepository.count());assertEquals(2,commands.count()); + } finally { pool.shutdownNow(); } + } + @Test void duplicateAndConflictingRequestAndBusyCoordinateAreDifferentFacts() { + FormalApplyModels.Request r=request("r1");FormalApplyModels.Receipt first=service.submit(1L,"100018",r); + assertEquals("ACCEPTED",first.getState());assertEquals(1,first.getRound()); + assertEquals(first.getInstanceId(),service.submit(1L,"100018",r).getInstanceId()); + r.setBizTitle("changed");assertThrows(RuntimeException.class,()->service.submit(1L,"100018",r)); + FormalApplyModels.Receipt busy=service.submit(1L,"100018",request("r2"));assertEquals("REJECTED",busy.getState());assertEquals("IN_FLIGHT",busy.getRejectCode()); + assertEquals(1,instanceRepository.count());assertEquals(2,commands.count()); + } + @Test void sameRequestIdOnDifferentCoordinatesStillConflicts() { + service.submit(1L,"100018",request("r1"));FormalApplyModels.Request changed=request("r1");changed.setBizId("8"); + assertThrows(RuntimeException.class,()->service.submit(1L,"100018",changed));assertEquals(1,commands.count()); + } + @Test void twoLegitimateApplicationsNeverShareInstanceOrReceipt() { + FormalApplyModels.Receipt a=service.submit(1L,"100018",request("r")); + assertEquals("NOT_FOUND",service.query(1L,"100019","r",null).getState()); + FormalApplyModels.Receipt b=service.submit(1L,"100019",request("r")); + assertNotEquals(a.getInstanceId(),b.getInstanceId());assertEquals(2,instanceRepository.count()); + } + @Test void receiptReplaySurvivesGrantRevocationDefinitionChangesAndUserLookupFailure() { + FormalApplyModels.Request r=request("r");FormalApplyModels.Receipt a=service.submit(1L,"100018",r); + FlwUsageGrant g=grants.findBySchIdAndFlowGuidAndAppId(1L,def.getFlowGuid(),18).get();g.setEnabled(0);grants.saveAndFlush(g); + def.setStatus(0);def.setLineOffline(1);defRepository.saveAndFlush(def); + doThrow(new IllegalStateException("UB unavailable")).when(users).getUserById(anyString()); + assertEquals(a.getInstanceId(),service.submit(1L,"100018",r).getInstanceId()); + FormalApplyModels.Request other=request("other");other.setBizId("8");assertEquals("USAGE_DENIED",service.submit(1L,"100018",other).getRejectCode()); + assertEquals(a.getInstanceId(),service.query(1L,"100018","r",hashes.hash(r)).getInstanceId()); + } + @Test void missingFirstAndWrongPreviousAnchorsAreDurableRejections() { + FormalApplyModels.Request invalid=request("missing");invalid.setExpectedPreviousRound(null); + assertEquals("PRECONDITION_FAILED",service.submit(1L,"100018",invalid).getRejectCode()); + assertEquals("REJECTED",service.query(1L,"100018","missing",null).getState()); + assertEquals(0,instanceRepository.count()); + } + @Test void explicitResubmissionCreatesNewInstanceAndLateOldReleaseCannotFreeIt() { + FormalApplyModels.Receipt first=service.submit(1L,"100018",request("first"));lifecycle.withdraw(1L,first.getInstanceId()); + FormalApplyModels.Request wrong=request("wrong");wrong.setExpectedPreviousRound(1); + assertEquals("PRECONDITION_FAILED",service.submit(1L,"100018",wrong).getRejectCode()); + FormalApplyModels.Request next=request("next");next.setExpectedPreviousRound(1);next.setExpectedPreviousInstanceId(first.getInstanceId()); + FormalApplyModels.Receipt second=service.submit(1L,"100018",next); + assertEquals(2,second.getRound());assertNotEquals(first.getInstanceId(),second.getInstanceId()); + tx.executeWithoutResult(t -> events.publishEvent(FlowEvent.of(FlowEvent.Type.WITHDRAWN,first.getInstanceId(),null,Collections.emptyMap()))); + assertEquals(second.getInstanceId(),tx.execute(t -> coordinates.lock(1L,A,"svc-hall","7").get().getActiveInstanceId())); + assertEquals(40,service.query(1L,"100018","first",null).getStatus()); + } + @Test void concurrentSameCommandReturnsExactlyOneInstance() throws Exception { concurrent(true); } + @Test void concurrentDifferentCommandsPersistOneAcceptanceAndOneRejection() throws Exception { concurrent(false); } + void concurrent(boolean same) throws Exception { + ExecutorService pool=Executors.newFixedThreadPool(2);CountDownLatch go=new CountDownLatch(1); + try { + Future a=pool.submit(()->{go.await();return service.submit(1L,"100018",request("a"));}); + Future b=pool.submit(()->{go.await();return service.submit(1L,"100018",request(same?"a":"b"));});go.countDown(); + FormalApplyModels.Receipt x=a.get(20,TimeUnit.SECONDS),y=b.get(20,TimeUnit.SECONDS); + assertEquals(1,instanceRepository.count()); + if(same){assertEquals("ACCEPTED",x.getState());assertEquals(x.getInstanceId(),y.getInstanceId());assertEquals(1,commands.count());} + else {assertEquals(new HashSet<>(Arrays.asList("ACCEPTED","REJECTED")),new HashSet<>(Arrays.asList(x.getState(),y.getState())));assertEquals(2,commands.count());} + } finally {pool.shutdownNow();} + } + @Test void eventPersistenceFailureRollsBackCommandInstanceAndPointer() { + doThrow(new IllegalStateException("injected event persistence failure")).when(recorder).record(any()); + assertThrows(RuntimeException.class,()->service.submit(1L,"100018",request("r"))); + assertEquals(0,commands.count());assertEquals(0,instanceRepository.count());assertEquals(0,coordinates.count()); + } + @Test void unavailableAuthorityDoesNotBecomeDeterministicRejection() { + when(activation.isAppActivatedStrict(anyInt(),anyLong())).thenThrow(new IllegalStateException("network")); + assertThrows(RuntimeException.class,()->service.submit(1L,"100018",request("r"))); + assertEquals("NOT_FOUND",service.query(1L,"100018","r",null).getState());assertEquals(0,instanceRepository.count()); + } + @Test void signedHttpExposesReceiptAndBlocksLegacyWriterAndOtherSourceReads() throws Exception { + OapiSignedClient client=new OapiSignedClient("http://localhost:"+port,"100018","svc-hall-dev-secret-100018"); + com.fasterxml.jackson.databind.JsonNode reply=client.post("/oapi/flow/commands?schId=1",mapper.writeValueAsString(request("r")),com.fasterxml.jackson.databind.JsonNode.class).getBody(); + assertEquals("ACCEPTED",reply.path("data").path("state").asText()); + assertEquals("ACCEPTED",client.get("/oapi/flow/commands/r?schId=1",com.fasterxml.jackson.databind.JsonNode.class).getBody().path("data").path("state").asText()); + assertNotEquals(0,client.post("/oapi/flow/apply?schId=1",mapper.writeValueAsString(request("legacy")),com.fasterxml.jackson.databind.JsonNode.class).getBody().path("code").asInt()); + FormalApplyModels.Receipt other=service.submit(1L,"100019",request("other")); + assertNotEquals(0,client.get("/oapi/flow/progress?schId=1&instanceId="+other.getInstanceId(),com.fasterxml.jackson.databind.JsonNode.class).getBody().path("code").asInt()); + } + @Test void databaseEnforcesCommandCoordinateAndRoundUniqueness() { + FormalApplyModels.Receipt r=service.submit(1L,"100018",request("r")); + FlwApplyCommand duplicate=new FlwApplyCommand();org.springframework.beans.BeanUtils.copyProperties(commands.findAll().get(0),duplicate,"id"); + assertThrows(org.springframework.dao.DataIntegrityViolationException.class,()->commands.saveAndFlush(duplicate)); + FlwBizCoordinate coordinate=new FlwBizCoordinate();org.springframework.beans.BeanUtils.copyProperties(coordinates.findAll().get(0),coordinate,"id"); + assertThrows(org.springframework.dao.DataIntegrityViolationException.class,()->coordinates.saveAndFlush(coordinate)); + FlwInstance instance=new FlwInstance();org.springframework.beans.BeanUtils.copyProperties(instanceRepository.findById(r.getInstanceId()).get(),instance,"id","version"); + assertThrows(org.springframework.dao.DataIntegrityViolationException.class,()->instanceRepository.saveAndFlush(instance)); + assertEquals(1,commands.count());assertEquals(1,coordinates.count());assertEquals(1,instanceRepository.count()); + } + @Test void terminalReleaseAndNewOccupancyAreSerializedByRealDatabaseLock() throws Exception { + FormalApplyModels.Receipt old=service.submit(1L,"100018",request("old")); + CountDownLatch terminalWritten=new CountDownLatch(1),commitTerminal=new CountDownLatch(1);ExecutorService pool=Executors.newFixedThreadPool(2); + FormalApplyModels.Request next=request("next");next.setExpectedPreviousRound(1);next.setExpectedPreviousInstanceId(old.getInstanceId()); + try { + Future finish=pool.submit(()->tx.executeWithoutResult(t->{lifecycle.withdraw(1L,old.getInstanceId());terminalWritten.countDown();try{if(!commitTerminal.await(10,TimeUnit.SECONDS))throw new IllegalStateException("timeout");}catch(InterruptedException e){throw new IllegalStateException(e);}})); + assertTrue(terminalWritten.await(10,TimeUnit.SECONDS));Future begin=pool.submit(()->service.submit(1L,"100018",next)); + assertThrows(TimeoutException.class,()->begin.get(200,TimeUnit.MILLISECONDS));commitTerminal.countDown();finish.get(10,TimeUnit.SECONDS); + FormalApplyModels.Receipt result=begin.get(10,TimeUnit.SECONDS);assertEquals("ACCEPTED",result.getState());assertEquals(2,result.getRound()); + assertEquals(result.getInstanceId(),tx.execute(t->coordinates.lock(1L,A,"svc-hall","7").get().getActiveInstanceId())); + } finally {commitTerminal.countDown();pool.shutdownNow();} + } + @Test void completedDoesNotAutomaticallyQualifyForAnotherRound() { + FormalApplyModels.Receipt first=service.submit(1L,"100018",request("first")); + tx.executeWithoutResult(t->{FlwInstance instance=instanceRepository.findById(first.getInstanceId()).get();instance.setStatus(20);instanceRepository.saveAndFlush(instance);events.publishEvent(FlowEvent.of(FlowEvent.Type.COMPLETED,instance.getId(),null,Collections.emptyMap()));}); + FormalApplyModels.Request next=request("next");next.setExpectedPreviousInstanceId(first.getInstanceId());next.setExpectedPreviousRound(1); + assertEquals("RESUBMIT_NOT_ALLOWED",service.submit(1L,"100018",next).getRejectCode());assertEquals(1,instanceRepository.count()); + } + @Test void missingOrRecreatedSourceNeverInheritsHistoricalCommandsOrGrants() throws Exception { + service.submit(1L,"100018",request("r")); + doReturn(mapper.readTree("{\"id\":18}")).when(apps).getAppByKey("100018"); + assertThrows(RuntimeException.class,()->service.query(1L,"100018","r",null)); + doReturn(mapper.readTree("{\"id\":18,\"sourcePrincipalId\":\""+B+"\"}")).when(apps).getAppByKey("100018"); + assertEquals("NOT_FOUND",service.query(1L,"100018","r",null).getState()); + assertEquals("USAGE_DENIED",service.submit(1L,"100018",request("new")).getRejectCode()); + } + @Test void rawKeysAreNeverSilentlyTrimmedOrAliasedByMysql() { + FormalApplyModels.Request r=request("space");r.setBizId("7 "); + assertThrows(RuntimeException.class,()->service.submit(1L,"100018",r));assertEquals(0,commands.count()); + } + @Test void receiptRejectsDefinitionIdentityDriftEvenWhenVersionNumberIsEqual() { + FormalApplyModels.Receipt result=service.submit(1L,"100018",request("r")); + FlwDef other=saveAndPublishDef("other","Other",linearDef("a","Review"));assertEquals(def.getVersion(),other.getVersion()); + tx.executeWithoutResult(t->{FlwInstance instance=instanceRepository.findById(result.getInstanceId()).get();instance.setDefId(other.getId());instanceRepository.saveAndFlush(instance);}); + assertThrows(IllegalStateException.class,()->service.query(1L,"100018","r",null)); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FormalFlowHttpFixtureMain.java b/src/test/java/com/yunhai/flowcenter/service/FormalFlowHttpFixtureMain.java new file mode 100644 index 0000000..0158f97 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FormalFlowHttpFixtureMain.java @@ -0,0 +1,55 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.FlowCenterApplication; +import com.yunhai.flowcenter.entity.*; +import com.yunhai.flowcenter.repository.*; +import com.yunhai.flowcenter.stub.StubUserClient; +import com.yunhai.platform.openapi.base.*; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.*; +import org.springframework.jdbc.core.JdbcTemplate; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** Test-only separate JVM: real FL HTTP/engine/database/callback producer; UB stays explicitly offline. */ +public class FormalFlowHttpFixtureMain { + public static final String GUID="eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + public static final String SOURCE="11111111-1111-4111-8111-111111111111"; + @TestConfiguration + static class IdentityFixtures { + @Bean @Primary OpenApiAppClient fixtureApps(ObjectMapper mapper) throws Exception { + OpenApiAppClient client=mock(OpenApiAppClient.class); + when(client.getAppByKey("100018")).thenReturn(mapper.readTree("{\"id\":18,\"sourcePrincipalId\":\""+SOURCE+"\",\"appCode\":\"svc-hall\"}")); + return client; + } + @Bean @Primary OpenApiUserClient fixtureUsers(){return new StubUserClient();} + @Bean @Primary UserBaseActivationClient fixtureActivation(){ + UserBaseActivationClient client=mock(UserBaseActivationClient.class); + when(client.isAppActivatedStrict(anyInt(),anyLong())).thenReturn(true);return client; + } + } + public static void main(String[] args) { + String callback=System.getenv("FL_FIXTURE_CALLBACK"); + if(callback==null || !callback.startsWith("http://127.0.0.1:"))throw new IllegalArgumentException("Isolated callback required"); + ConfigurableApplicationContext context=new SpringApplication(FlowCenterApplication.class,IdentityFixtures.class).run(args); + JdbcTemplate jdbc=context.getBean(JdbcTemplate.class); + if(jdbc.queryForObject("SELECT @@port",Integer.class)!=13326)throw new IllegalStateException("Unsafe database"); + if (!"fl_http_20261026".equals(jdbc.queryForObject("SELECT DATABASE()",String.class))) throw new IllegalStateException("Unsafe fixture schema"); + for (String table : jdbc.queryForList("SHOW TABLES",String.class)) { + if (!table.matches("flw_[a-z_]+|flow_callback_(event|reg)")) throw new IllegalStateException("Unexpected table in isolated schema"); + jdbc.update("DELETE FROM `"+table+"`"); + } + FlwDef def=new FlwDef();def.setSchId(1L);def.setFlowGuid(GUID);def.setCode("http-chain");def.setName("HTTP chain");def.setVersion(1);def.setStatus(1); + def.setContentJson("{\"start\":\"start\",\"nodes\":[{\"id\":\"start\",\"type\":\"START\"},{\"id\":\"review\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[101]}},{\"id\":\"end\",\"type\":\"END\"}],\"edges\":[{\"from\":\"start\",\"to\":\"review\"},{\"from\":\"review\",\"to\":\"end\"}]}"); + context.getBean(FlwDefRepository.class).saveAndFlush(def); + FlwUsageGrant grant=new FlwUsageGrant();grant.setSchId(1L);grant.setFlowGuid(GUID);grant.setAppId(18);grant.setSourcePrincipalId(SOURCE);grant.setEnabled(1); + context.getBean(FlwUsageGrantRepository.class).saveAndFlush(grant); + FlwCallbackReg reg=new FlwCallbackReg();reg.setSchId(1L);reg.setAppKey("100018");reg.setSourcePrincipalId(SOURCE);reg.setFlowGuid(GUID); + reg.setCallbackUrl(callback);reg.setSecret("isolated-callback-secret");reg.setStatus(0); + context.getBean(FlwCallbackRegRepository.class).saveAndFlush(reg); + System.out.println("FL_HTTP_FIXTURE_READY"); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizerTest.java b/src/test/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizerTest.java new file mode 100644 index 0000000..0ae0e4b --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizerTest.java @@ -0,0 +1,67 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.model.FormalApplyModels; +import com.yunhai.flowcenter.model.OapiModels; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class FormalPayloadCanonicalizerTest { + private final ObjectMapper mapper = new ObjectMapper(); + private final FormalPayloadCanonicalizer canonicalizer = new FormalPayloadCanonicalizer(mapper); + private FormalApplyModels.Request body() throws Exception { + return mapper.readValue("{\"requestId\":\"r1\",\"flowGuid\":\"flow\",\"bizType\":\"svc-hall\",\"bizId\":\"7\",\"submitterId\":201,\"expectedPreviousRound\":0}", FormalApplyModels.Request.class); + } + @Test void keyOrderAndNumericFormattingAreEquivalent() throws Exception { + FormalApplyModels.Request a=body(), b=body(); + a.setFormData(mapper.readValue("{\"b\":2,\"a\":1.0}",Map.class)); + b.setFormData(mapper.readValue("{\"a\":1,\"b\":2.00}",Map.class)); + assertEquals(canonicalizer.hash(a),canonicalizer.hash(b)); + } + @Test void nullEmptyAndMissingObjectValuesAreDistinct() throws Exception { + FormalApplyModels.Request a=body(); String none=canonicalizer.hash(a); + a.setFormData(Collections.emptyMap()); assertNotEquals(none,canonicalizer.hash(a)); + String empty=canonicalizer.hash(a); a.setFormData(Collections.singletonMap("x",null)); + assertNotEquals(empty,canonicalizer.hash(a)); + String nil=canonicalizer.hash(a); a.setFormData(Collections.singletonMap("x","")); + assertNotEquals(nil,canonicalizer.hash(a)); + } + @Test void snapshotStringAndObjectAreEquivalent() throws Exception { + FormalApplyModels.Request a=body(),b=body(); a.setBizSnapshot("{\"b\":2,\"a\":1}"); + b.setBizSnapshot(mapper.readValue("{\"a\":1,\"b\":2}",Map.class)); + assertEquals(canonicalizer.hash(a),canonicalizer.hash(b)); + } + @Test void personnelAndNodeOrderAreSetsButDuplicatesAreRejected() throws Exception { + FormalApplyModels.Request a=body(),b=body(); + OapiModels.AssignItem first=new OapiModels.AssignItem(); first.setNodeId("n"); first.setUserIds(Arrays.asList(3,2)); + OapiModels.AssignItem second=new OapiModels.AssignItem(); second.setNodeId("n"); second.setUserIds(Arrays.asList(2,3)); + a.setAssigns(Collections.singletonList(first)); b.setAssigns(Collections.singletonList(second)); + assertEquals(canonicalizer.hash(a),canonicalizer.hash(b)); + second.setUserIds(Arrays.asList(2,2)); assertThrows(RuntimeException.class,()->canonicalizer.hash(b)); + b.setAssigns(Arrays.asList(first,first)); assertThrows(RuntimeException.class,()->canonicalizer.hash(b)); + } + @Test void delimitersCannotCreateOldConcatenationCollisions() throws Exception { + FormalApplyModels.Request a=body(),b=body(); a.setBizTitle("a|b");a.setBizUrl("c"); + b.setBizTitle("a"); b.setBizUrl("b|c"); assertNotEquals(canonicalizer.hash(a),canonicalizer.hash(b)); + } + @Test void versionAndPredecessorAndPayloadChangesChangeHashButRequestIdDoesNot() throws Exception { + FormalApplyModels.Request a=body();String original=canonicalizer.hash(a); + a.setRequestId("other"); assertEquals(original,canonicalizer.hash(a)); + a.setExpectedDefVersion(2); assertNotEquals(original,canonicalizer.hash(a));a.setExpectedDefVersion(null); + a.setExpectedPreviousInstanceId(12L); assertNotEquals(original,canonicalizer.hash(a));a.setExpectedPreviousInstanceId(null); + a.setSubmitterName("changed"); assertNotEquals(original,canonicalizer.hash(a)); + } + @Test void ordinaryArrayOrderAndInvalidJsonAreNotSilentlyIgnored() throws Exception { + FormalApplyModels.Request a=body(),b=body(); + a.setFormData(Collections.singletonMap("items",Arrays.asList(1,2)));b.setFormData(Collections.singletonMap("items",Arrays.asList(2,1))); + assertNotEquals(canonicalizer.hash(a),canonicalizer.hash(b)); + a.setBizSnapshot("{} trailing"); assertThrows(RuntimeException.class,()->canonicalizer.hash(a)); + } + @Test void frozenWireVectorMatchesShIndependentImplementation() throws Exception { + FormalApplyModels.Request r=body();r.setBizTitle("User的Title");r.setBizUrl("/apply-detail?id=7"); + r.setBizSnapshot("{\"b\":2,\"a\":1}");r.setSubmitterName("User"); + r.setFormData(mapper.readValue("{\"n\":1.0}",Map.class)); + assertEquals("178c717d364631ba2956b549353306d713351bd9b98fd135b309d1d1574976d7",canonicalizer.hash(r)); + } +} 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: