release: FL 2.0 候选(C01 记账边界+P2-2 认领协议+D10 变量契约+D11 资格终审+D09 激活代次+T01/T03/T04/T05)
按《FL最终代码审计与上线阻断清单》194 项已审计工作树形成候选,基座 = origin/main 11e0778。
- 源码/测试/资源:沿用已审计工作树内容(96 项相对远端为新增或更新),含未跟踪实现
CallbackFactStore、CallbackSourcePolicy、VariableContract、FormalApplyCommandService、
FlowActorContextGate 等;C01 续验夹具修正一并入内。
- CI:保留远端 11e0778 的门禁强化(CI_SOURCE_SHA 核对、容器回收验证、空 schema 反向门禁、
main ref 才推镜像);ci/flow-test-schema.sql 按当前实体重新生成(14→20 表,补 6 新表与
6 新列,回调日志复合唯一键 uk_flw_log_event_reg),workflow 表数断言同步 14→20。
- 依据:docs/tasks/evidence/FL-final-audit-20261027(194 项哈希/版本核对)。
This commit is contained in:
@@ -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'@'%';" | \
|
||||
|
||||
+2
-2
@@ -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 已完成验证。
|
||||
|
||||
+171
-30
@@ -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 */;
|
||||
|
||||
@@ -100,6 +100,14 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- R2(Codex D04):正常/恢复投递信封与真实接收端 FlowCallbackReceiver 的契约测试用。
|
||||
仅 test 域——生产件不引入(FL 是回调生产方不是消费方);测试上下文显式关装配
|
||||
(application-test.yml yh.flow-callback.enabled=false),接收端由测试手工构造。 -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-flow-callback-spring-boot-starter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
@@ -28,10 +29,22 @@ import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 回调分发器(概览 §8.4 可靠性设计):引擎 AFTER_COMMIT 事件 → 匹配 flw_callback_reg
|
||||
* (flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
|
||||
* 回调分发器(概览 §8.4 可靠性设计;2026-10-25 R2 返修):引擎 AFTER_COMMIT 事件 →
|
||||
* 匹配 flw_callback_reg(flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
|
||||
* 落库成功即视为主业务完成,推送失败不影响审批(异步解耦是特性)。
|
||||
* @Scheduled(fixedDelay=30s) 扫 (status=0, next_retry_time<=now) 重推;死信留管理端手工重推。
|
||||
*
|
||||
* <p>R2(Codex D04)投递可靠性口径:
|
||||
* <ul>
|
||||
* <li><b>统一冻结信封</b>——事实行在状态事务内冻结完整投递体(含 eventId/eventSeq/flowGuid);
|
||||
* 正常分发优先复用冻结体,崩溃恢复分发原样复用同一冻结体,两条路径消息体同构;恢复体
|
||||
* 对 R2 前存量事实行按列值幂等补齐 eventId/eventSeq/flowGuid 后投出。</li>
|
||||
* <li><b>未可靠建行不记账</b>——任一匹配订阅方的投递行建行失败(含持久化异常)时,事实行
|
||||
* 不标记 dispatched:正常链不标记(恢复扫描补建),恢复链保持 dispatched=0 等下轮重扫;
|
||||
* 异步首推的提交失败不回退建行结果(行已在,重试扫描接手)。</li>
|
||||
* <li><b>并发幂等靠数据库唯一键</b>——(event_id, reg_id) 复合唯一约束兜底 exists→insert 竞态,
|
||||
* DuplicateKeyException 一律视为"已建行"幂等成功。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -48,6 +61,17 @@ public class CallbackDispatcher {
|
||||
private final CallbackPushService pushService;
|
||||
private final CallbackProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final com.yunhai.flowcenter.repository.FlwStateEventRepository stateEventRepository;
|
||||
private final CallbackFactStore factStore;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private CallbackSourcePolicy sourcePolicy;
|
||||
|
||||
private boolean self(FlwCallbackReg reg, FlwInstance instance) {
|
||||
if (instance.getSourcePrincipalId() == null) return true; // legacy cutover is a separate migration
|
||||
if (sourcePolicy == null) throw new IllegalStateException("Missing formal callback source policy");
|
||||
return sourcePolicy.matches(reg, instance);
|
||||
}
|
||||
|
||||
private volatile ExecutorService pushExecutor;
|
||||
|
||||
@@ -70,14 +94,129 @@ public class CallbackDispatcher {
|
||||
}
|
||||
try {
|
||||
dispatch(event);
|
||||
markFactDispatched(event);
|
||||
} catch (Exception e) {
|
||||
// 分发失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程
|
||||
log.error("[flow-center] 回调分发异常:instance={} event={} {}",
|
||||
// 分发/建行失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程;
|
||||
// R2:不标记 dispatched——恢复扫描稍后按事实行补建缺失的投递行
|
||||
log.error("[flow-center] 回调分发异常(事实保持待补分发):instance={} event={} {}",
|
||||
event.getInstanceId(), event.getType().getCode(), e.toString(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(FlowEvent event) {
|
||||
/** T05:事实行分发完成记账(崩溃窗口恢复扫描据此区分"已分发"与"待补分发") */
|
||||
private void markFactDispatched(FlowEvent event) {
|
||||
if (event.getStableEventId() == null) {
|
||||
return; // 无事实行(存量发布路径/记录器缺席),维持旧行为
|
||||
}
|
||||
try {
|
||||
// Cross the separate bean's proxy: the completed business transaction cannot commit this write.
|
||||
factStore.markDispatched(event.getStableEventId());
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 事件事实分发记账失败(重扫会幂等补):eventId={} {}",
|
||||
event.getStableEventId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T05 崩溃窗口恢复扫描:状态事务已提交但分发未及执行(进程被杀/分发异常)的事实行,
|
||||
* 过缓冲期后按存量事实补分发——幂等(同 eventId 已建投递行则跳过),不重放旧轮身份
|
||||
* (载荷即事实快照)。R2:任一订阅方建行失败则保持 dispatched=0,下轮重扫补齐。
|
||||
*/
|
||||
@Scheduled(fixedDelay = 60_000)
|
||||
public void sweepUndispatchedFacts() {
|
||||
try {
|
||||
java.util.List<com.yunhai.flowcenter.entity.FlwStateEvent> due =
|
||||
stateEventRepository.findTop50ByDispatchedAndCreateTimeLessThanEqualOrderByCreateTimeAsc(
|
||||
0, java.time.LocalDateTime.now().minusSeconds(60));
|
||||
for (com.yunhai.flowcenter.entity.FlwStateEvent fact : due) {
|
||||
executor().submit(() -> dispatchFact(fact));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 事件事实补分发扫描异常:{}", e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void dispatchFact(com.yunhai.flowcenter.entity.FlwStateEvent fact) {
|
||||
try {
|
||||
FlwInstance instance = instanceRepository.findById(fact.getInstanceId()).orElse(null);
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
if (def == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> stored = objectMapper.readValue(fact.getPayloadJson(), Map.class);
|
||||
java.util.List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
|
||||
instance.getSchId(), def.getFlowGuid(), 0);
|
||||
boolean allDelivered = true;
|
||||
for (FlwCallbackReg reg : regs) {
|
||||
if (!self(reg, instance)) continue;
|
||||
if (!subscribed(reg.getEventTypes(), fact.getEventType())) {
|
||||
continue;
|
||||
}
|
||||
if (logRepository.existsByEventIdAndRegId(fact.getEventId(), reg.getId())) {
|
||||
continue; // 幂等:崩溃前已建的投递行不重建
|
||||
}
|
||||
try {
|
||||
persistFactDelivery(reg, instance, def, fact, stored);
|
||||
} catch (DuplicateKeyException dup) {
|
||||
// uk(event_id, reg_id) 并发兜底:另一路径已建行,视为已投递
|
||||
} catch (Exception e) {
|
||||
allDelivered = false;
|
||||
log.error("[flow-center] 事件事实补分发建行失败(保留待重扫):eventId={} regId={} {}",
|
||||
fact.getEventId(), reg.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
if (!allDelivered) {
|
||||
return; // R2:未全部可靠建行,不标记 dispatched
|
||||
}
|
||||
fact.setDispatched(1);
|
||||
stateEventRepository.save(fact);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 事件事实补分发失败(下轮重扫):eventId={} {}", fact.getEventId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 补分发建行:载荷以事实冻结体为准(R2 冻结信封原样复用,与正常链同构);
|
||||
* R2 前存量事实行缺 eventId/eventSeq/flowGuid 时按列值幂等补齐。异步首推同正常链。
|
||||
*/
|
||||
private void persistFactDelivery(FlwCallbackReg reg, FlwInstance instance, FlwDef def,
|
||||
com.yunhai.flowcenter.entity.FlwStateEvent fact,
|
||||
Map<String, Object> stored) throws Exception {
|
||||
String payloadJson;
|
||||
if (stored.containsKey("eventId") && stored.containsKey("flowGuid")) {
|
||||
// R2 冻结体:完整信封已含全部投递字段,原样复用(不可变)
|
||||
payloadJson = fact.getPayloadJson();
|
||||
} else {
|
||||
// R2 前存量事实行:信封字段缺失,按事实列值+现查 flowGuid 幂等修复
|
||||
Map<String, Object> repaired = new HashMap<>(stored);
|
||||
repaired.put("eventId", fact.getEventId());
|
||||
if (fact.getEventSeq() != null) {
|
||||
repaired.put("eventSeq", fact.getEventSeq());
|
||||
}
|
||||
repaired.put("flowGuid", def.getFlowGuid());
|
||||
payloadJson = objectMapper.writeValueAsString(repaired);
|
||||
}
|
||||
FlwCallbackLog callbackLog = new FlwCallbackLog();
|
||||
callbackLog.setSchId(instance.getSchId());
|
||||
callbackLog.setRegId(reg.getId());
|
||||
callbackLog.setEventId(fact.getEventId());
|
||||
callbackLog.setEvent(fact.getEventType());
|
||||
callbackLog.setInstanceId(instance.getId());
|
||||
callbackLog.setBizType(instance.getBizType());
|
||||
callbackLog.setBizId(instance.getBizId());
|
||||
callbackLog.setPayloadJson(payloadJson);
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setRetryCount(0);
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now());
|
||||
Long logId = logStore.persist(callbackLog);
|
||||
submitPush(logId);
|
||||
}
|
||||
|
||||
private void dispatch(FlowEvent event) throws Exception {
|
||||
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null);
|
||||
if (instance == null) {
|
||||
return;
|
||||
@@ -90,6 +229,7 @@ public class CallbackDispatcher {
|
||||
List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
|
||||
instance.getSchId(), def.getFlowGuid(), 0);
|
||||
for (FlwCallbackReg reg : regs) {
|
||||
if (!self(reg, instance)) continue;
|
||||
if (!subscribed(reg.getEventTypes(), eventCode)) {
|
||||
continue;
|
||||
}
|
||||
@@ -97,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<String, Object> payload = new HashMap<>();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("event", event.getType().getCode());
|
||||
payload.put("schId", instance.getSchId());
|
||||
payload.put("flowGuid", def.getFlowGuid());
|
||||
payload.put("instanceId", instance.getId());
|
||||
payload.put("instanceVersion", instance.getVersion());
|
||||
payload.put("bizType", instance.getBizType());
|
||||
payload.put("bizId", instance.getBizId());
|
||||
payload.put("bizTitle", instance.getBizTitle());
|
||||
payload.put("occurredTime", LocalDateTime.now().format(TIME));
|
||||
// 2026-09-20 信封升级:任务定位与操作人(均可空,办件级事件/历史重放体为 null——
|
||||
// 契约见《子应用接入流程中心规范.md》§6.3/§6.4)
|
||||
payload.put("taskId", event.getTaskId());
|
||||
payload.put("operatorUserId", event.getOperatorId());
|
||||
payload.put("operatorName", event.getOperatorName());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
if (event.getData() != null) {
|
||||
data.putAll(event.getData());
|
||||
}
|
||||
// 身份在事件发布线程中冻结,不能用 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<String, Object> legacyEnvelope(FlowEvent event, FlwInstance instance, FlwDef def,
|
||||
String eventId) {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("eventId", eventId);
|
||||
if (event.getStableEventSeq() != null) {
|
||||
payload.put("eventSeq", event.getStableEventSeq());
|
||||
}
|
||||
payload.put("event", event.getType().getCode());
|
||||
payload.put("schId", instance.getSchId());
|
||||
payload.put("flowGuid", def.getFlowGuid());
|
||||
payload.put("instanceId", instance.getId());
|
||||
payload.put("instanceVersion", instance.getVersion());
|
||||
payload.put("bizType", instance.getBizType());
|
||||
payload.put("bizId", instance.getBizId());
|
||||
payload.put("bizTitle", instance.getBizTitle());
|
||||
payload.put("occurredTime", LocalDateTime.now().format(TIME));
|
||||
// 2026-09-20 信封升级:任务定位与操作人(均可空,办件级事件/历史重放体为 null——
|
||||
// 契约见《子应用接入流程中心规范.md》§6.3/§6.4)
|
||||
payload.put("taskId", event.getTaskId());
|
||||
payload.put("operatorUserId", event.getOperatorId());
|
||||
payload.put("operatorName", event.getOperatorName());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
if (event.getData() != null) {
|
||||
data.putAll(event.getData());
|
||||
}
|
||||
// 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。
|
||||
if (event.getRoundIdentity() != null) {
|
||||
data.putAll(event.getRoundIdentity());
|
||||
}
|
||||
payload.put("data", data);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** 重推扫描(fixedDelay 串行防重入;推拉结合里 Webhook 永远只是加速器) */
|
||||
@Scheduled(fixedDelay = 30_000)
|
||||
public void scanRetry() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,45 @@ package com.yunhai.flowcenter.callback;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.BeanPropertyRowMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 回调日志落库(独立 bean 保证 REQUIRES_NEW 生效):AFTER_COMMIT 监听阶段原事务已提交
|
||||
* 但事务资源仍挂在线程上,REQUIRED 会"加入"已完结事务并抛 no transaction is in progress——
|
||||
* 必须显式开新事务写日志。落库成功即视为主业务完成,推送失败不影响审批。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CallbackLogStore {
|
||||
|
||||
private final FlwCallbackLogRepository logRepository;
|
||||
private final DataSource dataSource;
|
||||
private final int claimIdleSeconds;
|
||||
|
||||
@Autowired
|
||||
public CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource) {
|
||||
this(logRepository, dataSource, 60);
|
||||
}
|
||||
|
||||
// 窄范围故障测试可缩短会话租期;生产固定 60s,大于正常 HTTP 5s 连接 + 10s 读取超时。
|
||||
CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource, int claimIdleSeconds) {
|
||||
if (claimIdleSeconds < 1) throw new IllegalArgumentException("claimIdleSeconds must be positive");
|
||||
this.logRepository = logRepository;
|
||||
this.dataSource = dataSource;
|
||||
this.claimIdleSeconds = claimIdleSeconds;
|
||||
}
|
||||
|
||||
/** @return 落库后的日志行 id */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
@@ -24,4 +48,198 @@ public class CallbackLogStore {
|
||||
FlwCallbackLog saved = logRepository.saveAndFlush(callbackLog);
|
||||
return saved.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 会话锁不是事务/行锁。专用借出连接始终 autocommit,HTTP 不占用数据库事务。
|
||||
* 连接空闲 60s 后由数据库关闭并释放锁;所有结果必须在原连接上验证锁所有权,禁止换连接补写。
|
||||
*/
|
||||
public Claim tryClaim(Long id) {
|
||||
Claim claim = openSession();
|
||||
try {
|
||||
if (!claim.acquire(id)) {
|
||||
claim.close();
|
||||
return null;
|
||||
}
|
||||
claim.row = claim.read(id);
|
||||
if (claim.row == null) {
|
||||
claim.close();
|
||||
return null;
|
||||
}
|
||||
return claim;
|
||||
} catch (RuntimeException | SQLException failure) {
|
||||
claim.close();
|
||||
throw new IllegalStateException("回调认领失败", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private Claim openSession() {
|
||||
Connection connection = null;
|
||||
try {
|
||||
// 不用 DataSourceUtils:绝不借用调用方已绑定的业务事务连接。
|
||||
connection = dataSource.getConnection();
|
||||
if (!connection.getAutoCommit()) throw new SQLException("callback claim requires autocommit");
|
||||
long previous;
|
||||
try (Statement st = connection.createStatement(); ResultSet rs = st.executeQuery("SELECT @@session.wait_timeout")) {
|
||||
rs.next(); previous = rs.getLong(1);
|
||||
}
|
||||
try (Statement st = connection.createStatement()) {
|
||||
st.execute("SET SESSION wait_timeout = " + claimIdleSeconds);
|
||||
}
|
||||
return new Claim(connection, previous);
|
||||
} catch (SQLException failure) {
|
||||
if (connection != null) {
|
||||
try { connection.abort(Runnable::run); } catch (SQLException ignored) { }
|
||||
try { connection.close(); } catch (SQLException ignored) { }
|
||||
}
|
||||
throw new IllegalStateException("回调认领连接不可用", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** 人工重推先完成整批短事务,再交回推送层发 HTTP;成功行不能被旧管理快照复活。 */
|
||||
public List<Long> resetForReplay(Long school, Collection<Long> ids) {
|
||||
try (Claim claim = openSession()) {
|
||||
List<Long> sorted = new ArrayList<>(new TreeSet<>(ids));
|
||||
for (Long id : sorted) {
|
||||
if (!claim.acquire(id)) throw new BizException(ErrorCode.CUSTOM, "回调正在派发,请稍后重推:" + id);
|
||||
FlwCallbackLog row = claim.read(id);
|
||||
if (row == null || !school.equals(row.getSchId())) throw new BizException(ErrorCode.FORBIDDEN, "回调日志不存在或不属于该校");
|
||||
}
|
||||
claim.connection.setAutoCommit(false);
|
||||
List<Long> reset = new ArrayList<>();
|
||||
try {
|
||||
for (Long id : sorted) {
|
||||
// 也检查所有权,旧 MySQL 若不支持同会话多锁会 fail closed,而不会无锁更新。
|
||||
if (!claim.owns(id)) throw new BizException(ErrorCode.CUSTOM, "回调派发权已失效,请重试");
|
||||
int changed = claim.update(id, "status=0, retry_count=0, next_retry_time=?, update_time=?, update_by=?",
|
||||
"status in (0,2)", Timestamp.valueOf(LocalDateTime.now()), Timestamp.valueOf(LocalDateTime.now()), actor());
|
||||
if (changed == 1) reset.add(id);
|
||||
}
|
||||
claim.connection.commit();
|
||||
return reset;
|
||||
} catch (RuntimeException | SQLException failure) {
|
||||
claim.connection.rollback();
|
||||
throw failure;
|
||||
} finally { claim.connection.setAutoCommit(true); }
|
||||
} catch (SQLException failure) {
|
||||
throw new IllegalStateException("回调重推状态提交失败", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String actor() {
|
||||
com.yunhai.platform.auth.LoginUser user = com.yunhai.platform.auth.UserContextHolder.get();
|
||||
return user == null ? null : user.getLoginName();
|
||||
}
|
||||
|
||||
/** 令牌即拥有命名锁的原始数据库会话;状态更新只能在该连接上进行。 */
|
||||
public static final class Claim implements AutoCloseable {
|
||||
private final Connection connection;
|
||||
private final long previousWaitTimeout;
|
||||
private final Map<Long, String> keys = new LinkedHashMap<>();
|
||||
private FlwCallbackLog row;
|
||||
private boolean closed;
|
||||
private boolean settled;
|
||||
|
||||
private Claim(Connection connection, long previousWaitTimeout) {
|
||||
this.connection = connection;
|
||||
this.previousWaitTimeout = previousWaitTimeout;
|
||||
}
|
||||
|
||||
public FlwCallbackLog row() { return row; }
|
||||
|
||||
public boolean active() {
|
||||
try { return !closed && row != null && owns(row.getId()); }
|
||||
catch (SQLException expired) { return false; }
|
||||
}
|
||||
|
||||
private boolean acquire(Long id) throws SQLException {
|
||||
String key;
|
||||
// 64 字符锁名:库名隔离,同 (eventId,regId) 的全部执行者用同一身份。
|
||||
try (PreparedStatement st = connection.prepareStatement(
|
||||
"SELECT SHA2(CONCAT(LOWER(DATABASE()), ':flw_callback:', event_id, ':', reg_id),256) FROM flw_callback_log WHERE id=?")) {
|
||||
st.setLong(1, id);
|
||||
try (ResultSet rs = st.executeQuery()) { if (!rs.next()) return false; key = rs.getString(1); }
|
||||
}
|
||||
try (PreparedStatement st = connection.prepareStatement("SELECT GET_LOCK(?,0)")) {
|
||||
st.setString(1, key);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
rs.next();
|
||||
if (rs.getInt(1) != 1 || rs.wasNull()) return false;
|
||||
}
|
||||
}
|
||||
keys.put(id, key);
|
||||
return true;
|
||||
}
|
||||
|
||||
private FlwCallbackLog read(Long id) throws SQLException {
|
||||
try (PreparedStatement st = connection.prepareStatement("SELECT * FROM flw_callback_log WHERE id=?")) {
|
||||
st.setLong(1, id);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
return rs.next() ? new BeanPropertyRowMapper<>(FlwCallbackLog.class).mapRow(rs, 0) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean owns(Long id) throws SQLException {
|
||||
try (PreparedStatement st = connection.prepareStatement("SELECT IS_USED_LOCK(?)=CONNECTION_ID()")) {
|
||||
st.setString(1, keys.get(id));
|
||||
try (ResultSet rs = st.executeQuery()) { rs.next(); return rs.getBoolean(1); }
|
||||
}
|
||||
}
|
||||
|
||||
private int update(Long id, String assignments, String condition, Object... values) throws SQLException {
|
||||
try (PreparedStatement st = connection.prepareStatement("UPDATE flw_callback_log SET " + assignments
|
||||
+ " WHERE id=? AND " + condition + " AND IS_USED_LOCK(?)=CONNECTION_ID()")) {
|
||||
int i=1;
|
||||
for (Object value : values) st.setObject(i++, value);
|
||||
st.setLong(i++, id); st.setString(i, keys.get(id));
|
||||
return st.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean success(int httpStatus) { return finish("status=1, last_http_status=?, last_error=NULL, push_time=?, next_retry_time=NULL",
|
||||
String.valueOf(httpStatus), Timestamp.valueOf(LocalDateTime.now())); }
|
||||
|
||||
public boolean failure(String error, String httpStatus, long backoffMinutes) {
|
||||
return finish("retry_count=COALESCE(retry_count,0)+1, last_http_status=?, last_error=?, next_retry_time=?",
|
||||
httpStatus, error, Timestamp.valueOf(LocalDateTime.now().plusMinutes(backoffMinutes)));
|
||||
}
|
||||
|
||||
public boolean dead(String error) { return finish("status=2, last_error=?", error); }
|
||||
|
||||
private boolean finish(String assignments, Object... values) {
|
||||
if (closed || settled || row == null) return false;
|
||||
Object[] audited = Arrays.copyOf(values, values.length+2);
|
||||
audited[values.length] = Timestamp.valueOf(LocalDateTime.now()); audited[values.length+1] = actor();
|
||||
try {
|
||||
// count 是附加 CAS;不是把旧实体全量 merge 回库。会话失权/成功/人工处理后均不能覆盖。
|
||||
settled = update(row.getId(), assignments + ", update_time=?, update_by=?",
|
||||
"status=0 AND retry_count=" + row.getRetryCount(), audited) == 1;
|
||||
if (!settled) log.debug("[flow-center] 回调旧结果已忽略:logId={}", row.getId());
|
||||
return settled;
|
||||
} catch (SQLException failure) {
|
||||
log.warn("[flow-center] 回调结果未记账(会话失效或数据库不可用,留待重试):logId={} {}", row.getId(), failure.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
for (String key : keys.values()) {
|
||||
try (PreparedStatement st = connection.prepareStatement("SELECT RELEASE_LOCK(?)")) {
|
||||
st.setString(1, key); st.execute();
|
||||
}
|
||||
}
|
||||
try (Statement st = connection.createStatement()) {
|
||||
st.execute("SET SESSION wait_timeout = " + previousWaitTimeout);
|
||||
}
|
||||
} catch (SQLException failedRelease) {
|
||||
// 不能把可能残留会话锁的物理连接还给池;显式中止,让数据库释放全部锁。
|
||||
try { connection.abort(Runnable::run); } catch (SQLException ignored) { }
|
||||
} finally {
|
||||
try { connection.close(); } catch (SQLException ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ import java.util.UUID;
|
||||
* <b>持续重试不终止</b>(T1.1:消费方只承诺"瞬时失败回 5xx",恢复后自动追平,消费方免对账);
|
||||
* 挂起(retryCount 首达 hang-alert-retries)时 UserMsg 告警恰一次;
|
||||
* 死信(status=2)仅两个入口——注册停用、管理端手工标记,重试次数永不自动触发。
|
||||
* 推送一律在新事务里落库(AFTER_COMMIT 监听器/调度线程均无外层事务)。
|
||||
* HTTP 在业务事务之外执行,结果在认领会话上以自动提交的条件 UPDATE 落定。
|
||||
* 推送本身与落库解耦:payload_json 是完整快照,可重放。
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -40,43 +40,72 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class CallbackPushService {
|
||||
|
||||
private static final Set<Long> IN_FLIGHT = new HashSet<>();
|
||||
/** 仅观测,不阻断数据库租期后的恢复;每个执行者有独立本地状态。 */
|
||||
private final Map<Long, Integer> IN_FLIGHT = new HashMap<>();
|
||||
|
||||
/** 行是否有本地执行者在途(仅观测;互斥由数据库认领负责)。 */
|
||||
boolean inFlight(Long logId) {
|
||||
synchronized (IN_FLIGHT) {
|
||||
return IN_FLIGHT.containsKey(logId);
|
||||
}
|
||||
}
|
||||
|
||||
private final FlwCallbackLogRepository logRepository;
|
||||
private final FlwCallbackRegRepository regRepository;
|
||||
private final CallbackProperties properties;
|
||||
private final CallbackLogStore logStore;
|
||||
private final UserMsgClient userMsgClient;
|
||||
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
|
||||
appIdentityProvider;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private CallbackSourcePolicy sourcePolicy;
|
||||
|
||||
/** 推送指定日志行(幂等入口:成功/终态/在途跳过) */
|
||||
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
|
||||
public void pushAsync(Long logId) {
|
||||
synchronized (IN_FLIGHT) {
|
||||
if (!IN_FLIGHT.add(logId)) {
|
||||
IN_FLIGHT.merge(logId, 1, Integer::sum);
|
||||
}
|
||||
try (CallbackLogStore.Claim claim = logStore.tryClaim(logId)) {
|
||||
if (claim == null) {
|
||||
log.debug("[flow-center] 回调数据库派发权未取得:logId={}", logId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
doPush(logId);
|
||||
doPush(claim);
|
||||
} finally {
|
||||
synchronized (IN_FLIGHT) {
|
||||
IN_FLIGHT.remove(logId);
|
||||
IN_FLIGHT.computeIfPresent(logId, (id, count) -> count == 1 ? null : count - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void doPush(Long logId) {
|
||||
FlwCallbackLog callbackLog = logRepository.findById(logId).orElse(null);
|
||||
private void doPush(CallbackLogStore.Claim claim) {
|
||||
FlwCallbackLog callbackLog = claim.row();
|
||||
if (callbackLog == null
|
||||
|| callbackLog.getStatus() != FlwEnums.CallbackLogStatus.PENDING.getCode()) {
|
||||
log.debug("[flow-center] 回调推送早退(行缺席或非待推):logId={} status={}",
|
||||
callbackLog == null ? null : callbackLog.getId(), callbackLog == null ? null : callbackLog.getStatus());
|
||||
return;
|
||||
}
|
||||
FlwCallbackReg reg = regRepository.findById(callbackLog.getRegId()).orElse(null);
|
||||
if (reg == null || reg.getStatus() != 0) {
|
||||
// 注册已停用:保留日志待查,不再推送(置死信便于管理端一次性清理)
|
||||
markDead(callbackLog, "registration disabled");
|
||||
markDead(claim, "registration disabled");
|
||||
return;
|
||||
}
|
||||
if (sourcePolicy != null) {
|
||||
try {
|
||||
if (!sourcePolicy.permits(reg, callbackLog)) {
|
||||
markDead(claim, "SELF source or command identity mismatch");
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException unavailable) {
|
||||
markRetryOrFail(claim, "source authentication unavailable", null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!claim.active()) return; // 权威查询迟到/会话租期已失效:不再发送。
|
||||
String body = callbackLog.getPayloadJson();
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
@@ -94,41 +123,34 @@ public class CallbackPushService {
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
int status = resp.getStatusCodeValue();
|
||||
if (status >= 200 && status < 300) {
|
||||
markSuccess(callbackLog, status);
|
||||
markSuccess(claim, status);
|
||||
} else {
|
||||
markRetryOrFail(callbackLog, "HTTP " + status, String.valueOf(status));
|
||||
markRetryOrFail(claim, "HTTP " + status, String.valueOf(status));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
markRetryOrFail(callbackLog, rootMessage(e), null);
|
||||
markRetryOrFail(claim, rootMessage(e), null);
|
||||
}
|
||||
}
|
||||
|
||||
private void markSuccess(FlwCallbackLog callbackLog, int httpStatus) {
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.SUCCESS.getCode());
|
||||
callbackLog.setRetryCount(callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount());
|
||||
callbackLog.setLastHttpStatus(String.valueOf(httpStatus));
|
||||
callbackLog.setLastError(null);
|
||||
callbackLog.setPushTime(LocalDateTime.now());
|
||||
saveQuietly(callbackLog);
|
||||
log.debug("[flow-center] 回调推送成功:eventId={} http={}", callbackLog.getEventId(), httpStatus);
|
||||
private void markSuccess(CallbackLogStore.Claim claim, int httpStatus) {
|
||||
if (claim.success(httpStatus)) {
|
||||
log.debug("[flow-center] 回调推送成功:eventId={} http={}", claim.row().getEventId(), httpStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败落定(持续重试语义):越过退避表后按 {@link CallbackProperties#getCapBackoffMinutes()}
|
||||
* 封顶继续排期,<b>绝不自动死信</b>;retryCount 首次等于挂起阈值时告警恰一次。
|
||||
*/
|
||||
private void markRetryOrFail(FlwCallbackLog callbackLog, String error, String httpStatus) {
|
||||
private void markRetryOrFail(CallbackLogStore.Claim claim, String error, String httpStatus) {
|
||||
FlwCallbackLog callbackLog = claim.row();
|
||||
int retryCount = (callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount()) + 1;
|
||||
callbackLog.setRetryCount(retryCount);
|
||||
callbackLog.setLastHttpStatus(httpStatus);
|
||||
callbackLog.setLastError(truncate(error));
|
||||
java.util.List<Long> backoff = properties.getRetryBackoffMinutes();
|
||||
long backoffMinutes = retryCount <= backoff.size()
|
||||
? backoff.get(retryCount - 1)
|
||||
: Math.max(1, properties.getCapBackoffMinutes());
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now().plusMinutes(backoffMinutes));
|
||||
saveQuietly(callbackLog);
|
||||
if (!claim.failure(truncate(error), httpStatus, backoffMinutes)) return;
|
||||
callbackLog.setRetryCount(retryCount); // 仅用于日志/告警;数据库已原子 +1,绝不 merge 此快照。
|
||||
log.warn("[flow-center] 回调推送失败(第 {} 次重试,{} 分钟后):eventId={} {}",
|
||||
retryCount, backoffMinutes, callbackLog.getEventId(), error);
|
||||
// 挂起告警恰一次:retryCount 每失败 +1 单调递增,等于阈值的时机只有一次;
|
||||
@@ -163,9 +185,20 @@ public class CallbackPushService {
|
||||
* 仍被注册停用场景使用(doPush:注册已停用即无意义重试,置死信便于管理端清理)。
|
||||
*/
|
||||
public void markDead(FlwCallbackLog callbackLog, String error) {
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.DEAD.getCode());
|
||||
callbackLog.setLastError(truncate(error));
|
||||
saveQuietly(callbackLog);
|
||||
try (CallbackLogStore.Claim claim = logStore.tryClaim(callbackLog.getId())) {
|
||||
if (claim == null) throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.CUSTOM, "回调正在派发,请稍后标记");
|
||||
if (claim.row().getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "已成功日志无需标记死信");
|
||||
}
|
||||
markDead(claim, error);
|
||||
}
|
||||
}
|
||||
|
||||
private void markDead(CallbackLogStore.Claim claim, String error) {
|
||||
if (!claim.dead(truncate(error))) return;
|
||||
FlwCallbackLog callbackLog = claim.row();
|
||||
log.error("[flow-center] 回调推送进入死信:eventId={} biz={}/{} error={}",
|
||||
callbackLog.getEventId(), callbackLog.getBizType(), callbackLog.getBizId(), error);
|
||||
alertDead(callbackLog, error);
|
||||
@@ -203,14 +236,6 @@ public class CallbackPushService {
|
||||
}
|
||||
}
|
||||
|
||||
private void saveQuietly(FlwCallbackLog callbackLog) {
|
||||
try {
|
||||
logRepository.save(callbackLog);
|
||||
} catch (Exception e) {
|
||||
log.error("[flow-center] 回调日志落库失败:eventId={} {}", callbackLog.getEventId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调探测(2026-10-11 管理端增强):以该注册的 secret 按现行信封签一条未知事件码 flow.ping,
|
||||
* 走真实推送管道同步一发——接收方按规范 §6.4 对未知事件码"记日志后 200",不破坏业务数据。
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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 返修冻结完整信封):普通同步监听器,与发布方
|
||||
* <b>同一事务</b>执行——状态变更提交成功 ⇔ 不可变事件事实已落库,关闭"提交到 AFTER_COMMIT
|
||||
* 分发之间进程被杀,事件永久丢失"的窗口(审计 E24)。
|
||||
*
|
||||
* <p>职责边界:只落事实行并给 {@link FlowEvent} 赋<b>稳定身份</b>与<b>冻结信封</b>
|
||||
* (eventId 全订阅方/全部重试复用;eventSeq 实例内单调;R2 起信封含 eventId/eventSeq/flowGuid
|
||||
* 全部投递字段——正常分发与崩溃恢复共用同一冻结体,Codex D04"恢复消息体缺 eventId"关闭);
|
||||
* 回调分发、订阅匹配、推送仍归 {@link CallbackDispatcher}。URGE 站内信不入事件流(既有口径)。
|
||||
* 无事务的发布(fallbackExecution 场景)退化为自动提交直写——事实仍落,只是失去与状态的
|
||||
* 同事务原子性(记录 warn,不静默)。</p>
|
||||
*/
|
||||
@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<String, Object> fullEnvelope(FlowEvent event, FlwInstance instance,
|
||||
FlwDef def, String eventId, Integer eventSeq) {
|
||||
Map<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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/**");
|
||||
}
|
||||
}
|
||||
@@ -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<com.yunhai.flowcenter.entity.FlwUsageGrant> usageGrantList(
|
||||
BoaUser user, @RequestParam("flowGuid") String flowGuid) {
|
||||
return usageGrantAdminService.listByFlow(adminGuard.requireAdmin(user, null), flowGuid);
|
||||
}
|
||||
|
||||
/** 授予/重申使用授权(appKey 当场解析为 UserBase 稳定主体落库;enforce 开启时校学校开通) */
|
||||
@PostMapping("/usage-grant/grant")
|
||||
public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantGrant(
|
||||
BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return usageGrantAdminService.grant(schId,
|
||||
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")),
|
||||
body.get("appKey") == null ? null : String.valueOf(body.get("appKey")),
|
||||
body.get("remark") == null ? null : String.valueOf(body.get("remark")),
|
||||
user.getName());
|
||||
}
|
||||
|
||||
/** 撤销使用授权(enabled=0 行保留;只拦新发起,在途与已受理命令不受影响) */
|
||||
@PostMapping("/usage-grant/revoke")
|
||||
public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantRevoke(
|
||||
BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return usageGrantAdminService.revoke(schId,
|
||||
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")),
|
||||
body.get("appId") == null ? null : Integer.valueOf(String.valueOf(body.get("appId"))),
|
||||
body.get("remark") == null ? null : String.valueOf(body.get("remark")),
|
||||
user.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/def/get")
|
||||
public FlwDef defGet(BoaUser user, @RequestParam("id") Long id) {
|
||||
return defAdminService.get(adminGuard.requireAdmin(user, null), id);
|
||||
@@ -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<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return defAdminService.offlineLine(schId,
|
||||
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
|
||||
}
|
||||
|
||||
/** 线级恢复:只恢复线可用性,不改写任何版本状态或发布内容 */
|
||||
@PostMapping("/def/enableLine")
|
||||
public int defEnableLine(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return defAdminService.enableLine(schId,
|
||||
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
|
||||
}
|
||||
|
||||
/** 在途办件数(议题二④):flowGuids 逗号分隔,去重后最多 100;返回 flowGuid → 审批中办件数(无在途也返 0 键) */
|
||||
@GetMapping("/def/inflight")
|
||||
public Map<String, Long> defInflight(BoaUser user, @RequestParam("flowGuids") String flowGuids) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.yunhai.flowcenter.controller.api;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.service.FlowAccessGuard;
|
||||
import com.yunhai.flowcenter.service.FlowDiagramService;
|
||||
import com.yunhai.flowcenter.service.FlowInstanceService;
|
||||
@@ -41,6 +42,22 @@ public class FlowUserController {
|
||||
private final FlowDiagramService diagramService;
|
||||
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
|
||||
|
||||
/** oapi 预览服务复用(T07 托管选人:候选+版本锚同源,字段注入防构造钉) */
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.yunhai.flowcenter.service.OapiFlowService oapiFlowService;
|
||||
|
||||
/**
|
||||
* 发起时指派预览(2026-10-25 T07 托管选人 iframe 数据源,F6 预置件点亮):
|
||||
* CAS 会话取学校+提交人,复用 oapi applyPreview(含 T03 线级停发守卫)——
|
||||
* 返回 {flowGuid, defVersion, nodes:[{nodeId,nodeName,countersign,selection,candidates}]}。
|
||||
* <b>只读预览</b>:不建实例、不建任务、不落任何流程事实;正式提交仍走 apply 终审
|
||||
* (前端候选不是授权凭据)。
|
||||
*/
|
||||
@GetMapping("/apply/preview")
|
||||
public OapiModels.ApplyPreviewVo applyPreview(BoaUser user, @RequestParam("flowGuid") String flowGuid) {
|
||||
return oapiFlowService.applyPreview(requireSchId(user), flowGuid, user.getId());
|
||||
}
|
||||
|
||||
/** 我提交的办件 */
|
||||
@GetMapping("/my")
|
||||
public BasePageResult<FlowModels.InstanceItem> my(BoaUser user,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 可选过滤 */
|
||||
|
||||
@@ -15,7 +15,8 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源,
|
||||
* 零跨服务依赖);USERS 定义直配,姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。
|
||||
* 零跨服务依赖);USERS 定义直配。审批责任人统一走 D11 资格终审(存在+启用+校域);
|
||||
* 仅 NOTIFY 抄送接收人保留宽容解析(姓名补全失败降级空名、不校验资格)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -32,13 +33,31 @@ public class AssigneeResolver {
|
||||
}
|
||||
List<AssigneeRef> refs = new ArrayList<>();
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
// D11:科室启用 ≠ 成员都可办理——逐人走同一资格终审;不合格成员按既有
|
||||
// “只取启用成员”口径剔除(local status 已在 resolveEnabled 过滤),并留日志。
|
||||
List<String> rejected = new ArrayList<>();
|
||||
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
|
||||
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
|
||||
try {
|
||||
requireEligibleAssignee(schId, m.getUserId());
|
||||
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
|
||||
} catch (BizException e) {
|
||||
if (e.getErrorCode() == ErrorCode.INTERNAL_SERVER_ERROR) {
|
||||
throw e; // 权威源不可达≠成员不合格,不能降级成“无人可用”
|
||||
}
|
||||
rejected.add(m.getUserId() + "=" + e.getMessage());
|
||||
log.warn("[flow-center] 科室成员资格校验不通过,跳过:schId={} deptKey={} userId={} reason={}",
|
||||
schId, a.deptKey, m.getUserId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
if (refs.isEmpty() && !rejected.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"节点[" + node.id + "]无可用办理人:科室成员均不具备办理资格("
|
||||
+ String.join(";", rejected) + ")");
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||||
if (a.userIds != null) {
|
||||
for (Integer uid : a.userIds) {
|
||||
refs.add(new AssigneeRef(uid, fetchName(uid)));
|
||||
refs.add(new AssigneeRef(uid, requireEligibleAssigneeName(schId, uid)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -51,10 +70,30 @@ public class AssigneeResolver {
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* D11 固定 USERS 整体预检:流程启动前逐节点核验全部固定审批人,任何一人不存在/停用/跨校
|
||||
* 即整体拒绝——不允许“过滤掉非法人后继续”。运行时建任务仍复用 {@link #resolve} 兜底。
|
||||
*/
|
||||
public void requireEligibleFixedAssignees(Long schId, FlowDefModel model) {
|
||||
for (FlowDefModel.Node node : model.nodes) {
|
||||
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignee == null
|
||||
|| !FlowDefModel.ASSIGNEE_USERS.equals(node.assignee.type)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
resolve(schId, node);
|
||||
} catch (BizException e) {
|
||||
throw new BizException(e.getErrorCode(),
|
||||
"节点[" + node.id + "]固定审批人配置非法:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义),
|
||||
* 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
|
||||
* 绝不能因抄送对象缺失卡住流程推进。与 {@link #resolve} 的强口径相反。
|
||||
* 绝不能因抄送对象缺失卡住流程推进。D11 的资格终审只约束审批责任人,本方法刻意不校验
|
||||
* 存在/启用/校域,直接复用既有姓名补全容错。
|
||||
*/
|
||||
public List<AssigneeRef> resolveTolerant(Long schId, FlowDefModel.Node node) {
|
||||
FlowDefModel.Assignee a = node.receivers;
|
||||
@@ -89,24 +128,52 @@ public class AssigneeResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 移交目标校验(严格口径,与 {@link #fetchName} 的降级容错相反):
|
||||
* 查不到就必须拒绝——否则会建出一条谁在待办里都看不到的僵尸任务。
|
||||
* UserBase 不可达与"人不存在"分别报错,便于排查。
|
||||
* 审批责任人资格终审(D11 唯一权威入口):存在 + 启用(status=1,null fail-closed)
|
||||
* + 属于当前学校。UserBase 不可达与“人不合格”分别报错,前者不可降级为过滤。
|
||||
*/
|
||||
public String requireUserName(Integer userId) {
|
||||
public BoaUser requireEligibleAssignee(Long schId, Integer userId) {
|
||||
BoaUser user = requireValidUser(userId);
|
||||
Long userSchId = user.getSchool() == null || user.getSchool().getId() == null
|
||||
? null : Long.valueOf(user.getSchool().getId());
|
||||
if (schId == null || !schId.equals(userSchId)) {
|
||||
log.warn("[flow-center] 办理人不属于本校,拒绝:userId={} userSchool={} schId={}",
|
||||
userId, userSchId, schId);
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"办理人不属于该学校,不能办理任务:" + userId
|
||||
+ "(所属学校 " + (userSchId == null ? "未知" : userSchId)
|
||||
+ ",要求 " + schId + ")");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 资格终审通过后返回姓名快照;姓名缺失保留空串(既有口径)。 */
|
||||
public String requireEligibleAssigneeName(Long schId, Integer userId) {
|
||||
BoaUser user = requireEligibleAssignee(schId, userId);
|
||||
return user.getName() == null ? "" : user.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* 存在 + 启用子检查。只供本类资格终审复用;外部审批责任人必须走
|
||||
* {@link #requireEligibleAssignee}(含校域),避免漏掉学校范围。
|
||||
*/
|
||||
private BoaUser requireValidUser(Integer userId) {
|
||||
if (userId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象");
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少办理人");
|
||||
}
|
||||
BoaUser user;
|
||||
try {
|
||||
user = userClient.getUserById(String.valueOf(userId));
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 移交对象校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "移交对象校验失败,请稍后重试:" + userId);
|
||||
log.warn("[flow-center] 办理人资格校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "办理人资格校验失败,请稍后重试:" + userId);
|
||||
}
|
||||
if (user == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "移交对象不存在:" + userId);
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理人不存在:" + userId);
|
||||
}
|
||||
return user.getName() == null ? "" : user.getName();
|
||||
if (!Integer.valueOf(1).equals(user.getStatus())) {
|
||||
log.warn("[flow-center] 办理人为禁用/未知状态账号,拒绝:userId={} status={}", userId, user.getStatus());
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理人为禁用账号,不能办理任务:" + userId);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class DefValidator {
|
||||
*/
|
||||
public static void validate(FlowDefModel model, Set<String> enabledDeptKeys, Set<String> enabledRouterKeys) {
|
||||
List<String> problems = new ArrayList<>();
|
||||
VariableContract.validateDefinition(model, false);
|
||||
|
||||
if (model.nodes == null || model.nodes.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点");
|
||||
@@ -248,7 +249,7 @@ public final class DefValidator {
|
||||
* (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
|
||||
*
|
||||
* <p>EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦
|
||||
* (表单变量是子应用运行时传的,发布期无从核对 schema,一律放行、缺失按 null 注入求值)。
|
||||
* 新契约的变量引用/类型/值域由 VariableContract 统一检查;历史无声明版本保持兼容。
|
||||
*/
|
||||
private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set<String> enabledRouterKeys,
|
||||
List<String> problems) {
|
||||
|
||||
@@ -1,81 +1,201 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.entity.*;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.flowcenter.repository.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
|
||||
/**
|
||||
* 节点放行推进(AFTER_COMMIT 监听器经代理调用本 bean,REQUIRES_NEW 生效——
|
||||
* 事务已提交后的同步监听阶段没有活动事务,@Modifying 批量语句必须有新事务承载):
|
||||
* 该节点待办清零且办件在途才推进;并发冲突由 flw_instance.version 乐观锁暴露,监听器层负责重试。
|
||||
*/
|
||||
@Slf4j
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/** D09: durable activation membership, serialized last-handler payload, and revision-scoped settlement. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowAdvanceService {
|
||||
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
private final FlwAdvanceIntentRepository intentRepository;
|
||||
@PersistenceContext private EntityManager entityManager;
|
||||
|
||||
/** The listener groups the existing totalTasks TASK_CREATED events within their creation transaction. */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public Long registerTask(FlowEvent event, Long activationIntentId) {
|
||||
String nodeId = (String) event.getData().get("nodeId");
|
||||
FlwAdvanceIntent intent;
|
||||
if (activationIntentId == null) {
|
||||
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElseThrow(IllegalStateException::new);
|
||||
intent = new FlwAdvanceIntent();
|
||||
intent.setInstanceId(instance.getId());intent.setNodeId(nodeId);
|
||||
intent.setActivationTaskId(event.getTaskId());
|
||||
intent.setActivationDefId(instance.getDefId());intent.setActivationDefVersion(instance.getDefVersion());
|
||||
intent.setActivationApplyRound(instance.getApplyRound());
|
||||
intent.setStatus(FlwAdvanceIntent.STATE_DONE); // Registration only; no advance work before handling.
|
||||
intent.setNextRetryAt(LocalDateTime.now());
|
||||
} else {
|
||||
intent = intentRepository.findById(activationIntentId).orElseThrow(IllegalStateException::new);
|
||||
if (!Objects.equals(intent.getInstanceId(), event.getInstanceId()) || !Objects.equals(intent.getNodeId(), nodeId)) {
|
||||
throw new IllegalStateException("Activation registration identity mismatch");
|
||||
}
|
||||
}
|
||||
intent.addTask(event.getTaskId());
|
||||
return intentRepository.save(intent).getId();
|
||||
}
|
||||
|
||||
/** TRANSFERRED lacks the parent task ID. Accept only a uniquely proven parent activation, never guess by max ID. */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void registerTransfer(FlowEvent event) {
|
||||
String nodeId = (String) event.getData().get("fromNodeId");
|
||||
List<FlwAdvanceIntent> generations = intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId);
|
||||
Set<Long> parents = new HashSet<>();
|
||||
List<Long> candidates = taskRepository.findTransferParentCandidates(event.getTaskId(), event.getOperatorId());
|
||||
for (Long taskId : candidates) {
|
||||
FlwAdvanceIntent parent = memberOf(generations, taskId);
|
||||
if (parent == null) throw new IllegalStateException("D09 transfer parent requires audited activation backfill");
|
||||
parents.add(parent.getId());
|
||||
}
|
||||
if (parents.size() != 1) throw new IllegalStateException("D09 ambiguous transfer activation; parent identity is required");
|
||||
FlwAdvanceIntent parent = lockIntent(parents.iterator().next());
|
||||
parent.addTask(event.getTaskId());
|
||||
intentRepository.save(parent);
|
||||
}
|
||||
|
||||
/** Called synchronously in the task transaction, after claimAndFinish. The head lock orders commits, not clocks/IDs. */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void recordHandled(FlowEvent event) {
|
||||
String nodeId = (String) event.getData().get("nodeId");
|
||||
FlwAdvanceIntent candidate = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId), event.getTaskId());
|
||||
if (candidate == null) throw new IllegalStateException("D09 task has no proven activation; audited backfill required");
|
||||
FlwAdvanceIntent intent = lockIntent(candidate.getId());
|
||||
if (intent.handledTask(event.getTaskId())) return; // Duplicate/late publication cannot rewind the last handler.
|
||||
intent.addHandledTask(event.getTaskId());intent.setTriggerTaskId(event.getTaskId());
|
||||
intent.setEdgeName((String) event.getData().get("edgeName"));
|
||||
intent.setAssigneeId((Integer) event.getData().get("assigneeId"));
|
||||
intent.setAssigneeName((String) event.getData().get("assigneeName"));
|
||||
intent.setStatus(FlwAdvanceIntent.STATE_PENDING);intent.setAttempts(0);
|
||||
intent.setNextRetryAt(LocalDateTime.now());intent.setLastError(null);
|
||||
intentRepository.save(intent);
|
||||
}
|
||||
|
||||
public static final class IntentRef {
|
||||
public final Long id;
|
||||
public final Long revision;
|
||||
public IntentRef(Long id, Long revision) { this.id = id; this.revision = revision; }
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
|
||||
public IntentRef pendingForTask(Long instanceId, String nodeId, Long taskId) {
|
||||
FlwAdvanceIntent intent = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(instanceId, nodeId), taskId);
|
||||
return intent == null || !FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) ? null
|
||||
: new IntentRef(intent.getId(), intent.getRevision());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nodeId 待放行节点
|
||||
* @param edgeName 多出口节点所选出口(办理事件携带);可空——兜底从该节点
|
||||
* 最近一条已同意任务的 edge_name 列重放(推进在 AFTER_COMMIT 新事务重查,
|
||||
* 事件传参只是快路径,落库的值才是权威)
|
||||
* @param operator 本节点办理人(TASK_HANDLED 事件 data 穿透;推进产生的
|
||||
* TASK_CREATED/COMPLETED 操作人信封字段来源),可空
|
||||
* @return 是否实际推进了
|
||||
* One transaction owns validation, engine effects and settlement. RC sees the last committed task status;
|
||||
* the head lock serializes against last-handler refresh, and instance @Version fences reactivation writes.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean tryAdvance(Long instanceId, String nodeId, String edgeName, FlowOperator operator) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId).orElse(null);
|
||||
if (instance == null
|
||||
|| instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
|
||||
public boolean tryAdvance(Long intentId, Long observedRevision) {
|
||||
FlwAdvanceIntent intent = lockIntent(intentId);
|
||||
if (!ownsRevision(intent, observedRevision)) return false;
|
||||
FlwAdvanceIntent latest = intentRepository.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(
|
||||
intent.getInstanceId(), intent.getNodeId()).orElse(null);
|
||||
if (latest != null && !Objects.equals(latest.getId(), intent.getId())) {
|
||||
done(intent, "D09_STALE_ACTIVATION: successor=" + latest.getId());
|
||||
handoff(latest.getId());
|
||||
return false;
|
||||
}
|
||||
// 已推进过的节点不再放行(currentNodes 由引擎在放行时移除,乐观锁保证互斥)
|
||||
String current = instance.getCurrentNodes();
|
||||
boolean active = current != null && java.util.Arrays.stream(current.split(","))
|
||||
.anyMatch(nodeId::equals);
|
||||
if (!active) {
|
||||
if (intent.getActivationTaskId() == null || !intent.containsTask(intent.getActivationTaskId())
|
||||
|| intent.getTriggerTaskId() == null || !intent.handledTask(intent.getTriggerTaskId())) {
|
||||
hold(intent, "D09_UNANCHORED: audited cohort/last-handler backfill required; not executed");
|
||||
return false;
|
||||
}
|
||||
long pending = taskRepository.findByInstanceIdAndStatus(instanceId,
|
||||
FlwEnums.TaskStatus.PENDING.getCode()).stream()
|
||||
.filter(t -> nodeId.equals(t.getNodeId()))
|
||||
.count();
|
||||
if (pending > 0) {
|
||||
return false;
|
||||
FlwInstance instance = instanceRepository.findById(intent.getInstanceId()).orElse(null);
|
||||
if (instance == null || instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()
|
||||
|| !active(instance, intent.getNodeId())) {
|
||||
done(intent, null);return false;
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
if (def == null) {
|
||||
log.error("[flow-center] 办件定义缺失,推进中止:instance={} defId={}",
|
||||
instanceId, instance.getDefId());
|
||||
return false;
|
||||
if (!Objects.equals(instance.getDefId(), intent.getActivationDefId())
|
||||
|| !Objects.equals(instance.getDefVersion(), intent.getActivationDefVersion())
|
||||
|| !Objects.equals(instance.getApplyRound(), intent.getActivationApplyRound())) {
|
||||
hold(intent, "D09_IDENTITY_MISMATCH: no proven successor; audited backfill required");return false;
|
||||
}
|
||||
String chosen = edgeName;
|
||||
if (chosen == null) {
|
||||
chosen = taskRepository.findByInstanceIdAndStatus(instanceId,
|
||||
FlwEnums.TaskStatus.APPROVED.getCode()).stream()
|
||||
.filter(t -> nodeId.equals(t.getNodeId()) && t.getEdgeName() != null)
|
||||
.max(java.util.Comparator.comparing(
|
||||
FlwTask::getHandleTime, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder())))
|
||||
.map(FlwTask::getEdgeName)
|
||||
.orElse(null);
|
||||
boolean pending = taskRepository.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode())
|
||||
.stream().anyMatch(t -> intent.getNodeId().equals(t.getNodeId()));
|
||||
if (pending) {
|
||||
// A future handler holds/obtains this same head lock and must re-arm it in its task transaction.
|
||||
done(intent, null);return false;
|
||||
}
|
||||
flowEngine.continueAdvance(instance, def, nodeId, chosen, operator);
|
||||
instanceRepository.save(instance);
|
||||
FlwTask trigger = taskRepository.findById(intent.getTriggerTaskId()).orElse(null);
|
||||
if (trigger == null || !intent.containsTask(trigger.getId())
|
||||
|| !Objects.equals(trigger.getInstanceId(), instance.getId())
|
||||
|| !Objects.equals(trigger.getNodeId(), intent.getNodeId())
|
||||
|| !Integer.valueOf(FlwEnums.TaskStatus.APPROVED.getCode()).equals(trigger.getStatus())) {
|
||||
hold(intent, "D09_INVALID_LAST_HANDLER: not executed");return false;
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElseThrow(() -> new IllegalStateException("Missing activation definition"));
|
||||
flowEngine.continueAdvance(instance, def, intent.getNodeId(), trigger.getEdgeName(),
|
||||
FlowOperator.of(trigger.getAssigneeId(), trigger.getAssigneeName()));
|
||||
if (active(instance, intent.getNodeId())) throw new IllegalStateException("Activation did not advance; retaining recoverable intent");
|
||||
instanceRepository.saveAndFlush(instance); // An optimistic conflict rolls back engine writes AND settlement.
|
||||
done(intent, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Never merge a worker's detached row. A refreshed revision owns its own retry budget. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void defer(Long intentId, Long observedRevision, String error) {
|
||||
FlwAdvanceIntent intent = lockIntent(intentId);
|
||||
if (!ownsRevision(intent, observedRevision)) return;
|
||||
intent.setAttempts(intent.getAttempts() + 1);intent.setLastError(truncate(error));
|
||||
if (intent.getAttempts() >= 6) intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED);
|
||||
else intent.setNextRetryAt(LocalDateTime.now().plusSeconds(Math.min(1800, 30L * (1L << (intent.getAttempts() - 1)))));
|
||||
intentRepository.save(intent);
|
||||
}
|
||||
|
||||
private FlwAdvanceIntent lockIntent(Long id) {
|
||||
// Queries in a handling transaction may already have loaded a stale RR snapshot into the identity map.
|
||||
// A lock upgrade on that managed row checks its obsolete @Version before refresh can run.
|
||||
// Flush our own changes, detach ONLY this row, then load a new managed row with a locking current read.
|
||||
entityManager.flush();
|
||||
FlwAdvanceIntent cached = entityManager.find(FlwAdvanceIntent.class, id);
|
||||
if (cached != null) entityManager.detach(cached);
|
||||
return intentRepository.findByIdForUpdate(id).orElse(null);
|
||||
}
|
||||
|
||||
private void handoff(Long successorId) {
|
||||
FlwAdvanceIntent successor = lockIntent(successorId);
|
||||
// A stale worker must not consume its successor. Re-arm a previously waiting head, retaining its own payload.
|
||||
if (successor != null && successor.getTriggerTaskId() != null && FlwAdvanceIntent.STATE_DONE.equals(successor.getStatus())) {
|
||||
successor.setStatus(FlwAdvanceIntent.STATE_PENDING);successor.setAttempts(0);
|
||||
successor.setNextRetryAt(LocalDateTime.now());successor.setLastError(null);intentRepository.save(successor);
|
||||
}
|
||||
}
|
||||
|
||||
private static FlwAdvanceIntent memberOf(List<FlwAdvanceIntent> intents, Long taskId) {
|
||||
FlwAdvanceIntent found = null;
|
||||
for (FlwAdvanceIntent intent : intents) if (intent.containsTask(taskId)) {
|
||||
if (found != null && !Objects.equals(found.getId(), intent.getId())) throw new IllegalStateException("Task belongs to multiple activations");
|
||||
found = intent;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
private static boolean ownsRevision(FlwAdvanceIntent intent, Long revision) {
|
||||
return intent != null && FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) && Objects.equals(intent.getRevision(), revision);
|
||||
}
|
||||
private static boolean active(FlwInstance instance, String nodeId) {
|
||||
return instance.getCurrentNodes() != null && Arrays.asList(instance.getCurrentNodes().split(",")).contains(nodeId);
|
||||
}
|
||||
private void done(FlwAdvanceIntent intent, String reason) {
|
||||
intent.setStatus(FlwAdvanceIntent.STATE_DONE);intent.setLastError(reason);intentRepository.save(intent);
|
||||
}
|
||||
private void hold(FlwAdvanceIntent intent, String reason) {
|
||||
intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED);intent.setLastError(reason);intentRepository.save(intent);
|
||||
}
|
||||
private static String truncate(String s) { return s == null || s.length() <= 255 ? s : s.substring(0,255); }
|
||||
}
|
||||
|
||||
@@ -56,6 +56,18 @@ public class FlowDefModel {
|
||||
public List<Node> nodes;
|
||||
public List<Edge> edges;
|
||||
public Settings settings;
|
||||
/** null = 历史无契约版本;新发布必须显式声明(无业务变量时为 [])。 */
|
||||
public List<Variable> variables;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Variable {
|
||||
public String key;
|
||||
/** string/number/boolean/date/datetime/money/stringArray/fileArray/userArray */
|
||||
public String type;
|
||||
public Boolean required;
|
||||
@com.fasterxml.jackson.annotation.JsonProperty("enum")
|
||||
public List<String> enumValues;
|
||||
}
|
||||
/**
|
||||
* 画布坐标(nodeId → 左上角),仅前端设计器/查看器复用;引擎不消费。
|
||||
* 缺失时前端自行做自动布局,故可选、可空。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,46 @@ public class FlowEvent {
|
||||
private final Map<String, Object> data;
|
||||
private Map<String, Object> roundIdentity;
|
||||
|
||||
// ===== T05 稳定事件身份(2026-10-25) =====
|
||||
/** 事实行 event_id:发布线程同步监听器在状态事务内落事实行时一次性赋值,全订阅方/重试复用 */
|
||||
private String stableEventId;
|
||||
/** 事实行 event_seq(实例内单调序号) */
|
||||
private Integer stableEventSeq;
|
||||
/**
|
||||
* R2(Codex D04)冻结信封:事实落库时一次性构建的完整投递载荷 JSON(含 eventId/eventSeq/
|
||||
* flowGuid 等全部信封字段)。正常分发与崩溃恢复分发共用同一份冻结体——恢复消息体不再缺
|
||||
* eventId/eventSeq,两条路径字节级同构。事实行缺席(存量路径)为 null,由分发器现场构建。
|
||||
*/
|
||||
private String stablePayloadJson;
|
||||
|
||||
public void assignStableIdentity(String eventId, Integer eventSeq) {
|
||||
if (this.stableEventId != null) {
|
||||
return; // 只赋一次:幂等重入(同事件多监听器)不换身份
|
||||
}
|
||||
this.stableEventId = eventId;
|
||||
this.stableEventSeq = eventSeq;
|
||||
}
|
||||
|
||||
public String getStableEventId() {
|
||||
return stableEventId;
|
||||
}
|
||||
|
||||
public Integer getStableEventSeq() {
|
||||
return stableEventSeq;
|
||||
}
|
||||
|
||||
/** 随稳定身份一并冻结完整投递信封(只赋一次;身份与信封同源同事务) */
|
||||
public void assignStablePayload(String payloadJson) {
|
||||
if (this.stablePayloadJson != null) {
|
||||
return;
|
||||
}
|
||||
this.stablePayloadJson = payloadJson;
|
||||
}
|
||||
|
||||
public String getStablePayloadJson() {
|
||||
return stablePayloadJson;
|
||||
}
|
||||
|
||||
public void captureRoundIdentity(Integer round, String requestToken) {
|
||||
if (roundIdentity != null) return;
|
||||
Map<String, Object> identity = new HashMap<>();
|
||||
|
||||
@@ -1,50 +1,102 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAdvanceIntent;
|
||||
import com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* 节点放行推进监听器:TASK_HANDLED 事件 AFTER_COMMIT 后在新事务里重查——
|
||||
* 该节点待办是否清零、办件是否仍在途,全部成立才继续推进(下一节点/JOIN 合并/END)。
|
||||
* 会签并发办理时各方提交的快照互相看不到对方未提交的办理行,事务内判断
|
||||
* "节点是否全部办结"必假,提交后重查才是唯一可靠时机;推进串行化由办件乐观锁兜底
|
||||
* (重试循环在监听器侧,跨 bean 调用 {@link FlowAdvanceService#tryAdvance} 保证 REQUIRES_NEW 生效)。
|
||||
*/
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** TASK_CREATED establishes membership; TASK_HANDLED refreshes that same activation in the handling transaction. */
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FlowNodeAdvanceListener {
|
||||
|
||||
private final FlowAdvanceService advanceService;
|
||||
private final FlwAdvanceIntentRepository intentRepository;
|
||||
|
||||
@EventListener
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 2)
|
||||
public void persistIntent(FlowEvent event) {
|
||||
if (event.getData() == null) return;
|
||||
if (event.getType() == FlowEvent.Type.TASK_CREATED) {
|
||||
ActivationBatch batch = batch();
|
||||
if (!batch.tasks.add(event.getTaskId())) return;
|
||||
String key = event.getInstanceId() + ":" + event.getData().get("nodeId");
|
||||
int total = ((Number) event.getData().get("totalTasks")).intValue();
|
||||
if (total <= 0) throw new IllegalStateException("Missing activation batch size");
|
||||
Registration registration = batch.nodes.get(key);
|
||||
if (registration == null) { registration = new Registration(total);batch.nodes.put(key, registration); }
|
||||
if (registration.total != total) throw new IllegalStateException("Activation batch size changed");
|
||||
registration.intentId = advanceService.registerTask(event, registration.intentId);
|
||||
if (--registration.remaining == 0) batch.nodes.remove(key);
|
||||
} else if (event.getType() == FlowEvent.Type.TRANSFERRED) {
|
||||
advanceService.registerTransfer(event);
|
||||
} else if (event.getType() == FlowEvent.Type.TASK_HANDLED && event.getData().get("nodeId") != null) {
|
||||
advanceService.recordHandled(event);
|
||||
}
|
||||
}
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onTaskHandled(FlowEvent event) {
|
||||
if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) {
|
||||
return;
|
||||
}
|
||||
if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) return;
|
||||
String nodeId = (String) event.getData().get("nodeId");
|
||||
if (nodeId == null) {
|
||||
return;
|
||||
}
|
||||
String edgeName = (String) event.getData().get("edgeName");
|
||||
// 办理人随事件 data 穿透进推进链:新 TASK_CREATED/COMPLETED 的操作人信封字段(2026-09-20)
|
||||
FlowOperator operator = FlowOperator.of(
|
||||
(Integer) event.getData().get("assigneeId"),
|
||||
(String) event.getData().get("assigneeName"));
|
||||
if (nodeId == null) return;
|
||||
FlowAdvanceService.IntentRef ref = advanceService.pendingForTask(event.getInstanceId(), nodeId, event.getTaskId());
|
||||
if (ref == null) return;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
advanceService.tryAdvance(event.getInstanceId(), nodeId, edgeName, operator);
|
||||
return;
|
||||
} catch (ObjectOptimisticLockingFailureException e) {
|
||||
log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}",
|
||||
attempt, event.getInstanceId(), nodeId);
|
||||
try { advanceService.tryAdvance(ref.id, ref.revision);return; }
|
||||
catch (ObjectOptimisticLockingFailureException conflict) {
|
||||
log.warn("[flow-center] D09 advance conflict {}/3: intent={} revision={}", attempt, ref.id, ref.revision);
|
||||
} catch (Exception failure) {
|
||||
advanceService.defer(ref.id, ref.revision, failure.toString());return;
|
||||
}
|
||||
}
|
||||
log.error("[flow-center] 推进重试耗尽(请人工检查):instance={} node={}",
|
||||
event.getInstanceId(), nodeId);
|
||||
advanceService.defer(ref.id, ref.revision, "AFTER_COMMIT retries exhausted; sweep will resume");
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 30_000)
|
||||
public void sweepIntents() {
|
||||
for (FlwAdvanceIntent detached : intentRepository.findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
|
||||
FlwAdvanceIntent.STATE_PENDING, LocalDateTime.now())) {
|
||||
Long id = detached.getId(), revision = detached.getRevision();
|
||||
try { advanceService.tryAdvance(id, revision); }
|
||||
catch (Exception failure) { advanceService.defer(id, revision, failure.toString()); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronizations are suspended with REQUIRES_NEW; an outer activation batch cannot leak into its child. */
|
||||
private static ActivationBatch batch() {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) throw new IllegalStateException("Activation requires its creation transaction");
|
||||
for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
|
||||
if (sync instanceof ActivationBatch) return (ActivationBatch) sync;
|
||||
}
|
||||
ActivationBatch created = new ActivationBatch();
|
||||
TransactionSynchronizationManager.registerSynchronization(created);return created;
|
||||
}
|
||||
private static final class ActivationBatch implements TransactionSynchronization {
|
||||
final Map<String, Registration> nodes = new HashMap<>();
|
||||
final java.util.Set<Long> tasks = new java.util.HashSet<>();
|
||||
@Override public void beforeCommit(boolean readOnly) {
|
||||
if (!nodes.isEmpty()) throw new IllegalStateException("Incomplete activation membership; creation transaction must roll back");
|
||||
}
|
||||
}
|
||||
private static final class Registration {
|
||||
final int total;
|
||||
int remaining;
|
||||
Long intentId;
|
||||
Registration(int total) { this.total = total;this.remaining = total; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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<String> COMPARISONS = new HashSet<>(Arrays.asList("==", "!=", ">=", "<=", ">", "<"));
|
||||
|
||||
public static Set<String> referencedKeys(FlowDefModel model) {
|
||||
Set<String> 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<String, FlowDefModel.Variable> 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<String> 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<String, Object> submitted) {
|
||||
Set<String> 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<String> 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<FlowDefModel.Variable> fromSnapshot(Map<String, Object> snapshot) {
|
||||
Object fields = snapshot.get("fields");
|
||||
if (!(fields instanceof List)) fail("formContract.fields 必须为数组");
|
||||
List<FlowDefModel.Variable> 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); }
|
||||
}
|
||||
@@ -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,重启即丢)。
|
||||
*
|
||||
* <p>D09:每个激活批次记录精确任务成员,未办理时以 DONE 等待;办理同事务串行刷新
|
||||
* 本批次的末笔任务及 PENDING 工作。worker 按 id+revision 锁定当前读,推进与结算同事务;
|
||||
* NULL 代次只允许明确接棒或留待有证据的人工回填,不作为本代放行。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -14,8 +14,9 @@ import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存
|
||||
* biz_title/biz_url/biz_snapshot 快照与关联键;uk(sch_id, biz_type, biz_id) 幂等——
|
||||
* 同一业务对象一条办件,重复代发起返回原办件;驳回/撤回后重提复用同一行(R4 重启语义)。
|
||||
* biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为
|
||||
* (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。
|
||||
* sourcePrincipalId=NULL / legacySlot=1 仅保留迁移前旧入口的一业务一行语义;正式模式关闭旧写入口。
|
||||
*
|
||||
* <p>version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件),
|
||||
* 由 JPA @Version 在 flush 时自增。
|
||||
@@ -24,12 +25,19 @@ import java.time.LocalDateTime;
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_instance",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_instance_biz",
|
||||
columnNames = {"sch_id", "biz_type", "biz_id"}),
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_flw_instance_legacy", columnNames = {"sch_id", "biz_type", "biz_id", "legacy_slot"}),
|
||||
@UniqueConstraint(name = "uk_flw_instance_round", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "apply_round"})},
|
||||
indexes = {
|
||||
@Index(name = "idx_flw_inst_submitter", columnList = "sch_id, submitter_id, status"),
|
||||
@Index(name = "idx_flw_inst_status_time", columnList = "sch_id, status, submit_time")})
|
||||
public class FlwInstance extends BaseEntity {
|
||||
@Column(name="source_principal_id",length=36,updatable=false)
|
||||
private String sourcePrincipalId;
|
||||
/** 1 for legacy writers, NULL for formal commands. Migration must drop the old three-column UK. */
|
||||
@Column(name="legacy_slot")
|
||||
private Integer legacySlot = 1;
|
||||
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
@@ -141,7 +149,7 @@ public class FlwInstance extends BaseEntity {
|
||||
private String applyRequestToken;
|
||||
|
||||
/**
|
||||
* 当前发起轮次(1 起;随新一轮请求标识进位)。
|
||||
* 当前发起轮次(1 起)。正式命令由服务端坐标锁内分配,不接受客户端 attempt 分轮。
|
||||
*
|
||||
* <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
|
||||
* 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p>
|
||||
@@ -149,6 +157,22 @@ public class FlwInstance extends BaseEntity {
|
||||
@Column(name = "apply_round")
|
||||
private Integer applyRound;
|
||||
|
||||
/**
|
||||
* 受理时规范载荷指纹(2026-10-25 T04,SHA-256 十六进制):同 requestToken 必须同指纹——
|
||||
* 同 ID 不同载荷是两份命令,撞在同一个 token 上按冲突拒绝,不伪装成幂等成功。
|
||||
* 存量行(指纹列为 NULL)跳过该核对(守卫缺省是关,与 token/round 存量纪律一致)。
|
||||
*/
|
||||
@Column(name = "apply_payload_hash", length = 64)
|
||||
private String applyPayloadHash;
|
||||
|
||||
/**
|
||||
* 事件序号计数器(2026-10-25 T05):实例内事件事实 eventSeq 的发号器。发号时在事务内
|
||||
* 悲观锁住本行(findByIdForUpdate 当前读)再 +1——REPEATABLE READ 下对聚合做快照读会
|
||||
* 拿到陈旧 max(FlowEngineIT 会签并发实证),行上计数器+行锁才是串行安全的。
|
||||
*/
|
||||
@Column(name = "event_seq_counter")
|
||||
private Integer eventSeqCounter;
|
||||
|
||||
/**
|
||||
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
|
||||
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
|
||||
|
||||
@@ -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):状态/审批动作与不可变事件事实<b>同一数据库事务</b>落库。
|
||||
*
|
||||
* <p>关闭审计 E24 的丢失窗口:旧链路 AFTER_COMMIT 才按订阅生成 eventId、另事务落回调行——
|
||||
* 提交到分发之间进程被杀,事件永久丢失(订阅方靠对账兜底)。本事实行在发布线程的同一事务内
|
||||
* 写入:状态提交成功 ⇔ 事件事实已持久化;回调分发只是它的消费,崩溃后由 sweep 扫描补分发。</p>
|
||||
*
|
||||
* <p>身份:{@code eventId} 在事实创建时一次性生成,<b>全部订阅方/全部投递重试复用同一 eventId</b>
|
||||
* (消费端 (event_id, consumer_app_key) 唯一键去重);{@code eventSeq} 为实例内<b>尽力单调</b>序号
|
||||
* (FOR UPDATE 发号器;REQUIRES_NEW 子事务推进拓扑下极端并发窗口可能同值——有序性以
|
||||
* eventSeq+发生时间为准,同实例严格全序需专用发号器,列入后续;不承诺全局副作用严格有序)。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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):哪个<b>稳定应用主体</b>可以在哪所学校使用哪条流程线。
|
||||
*
|
||||
* <p>与 {@link FlwApp}(定义保存白名单)和流程定义管理权(FlowAdminGuard 管理角色)三权分立:
|
||||
* 有 flw_app 行不等于能新发起;是应用管理员也不等于能编辑/发布学校流程定义。
|
||||
* 许可键是三元 {@code (schId, flowGuid, appId)}——appId 是 UserBase application_info 主键
|
||||
* (稳定主体,appKey 可重置/注销后不复用历史来源),appKey 快照仅供展示回溯。</p>
|
||||
*
|
||||
* <p>撤权语义:{@code enabled=0} 只拦截<b>新发起</b>;已受理命令的回执恢复、在途实例推进
|
||||
* 不回读本表(P0-3 收口顺序:认证 → 自有命令回执 → 未命中才查新发起资格)。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<FlwAdvanceIntent, Long> {
|
||||
|
||||
/** 到点待执行: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<FlwAdvanceIntent> findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
|
||||
@Param("status") String status, @Param("now") LocalDateTime now);
|
||||
|
||||
List<FlwAdvanceIntent> findByInstanceIdAndNodeIdOrderByIdDesc(Long instanceId, String nodeId);
|
||||
|
||||
Optional<FlwAdvanceIntent> findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(
|
||||
Long instanceId, String nodeId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select i from FlwAdvanceIntent i where i.id=:id")
|
||||
Optional<FlwAdvanceIntent> 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);
|
||||
}
|
||||
@@ -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<FlwApplyCommand,Long> {
|
||||
boolean existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(Long school, String source, String flow, String state);
|
||||
Optional<FlwApplyCommand> findBySchIdAndSourcePrincipalIdAndRequestId(Long schId,String sourcePrincipalId,String requestId);
|
||||
}
|
||||
@@ -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<FlwBizCoordinate,Long> {
|
||||
@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<FlwBizCoordinate> 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);
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import java.util.List;
|
||||
|
||||
public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog, Long> {
|
||||
|
||||
/** T05 投递幂等:同一事件事实对同一订阅方只建一行(补分发与正常分发竞争时去重) */
|
||||
boolean existsByEventIdAndRegId(String eventId, Long regId);
|
||||
|
||||
/** 重推扫描主路径:待推/重试中且到点 */
|
||||
List<FlwCallbackLog> findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc(
|
||||
Integer status, LocalDateTime now);
|
||||
|
||||
@@ -14,7 +14,7 @@ public interface FlwCcRepository extends JpaRepository<FlwCc, Long> {
|
||||
/** 抄送我的(用户面页签,走 user+sent_time 索引;appKey 可空=不按应用过滤,口径=定义归属 flw_def.app_key) */
|
||||
@Query("select c from FlwCc c join FlwInstance i on i.id = c.instanceId join FlwDef d on d.id = i.defId "
|
||||
+ "where c.schId = :schId and c.userId = :userId "
|
||||
+ "and (:appKey is null or d.appKey = :appKey) order by c.sentTime desc")
|
||||
+ "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) order by c.sentTime desc")
|
||||
Page<FlwCc> pageCc(@Param("schId") Long schId, @Param("userId") Integer userId,
|
||||
@Param("appKey") String appKey, Pageable pageable);
|
||||
|
||||
|
||||
@@ -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<FlwCommandSource,String> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select s from FlwCommandSource s where s.id=:id")
|
||||
Optional<FlwCommandSource> lock(@Param("id") String id);
|
||||
}
|
||||
@@ -32,12 +32,22 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
|
||||
|
||||
List<FlwDef> findBySchIdOrderByUpdateTimeDesc(Long schId);
|
||||
|
||||
/** 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本 */
|
||||
@Query("select d from FlwDef d where d.schId = :schId and d.status = 1 "
|
||||
/**
|
||||
* 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本。
|
||||
* 2026-10-25 T03:外层追加 {@code lineOffline=0}——线级停发的整线不出现在新发起预览源,
|
||||
* 且不存在"停发后回退旧版"的形态(内层仍是该线已发布最高版本,停发不改变版本事实)。
|
||||
*/
|
||||
@Query("select d from FlwDef d where d.schId = :schId and d.status = 1 and d.lineOffline = 0 "
|
||||
+ "and d.version = (select max(d2.version) from FlwDef d2 where d2.schId = d.schId "
|
||||
+ "and d2.flowGuid = d.flowGuid and d2.status = 1) order by d.updateTime desc")
|
||||
List<FlwDef> findPublishedLatestPerGuid(@Param("schId") Long schId);
|
||||
|
||||
/** 线级停发/恢复(T03):批量 UPDATE 全版本行同值;返回受影响行数 */
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@Query("update FlwDef d set d.lineOffline = :offline where d.schId = :schId and d.flowGuid = :flowGuid")
|
||||
int updateLineOffline(@Param("schId") Long schId, @Param("flowGuid") String flowGuid,
|
||||
@Param("offline") Integer offline);
|
||||
|
||||
/** 在途统计(议题二④):一次取回多条流程线的全部版本行 */
|
||||
List<FlwDef> findBySchIdAndFlowGuidIn(Long schId, Collection<String> flowGuids);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,35 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long> {
|
||||
@org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select i from FlwInstance i where i.id=:id")
|
||||
Optional<FlwInstance> findFormalPreviousForUpdate(@Param("id") Long id);
|
||||
|
||||
|
||||
/**
|
||||
* T05 事件序号发号(2026-10-25 FlowEngineIT 会签并发实证三轮修正,终形态):
|
||||
* ①FOR UPDATE 标量当前读发号(取行 X 锁,任何隔离级别都看到已提交最新值)→
|
||||
* ②按读到的值回写计数器(值已在我锁内计算,无竞态)→③事实行按该值落库。
|
||||
* 纯快照读 max+1 会撞 uk;实体 merge 会触发 @Version 冲突;自增 UPDATE 后普通 SELECT
|
||||
* 的读回在并发窗口同样不可靠——FOR UPDATE 标量读是唯一两全形态。
|
||||
*/
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
value = "select coalesce(event_seq_counter, 0) + 1 from flw_instance where id = :id for update",
|
||||
nativeQuery = true)
|
||||
Integer nextEventSeqForUpdate(@Param("id") Long id);
|
||||
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
value = "update flw_instance set event_seq_counter = :v where id = :id", nativeQuery = true)
|
||||
int setEventSeqCounter(@Param("id") Long id, @Param("v") Integer v);
|
||||
|
||||
Optional<FlwInstance> findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(Long school, String source, String type, String biz);
|
||||
|
||||
@Query("select i from FlwInstance i where i.schId=:school and i.sourcePrincipalId=:source and (:bizType is null or i.bizType=:bizType) and (:status is null or i.status=:status) order by i.id desc")
|
||||
Page<FlwInstance> pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable);
|
||||
|
||||
/** biz uk 幂等主键 */
|
||||
@Query("select i from FlwInstance i where i.schId=?1 and i.bizType=?2 and i.bizId=?3 and i.sourcePrincipalId is null")
|
||||
Optional<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
|
||||
|
||||
List<FlwInstance> findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status);
|
||||
|
||||
@@ -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<FlwStateEvent, Long> {
|
||||
|
||||
/** 实例内单调序号取号:调用方持有该实例的状态行锁(同事务状态变更),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<FlwStateEvent> findTop50ByDispatchedAndCreateTimeLessThanEqualOrderByCreateTimeAsc(
|
||||
Integer dispatched, LocalDateTime deadline);
|
||||
|
||||
java.util.Optional<FlwStateEvent> findByEventId(String eventId);
|
||||
|
||||
boolean existsByEventId(String eventId);
|
||||
}
|
||||
@@ -13,6 +13,12 @@ import java.util.List;
|
||||
|
||||
public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
|
||||
|
||||
/** Additive D09 query: the real transfer parent is in this set. Ambiguous generations must be rejected. */
|
||||
@Query(value="select t.id from flw_task t join flw_task n on n.id=:newTaskId "
|
||||
+ "where t.instance_id=n.instance_id and t.node_id=n.node_id and t.status=5 "
|
||||
+ "and t.assignee_id=:operatorId and t.handle_time=n.arrive_time", nativeQuery=true)
|
||||
List<Long> findTransferParentCandidates(@Param("newTaskId") Long newTaskId, @Param("operatorId") Integer operatorId);
|
||||
|
||||
List<FlwTask> findByInstanceIdOrderByIdAsc(Long instanceId);
|
||||
|
||||
List<FlwTask> findByInstanceIdAndStatus(Long instanceId, Integer status);
|
||||
@@ -52,10 +58,13 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
|
||||
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId")
|
||||
int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId);
|
||||
|
||||
@Query("select t from FlwTask t join FlwInstance i on i.id=t.instanceId where t.schId=:school and i.sourcePrincipalId=:source and t.assigneeId=:user and t.status=0 and (:type is null or i.bizType=:type) order by t.id asc")
|
||||
Page<FlwTask> pageSelfTodo(@Param("school") Long school,@Param("source") String source,@Param("user") Integer user,@Param("type") String type, Pageable pageable);
|
||||
|
||||
/** 我的待办(主路径,走 assignee 索引;appKey 可空=不按应用过滤,口径=办件定义归属应用 flw_def.app_key) */
|
||||
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
|
||||
+ "where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 "
|
||||
+ "and (:appKey is null or d.appKey = :appKey) "
|
||||
+ "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) "
|
||||
+ "order by t.arriveTime asc, t.id asc")
|
||||
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
|
||||
@Param("appKey") String appKey, Pageable pageable);
|
||||
@@ -84,7 +93,7 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
|
||||
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
|
||||
+ "where t.schId = :schId and t.assigneeId = :assigneeId "
|
||||
+ "and t.status in :statuses "
|
||||
+ "and (:appKey is null or d.appKey = :appKey) "
|
||||
+ "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) "
|
||||
+ "and t.id = (select max(t2.id) from FlwTask t2 where t2.instanceId = t.instanceId "
|
||||
+ " and t2.assigneeId = :assigneeId and t2.status in :statuses) "
|
||||
+ "order by t.handleTime desc, t.id desc")
|
||||
|
||||
@@ -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<FlwUsageGrant, Long> {
|
||||
boolean existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(Long school, String flow, String source, Integer enabled);
|
||||
|
||||
|
||||
Optional<FlwUsageGrant> findBySchIdAndFlowGuidAndAppId(Long schId, String flowGuid, Integer appId);
|
||||
|
||||
List<FlwUsageGrant> findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(Long schId, String flowGuid);
|
||||
|
||||
List<FlwUsageGrant> findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(Long schId, Integer appId);
|
||||
}
|
||||
@@ -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<String> 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<String, Object> parse(String formDataJson, boolean strict) {
|
||||
if (formDataJson == null || formDataJson.trim().isEmpty()) {
|
||||
return new java.util.HashMap<>();
|
||||
}
|
||||
try {
|
||||
Map<String, Object> values = objectMapper.readValue(formDataJson, new TypeReference<Map<String, Object>>() { });
|
||||
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<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>oapi 通道的 approve/reject 只有<b>自报 operatorId/operatorName</b>——appKey 验签证明的是
|
||||
* 应用,不是用户;"task.assigneeId == operatorId" 的本人比较 likewise 只能证明调用方知道
|
||||
* 办理人 ID,不能证明实际办理的用户。在"可验证用户证明"(面向 FL 的用户会话票据/受限委托:
|
||||
* 绑定用户、学校、调用应用、任务/动作范围、期限与防重放标识,见 Codex 终审 D03/C.2)落地前,
|
||||
* 快捷审批<b>一律不放行,不存在放行配置</b>。</p>
|
||||
*
|
||||
* <p>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)
|
||||
* 并按证明放行;届时再恢复配置化灰度。</p>
|
||||
*/
|
||||
@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)办理");
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public class FlowCallbackAdminService {
|
||||
private final FlwCallbackLogRepository logRepository;
|
||||
private final CallbackPushService pushService;
|
||||
private final CallbackProperties callbackProperties;
|
||||
private final com.yunhai.flowcenter.callback.CallbackLogStore logStore;
|
||||
|
||||
public List<FlwCallbackReg> listRegs(Long schId) {
|
||||
return regRepository.findBySchIdOrderByUpdateTimeDesc(schId);
|
||||
@@ -104,7 +105,6 @@ public class FlowCallbackAdminService {
|
||||
* 管理端手工标记死信(持续重试语义下死信的唯一人工入口:人工判定事件无意义时止血)。
|
||||
* 标记后可经既有 rePush 复活。
|
||||
*/
|
||||
@Transactional
|
||||
public void markDead(Long schId, Long logId) {
|
||||
FlwCallbackLog callbackLog = logRepository.findById(logId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "推送日志不存在:" + logId));
|
||||
@@ -119,7 +119,6 @@ public class FlowCallbackAdminService {
|
||||
}
|
||||
|
||||
/** 手工重推(死信/失败日志):置回待推并立即触发一次推送 */
|
||||
@Transactional
|
||||
public void rePush(Long schId, List<Long> logIds) {
|
||||
if (logIds == null || logIds.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要重推的日志");
|
||||
@@ -128,20 +127,9 @@ public class FlowCallbackAdminService {
|
||||
if (logs.size() != logIds.size()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "存在不属于该校或不存在 dead/pending 日志");
|
||||
}
|
||||
for (FlwCallbackLog callbackLog : logs) {
|
||||
if (callbackLog.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
|
||||
continue;
|
||||
}
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setRetryCount(0);
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now());
|
||||
logRepository.save(callbackLog);
|
||||
}
|
||||
logRepository.flush();
|
||||
for (FlwCallbackLog callbackLog : logs) {
|
||||
if (callbackLog.getStatus() != null) {
|
||||
pushService.pushAsync(callbackLog.getId());
|
||||
}
|
||||
// 短事务提交并释放会话锁后才执行 HTTP;不能把管理端旧实体全量回写覆盖刚成功的状态。
|
||||
for (Long id : logStore.resetForReplay(schId, logIds)) {
|
||||
pushService.pushAsync(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 快照纪律同构:在途办件按发起时快照校验),
|
||||
* 并做表达式<b>键查①</b>红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试);
|
||||
* 运行期对 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<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
|
||||
def.getFormVersion(), "发布");
|
||||
List<String> problems = new ArrayList<>();
|
||||
validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
|
||||
validateExpressionTypesAndDomains(model, snapshot, problems);
|
||||
if (!problems.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"发布校验失败(表达式三查):\n" + String.join("\n", problems));
|
||||
}
|
||||
def.setContentJson(embedFormContract(def.getContentJson(), snapshot));
|
||||
model.variables = VariableContract.fromSnapshot(snapshot);
|
||||
VariableContract.validateDefinition(model, true);
|
||||
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
|
||||
def.setContentJson(embedFormContract(def.getContentJson(), snapshot, model.variables));
|
||||
} else if (def.getFormVersion() != null) {
|
||||
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
|
||||
}
|
||||
if (def.getFormUuid() == null || def.getFormUuid().trim().isEmpty()) {
|
||||
VariableContract.validateDefinition(model, true);
|
||||
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
|
||||
}
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setPublishTime(LocalDateTime.now());
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达式键查①(C1 三查之一,红牌):对每条 EXPRESSION 判断的出边表达式,
|
||||
* 变量集 ⊆ 快照字段 keys ∪ 内置变量({@link DefValidator#EXPRESSION_BUILTINS})。
|
||||
* 不执行表达式、不改沙箱(口径与 v2 方案 §4.7 一致)。
|
||||
*/
|
||||
private void validateExpressionKeysAgainstSnapshot(FlowDefModel model, Map<String, Object> snapshot,
|
||||
List<String> problems) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
|
||||
java.util.Set<String> fieldKeys = new java.util.HashSet<>();
|
||||
if (fields != null) {
|
||||
for (Map<String, Object> f : fields) {
|
||||
Object k = f.get("key");
|
||||
if (k != null) {
|
||||
fieldKeys.add(String.valueOf(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fieldKeys.isEmpty()) {
|
||||
problems.add("关联表单快照无字段(formContract.fields 为空):请检查表单是否已发布");
|
||||
return;
|
||||
}
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (n == null || !n.expressionJudge()) {
|
||||
continue;
|
||||
}
|
||||
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
|
||||
if (e.condition == null || e.condition.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
List<String> vars;
|
||||
try {
|
||||
vars = com.yunhai.flowcenter.engine.ExpressionEvaluator.variableNames(e.condition.trim());
|
||||
} catch (Exception ex) {
|
||||
// 语法错已由 DefValidator 拦下,这里理论上到不了;防御口径跳过
|
||||
continue;
|
||||
}
|
||||
for (String v : vars) {
|
||||
if (!fieldKeys.contains(v) && !DefValidator.EXPRESSION_BUILTINS.contains(v)) {
|
||||
problems.add("节点「" + n.name + "」出口「" + e.name + "」表达式变量 " + v
|
||||
+ " 不在关联表单字段集内(快照 " + fieldKeys.size() + " 个字段)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
|
||||
private String embedFormContract(String contentJson, Map<String, Object> snapshot) {
|
||||
private String embedFormContract(String contentJson, Map<String, Object> snapshot,
|
||||
List<FlowDefModel.Variable> variables) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
|
||||
objectMapper.readTree(contentJson);
|
||||
root.set("formContract", objectMapper.valueToTree(snapshot));
|
||||
root.set("variables", objectMapper.valueToTree(variables));
|
||||
return objectMapper.writeValueAsString(root);
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
@@ -310,109 +265,46 @@ public class FlowDefAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 三查②③(2026-10-20 C1 第一刀余量项) =====================
|
||||
|
||||
/** 数值桶:可与数值字面量比大小的快照字段类型 */
|
||||
private static final java.util.Set<String> NUMERIC_TYPES =
|
||||
new java.util.HashSet<>(java.util.Arrays.asList("number"));
|
||||
/** 值域字面量型(enum 域查③适用):options 收进快照 enum 的类型 */
|
||||
private static final java.util.Set<String> ENUM_TYPES =
|
||||
new java.util.HashSet<>(java.util.Arrays.asList("select", "radio"));
|
||||
|
||||
/** `字段 op '字面量'` / `字段 op 数值`(op 含两侧空格容忍) */
|
||||
private static final java.util.regex.Pattern FIELD_STR_EQ = java.util.regex.Pattern.compile(
|
||||
"([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=)\\s*'([^']*)'");
|
||||
private static final java.util.regex.Pattern FIELD_NUM_CMP = java.util.regex.Pattern.compile(
|
||||
"([A-Za-z_][A-Za-z0-9_]*)\\s*(>=|<=|>|<)\\s*(-?\\d+(\\.\\d+)?)");
|
||||
private static final java.util.regex.Pattern NUM_FIELD_CMP = java.util.regex.Pattern.compile(
|
||||
"(-?\\d+(\\.\\d+)?)\\s*(>=|<=|>|<)\\s*([A-Za-z_][A-Za-z0-9_]*)");
|
||||
|
||||
/**
|
||||
* 类型桶查②红牌 + 域查③红牌(<b>启发式实现</b>:正则模式匹配字面量比较形态,非 AST——
|
||||
* v2 §4.7 保守边界:不执行表达式、不做隐式转换,只拦"数值比较 × 非数值字段"与
|
||||
* "枚举等值 × 字面量不在值域"两类高发错配;不命中的形态留给运行期既有挂起纪律)。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void validateExpressionTypesAndDomains(FlowDefModel model, Map<String, Object> snapshot,
|
||||
List<String> problems) {
|
||||
List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
|
||||
if (fields == null || fields.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Map<String, Object>> fieldByKey = new HashMap<>();
|
||||
for (Map<String, Object> f : fields) {
|
||||
Object k = f.get("key");
|
||||
if (k != null) {
|
||||
fieldByKey.put(String.valueOf(k), f);
|
||||
}
|
||||
}
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (n == null || !n.expressionJudge()) {
|
||||
continue;
|
||||
}
|
||||
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
|
||||
if (e.condition == null || e.condition.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String where = "节点「" + n.name + "」出口「" + e.name + "」";
|
||||
String expr = e.condition.trim();
|
||||
// ② 类型桶查:数值比较算子作用于非数值字段 → 红牌
|
||||
java.util.regex.Matcher m = FIELD_NUM_CMP.matcher(expr);
|
||||
while (m.find()) {
|
||||
checkNumericBucket(where, m.group(1), m.group(2), fieldByKey, problems);
|
||||
}
|
||||
m = NUM_FIELD_CMP.matcher(expr);
|
||||
while (m.find()) {
|
||||
checkNumericBucket(where, m.group(4), m.group(3), fieldByKey, problems);
|
||||
}
|
||||
// ② 类型桶查(字符串侧):文本字面量等值比较数值字段 → 红牌
|
||||
// ③ 域查:枚举字段等值比较的字面量 ∉ 快照 enum 值域 → 红牌
|
||||
m = FIELD_STR_EQ.matcher(expr);
|
||||
while (m.find()) {
|
||||
String key = m.group(1);
|
||||
String op = m.group(2);
|
||||
String literal = m.group(3);
|
||||
Map<String, Object> field = fieldByKey.get(key);
|
||||
if (field == null) {
|
||||
continue; // 内置变量或未知键:①已拦/不属本查
|
||||
}
|
||||
String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
|
||||
if (NUMERIC_TYPES.contains(type)) {
|
||||
problems.add(where + ":字段 " + key + " 是 number 类型,不能与字符串字面量 '"
|
||||
+ literal + "' 做 " + op + " 比较(类型桶查②)");
|
||||
} else if (ENUM_TYPES.contains(type) && "==".equals(op)) {
|
||||
List<String> enums = (List<String>) field.get("enum");
|
||||
if (enums != null && !enums.isEmpty() && !enums.contains(literal)) {
|
||||
problems.add(where + ":字段 " + key + " 的值域是 " + enums
|
||||
+ ",字面量 '" + literal + "' 不在其中(域查③)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkNumericBucket(String where, String key, String op,
|
||||
Map<String, Map<String, Object>> fieldByKey, List<String> problems) {
|
||||
Map<String, Object> field = fieldByKey.get(key);
|
||||
if (field == null) {
|
||||
return;
|
||||
}
|
||||
String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
|
||||
if (type != null && !NUMERIC_TYPES.contains(type)) {
|
||||
problems.add(where + ":字段 " + key + " 是 " + type + " 类型,不能做数值比较 "
|
||||
+ key + " " + op + " 数字(类型桶查②)");
|
||||
}
|
||||
}
|
||||
|
||||
/** 停用:不可新发起,存量跑完 */
|
||||
@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) {
|
||||
|
||||
@@ -24,7 +24,8 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 办件生命周期:代发起(幂等 + R4 重启重提)、撤回。
|
||||
* 引擎实例生命周期与迁移前代发起。正式命令通过 create/startFormalInstance 建立独立轮次实例,
|
||||
* 命令受理、坐标锁和轮次分配归 FormalApplyCommandService;下述 R4 复用仅描述旧入口。
|
||||
*
|
||||
* <p><b>R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿)</b>:biz uk 命中已终态办件
|
||||
* (30 已拒绝 / 40 已撤回)时复用同一办件行"重启推进"——不新建、不报错:定义刷新为当前
|
||||
@@ -63,6 +64,13 @@ public class FlowInstanceService {
|
||||
public final String applyAppCode;
|
||||
/** 发起时指派快照 JSON(oapi apply 校验后渲染;null=无指派) */
|
||||
public final String assignJson;
|
||||
|
||||
/**
|
||||
* 受理载荷指纹(2026-10-25 T04,SHA-256 hex;<b>非构造参数</b>——13 参构造被
|
||||
* {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值;
|
||||
* null=存量调用方,指纹核对缺省关)。
|
||||
*/
|
||||
public String payloadHash;
|
||||
}
|
||||
|
||||
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
|
||||
@@ -138,6 +146,10 @@ public class FlowInstanceService {
|
||||
if (round != null) {
|
||||
instance.setApplyRound(round);
|
||||
}
|
||||
if (cmd.payloadHash != null) {
|
||||
// 新一轮命令重新取指纹(新轮=新命令,指纹跟随最新受理载荷)
|
||||
instance.setApplyPayloadHash(cmd.payloadHash);
|
||||
}
|
||||
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
instance.setSubmitTime(LocalDateTime.now());
|
||||
instance.setFinishTime(null);
|
||||
@@ -297,6 +309,16 @@ public class FlowInstanceService {
|
||||
public final String requestToken;
|
||||
public final Integer attempt;
|
||||
public final boolean retryOnly;
|
||||
/**
|
||||
* 期望的前一轮次(2026-10-25 T04 服务端轮次收口,可选):显式申报"我基于第 N 轮重提"。
|
||||
* 非空时:行轮次 ≠ N ⇒ STALE_ROUND(不重启);通过则服务端分配 round=N+1,
|
||||
* <b>不信客户端 attempt</b>。null=既有客户端 attempt 语义(存量兼容)。
|
||||
*/
|
||||
public final Integer expectedPrevRound;
|
||||
|
||||
public ApplyRetryPolicy(String requestToken, Integer attempt, boolean retryOnly) {
|
||||
this(requestToken, attempt, retryOnly, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** 守卫化结果:除 instanceId/duplicated 外,把"命中的是哪一种情形"落成稳定分支码 */
|
||||
@@ -345,6 +367,15 @@ public class FlowInstanceService {
|
||||
// 新一轮授权重提(attempt≥2)才走得动 R4——存量行的守卫缺省是**关**而不是开。
|
||||
int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
|
||||
if (token != null && token.equals(existing.getApplyRequestToken())) {
|
||||
// T04(2026-10-25):同 token 必须同指纹——同 ID 不同载荷是两份命令撞同一 token,
|
||||
// 按冲突拒绝,不伪装成幂等成功。存量行(指纹 NULL)或请求无指纹时跳过(守卫缺省关)。
|
||||
if (existing.getApplyPayloadHash() != null && cmd.payloadHash != null
|
||||
&& !existing.getApplyPayloadHash().equals(cmd.payloadHash)) {
|
||||
log.warn("[flow-center] 同请求标识但载荷指纹不一致(冲突拒绝):instance={} biz={}/{}",
|
||||
existing.getId(), cmd.bizType, cmd.bizId);
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
|
||||
}
|
||||
log.info("[flow-center] 同请求标识重投命中,只回读现状不重启:instance={} biz={}/{} status={}",
|
||||
existing.getId(), cmd.bizType, cmd.bizId, existing.getStatus());
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
@@ -353,6 +384,15 @@ public class FlowInstanceService {
|
||||
int status = existing.getStatus();
|
||||
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|
||||
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
|
||||
// T04:带 token 的请求撞上在途/通过件且 token 不符=另一份命令(审计 P0-2:
|
||||
// "10/20 即使异命令也回 IDEMPOTENT"是把他人命令的成功当自己的)。无 token 的
|
||||
// 存量调用方保持 IDEMPOTENT(守卫缺省关)。
|
||||
if (token != null) {
|
||||
log.warn("[flow-center] 在途/通过件命中且请求 token 不符(冲突拒绝):instance={} biz={}/{}",
|
||||
existing.getId(), cmd.bizType, cmd.bizId);
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
|
||||
}
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.IDEMPOTENT, existing.getApplyRound());
|
||||
}
|
||||
@@ -363,21 +403,52 @@ public class FlowInstanceService {
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.REPLAY_ONLY, existing.getApplyRound());
|
||||
}
|
||||
int incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt;
|
||||
if (incomingRound <= currentRound) {
|
||||
log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}",
|
||||
existing.getId(), currentRound, incomingRound);
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
|
||||
// T04 服务端轮次:显式 expectedPrevRound 时——行轮次必须恰为 N(否则陈旧),服务端只分配 N+1;
|
||||
// 未显式时保持客户端 attempt 语义(存量兼容)。
|
||||
Integer expectedPrev = policy == null ? null : policy.expectedPrevRound;
|
||||
int incomingRound;
|
||||
if (expectedPrev != null) {
|
||||
if (expectedPrev != currentRound) {
|
||||
log.warn("[flow-center] 重提前置轮次不符(不重启):instance={} 行轮次={} 期望前轮={}",
|
||||
existing.getId(), currentRound, expectedPrev);
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
|
||||
}
|
||||
incomingRound = currentRound + 1;
|
||||
} else {
|
||||
incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt;
|
||||
if (incomingRound <= currentRound) {
|
||||
log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}",
|
||||
existing.getId(), currentRound, incomingRound);
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
|
||||
}
|
||||
}
|
||||
restart(existing, cmd, token, incomingRound);
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.RESTARTED, incomingRound);
|
||||
}
|
||||
|
||||
/** 新建实例(与旧 apply 同一段代码形状:不复制第二份推进逻辑) */
|
||||
/** Formal command caller must hold the coordinate lock in the same transaction. */
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public FlwInstance createFormalInstance(ApplyCommand cmd, String token, Integer round, String source) {
|
||||
return createInstance(cmd, token, round, source);
|
||||
}
|
||||
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
public void startFormalInstance(FlwInstance instance, ApplyCommand cmd) {
|
||||
flowEngine.startAdvance(instance, cmd.def);
|
||||
publishSubmitted(instance);
|
||||
}
|
||||
|
||||
private void createInstance(ApplyCommand cmd, String token, Integer round) {
|
||||
createInstance(cmd, token, round, null);
|
||||
}
|
||||
|
||||
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setSourcePrincipalId(source);
|
||||
instance.setLegacySlot(source == null ? 1 : null);
|
||||
instance.setSchId(cmd.schId);
|
||||
instance.setDefId(cmd.def.getId());
|
||||
instance.setDefCode(cmd.def.getCode());
|
||||
@@ -393,6 +464,7 @@ public class FlowInstanceService {
|
||||
instance.setApplyAppCode(cmd.applyAppCode);
|
||||
instance.setApplyRequestToken(token);
|
||||
instance.setApplyRound(round);
|
||||
instance.setApplyPayloadHash(cmd.payloadHash);
|
||||
instance.setSubmitterId(cmd.submitterId);
|
||||
instance.setSubmitterName(cmd.submitterName);
|
||||
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
@@ -400,8 +472,11 @@ public class FlowInstanceService {
|
||||
instanceRepository.saveAndFlush(instance);
|
||||
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
|
||||
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
|
||||
flowEngine.startAdvance(instance, cmd.def);
|
||||
publishSubmitted(instance);
|
||||
if (source == null) {
|
||||
flowEngine.startAdvance(instance, cmd.def);
|
||||
publishSubmitted(instance);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, "仅允许访问本应用来源的流程事实"); }
|
||||
}
|
||||
@@ -162,15 +162,18 @@ public class FlowRouterAdminService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 重跑路由(F5-4 僵尸逃生门):useLatestDef=true 时 def 改取该 flowGuid 当前已发布最高版本,
|
||||
* 办件 defId/defCode/defVersion 快照同步刷新(与 R4 重启同语义,只刷定义三列,不动业务快照/
|
||||
* 状态/时间),trace 落一行留痕(保留节点 id=reroute-def,nodeName 载明 v旧→v新)。
|
||||
* 锚定 128/129 僵尸案例:办件挂在 v1 的 CONDITION 节点、修好的表达式已发布为 v2,按 def 快照
|
||||
* 重跑永远打转——此开关让管理员一键切到 v2 续走。在途 CONDITION 节点在新定义不存在时
|
||||
* 按人类可读业务错拒绝(不猜测节点映射)。useLatestDef=false 现行为逐字不变。
|
||||
* 重跑路由(挂起恢复唯一入口,§4.4)。2026-10-25 T03 在途锁版收口:useLatestDef=true 的
|
||||
* "切到最新发布版续走"旁路已按"在途实例固定原版本"原则停用——新发布版只影响新申报;
|
||||
* 挂起件按原定义重跑(修复表达式后按<b>原版本</b>重新发布或由发起方重新申报)。
|
||||
* 保留参数与显式拒绝(不静默忽略),旧调用方拿到人类可读业务错。
|
||||
*/
|
||||
@Transactional
|
||||
public void reroute(Long schId, Long instanceId, boolean useLatestDef) {
|
||||
if (useLatestDef) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"在途换版已停用(在途实例固定使用原版本,新版本只影响新申报);"
|
||||
+ "请修复表达式后按原版本重新发布,或让发起方终态后重新申报");
|
||||
}
|
||||
FlwInstance instance = instanceRepository.findById(instanceId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
|
||||
if (!instance.getSchId().equals(schId)) {
|
||||
@@ -183,58 +186,15 @@ public class FlowRouterAdminService {
|
||||
if (def == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失");
|
||||
}
|
||||
if (useLatestDef) {
|
||||
def = refreshToLatestPublishedDef(instance, def);
|
||||
}
|
||||
String conditionNode = findParkedCondition(instance, def);
|
||||
if (conditionNode == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, useLatestDef
|
||||
? "在途条件节点在最新已发布定义中不存在(节点已被改版移除/改型),无法按新定义重跑"
|
||||
: "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
|
||||
}
|
||||
flowEngine.rerouteCondition(instance, def, conditionNode);
|
||||
instanceRepository.save(instance);
|
||||
}
|
||||
|
||||
/** 取 flowGuid 当前已发布最高版本并刷新办件定义快照(仅三列;已是最新则原样返回不落留痕) */
|
||||
private FlwDef refreshToLatestPublishedDef(FlwInstance instance, FlwDef current) {
|
||||
FlwDef latest = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
instance.getSchId(), current.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElse(null);
|
||||
if (latest == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该流程当前无已发布版本,无法按最新定义重跑");
|
||||
}
|
||||
if (latest.getId().equals(instance.getDefId())) {
|
||||
return latest;
|
||||
}
|
||||
instance.setDefId(latest.getId());
|
||||
instance.setDefCode(latest.getCode());
|
||||
instance.setDefVersion(latest.getVersion());
|
||||
appendRerouteDefTrace(instance, current.getVersion(), latest.getVersion());
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义切换留痕行(同 FlowInstanceService.appendTraceEvent 形态;彼方法私有且该类冻结,不共用):
|
||||
* status=6 + 保留节点 id,nodeName 载明版本迁移;不参与引擎任何判断(同 submit/withdraw 行)。
|
||||
*/
|
||||
private void appendRerouteDefTrace(FlwInstance instance, Integer fromVersion, Integer toVersion) {
|
||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||
com.yunhai.flowcenter.entity.FlwTask row = new com.yunhai.flowcenter.entity.FlwTask();
|
||||
row.setInstanceId(instance.getId());
|
||||
row.setSchId(instance.getSchId());
|
||||
row.setNodeId(FlwEnums.TaskStatus.NODE_REROUTE_DEF);
|
||||
row.setNodeName("按新版定义重跑(v" + fromVersion + "→v" + toVersion + ")");
|
||||
// assignee=0:管理端动作无自然办理人,落 0 不产生待办(同发起行脏数据兜底口径)
|
||||
row.setAssigneeId(0);
|
||||
row.setAssigneeName("管理端");
|
||||
row.setStatus(FlwEnums.TaskStatus.SUBMITTED.getCode());
|
||||
row.setArriveTime(now);
|
||||
row.setHandleTime(now);
|
||||
row.setUrgeCount(0);
|
||||
taskRepository.save(row);
|
||||
}
|
||||
|
||||
/** 在途节点里找 CONDITION(多个时取第一个;正常挂起只会有一个) */
|
||||
private String findParkedCondition(FlwInstance instance, FlwDef def) {
|
||||
String cur = instance.getCurrentNodes();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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):学校×流程线×稳定主体的授予/撤销/查询。
|
||||
*
|
||||
* <p>授予时把 appKey 解析成 UserBase 稳定主键 appId 落库(授权对象是主体不是凭据),
|
||||
* 并按学校开通事实做发放闸(enforce 开启时未开通拒发)。撤销只置 {@code enabled=0}
|
||||
* (不删行,审计与幂等判据保留);撤权只拦新发起,不回写在途实例与已受理命令。</p>
|
||||
*/
|
||||
@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<FlwUsageGrant> listByFlow(Long schId, String flowGuid) {
|
||||
return grantRepository.findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(schId, flowGuid);
|
||||
}
|
||||
|
||||
public List<FlwUsageGrant> 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;
|
||||
}
|
||||
}
|
||||
@@ -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) 许可。
|
||||
*
|
||||
* <p>三层独立事实,缺一不可(enforce 开启时):</p>
|
||||
* <ol>
|
||||
* <li><b>稳定主体</b>:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用,
|
||||
* 不是主体;主体解析不可达=fail-closed 拒绝);</li>
|
||||
* <li><b>FL 本地使用许可</b>:{@code flw_usage_grant} 生效行(独立于 flw_app 定义保存白名单
|
||||
* 与流程定义管理权);</li>
|
||||
* <li><b>UB 学校开通</b>:该应用在该校的平台开通事实(第二重证明,不因本地 grant 而豁免)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>撤权边界(P0-3)</b>:本闸只拦"新发起";已受理命令的回执恢复与在途实例不回读本表——
|
||||
* 恢复路径的"是否已受理"判定归命令协议(T04),调用方负责在恢复命中时跳过本闸。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:原命令保持不变"); }
|
||||
}
|
||||
@@ -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<Integer> 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<String, JsonNode> sorted = new TreeMap<>();
|
||||
node.fields().forEachRemaining(e -> sorted.put(e.getKey(), e.getValue()));
|
||||
StringJoiner out = new StringJoiner(",", "{", "}");
|
||||
for (Map.Entry<String, JsonNode> 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); }
|
||||
}
|
||||
@@ -106,15 +106,114 @@ public class OapiFlowService {
|
||||
this.transactionTemplate = template;
|
||||
}
|
||||
|
||||
/**
|
||||
* T01 应用使用许可闸(2026-10-25):新发起必须持有 (school, flowGuid, appId) 生效许可。
|
||||
* setter 注入(构造被单测逐位钉住,同 transactionTemplate 先例);未装配(老单测直接 new)
|
||||
* 视为关闭,生产装配后由 {@code yh.flow.school-activation-enforce} 控制生效。
|
||||
* enforce 开启时 UserBase 不可达 fail-closed(见 {@link FlowUsageGrantGuard})。
|
||||
*/
|
||||
private FlowUsageGrantGuard usageGrantGuard;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
public void setUsageGrantGuard(FlowUsageGrantGuard guard) {
|
||||
this.usageGrantGuard = guard;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起期变量终审(字段注入:保持既有构造签名)。手工构造服务时也使用同一校验器,
|
||||
* 所有拒绝均发生在实例创建之前。
|
||||
*/
|
||||
private ApplyVariableValidator applyVariableValidator;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
public void setApplyVariableValidator(ApplyVariableValidator validator) {
|
||||
this.applyVariableValidator = validator;
|
||||
}
|
||||
|
||||
/** R1 SDK 快捷审批门禁(字段注入同上):门禁无条件 fail-closed,装配与否均不放行 */
|
||||
private FlowActorContextGate actorContextGate;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
public void setActorContextGate(FlowActorContextGate gate) {
|
||||
this.actorContextGate = gate;
|
||||
}
|
||||
|
||||
private void requireExpressionVariablesPresent(FlwDef def, String formDataJson) {
|
||||
ApplyVariableValidator validator = applyVariableValidator == null
|
||||
? new ApplyVariableValidator(objectMapper) : applyVariableValidator;
|
||||
validator.requireVariablesPresent(
|
||||
FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
|
||||
}
|
||||
|
||||
private FlowInstanceService.GuardedOutcome inTransaction(
|
||||
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
|
||||
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
|
||||
}
|
||||
|
||||
@Value("${flow-center.formal-command.enabled:false}")
|
||||
private boolean formalCommandEnabled;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private FlowSourcePrincipalResolver sourcePrincipalResolver;
|
||||
@org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwApplyCommandRepository formalCommands;
|
||||
@org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwUsageGrantRepository formalGrants;
|
||||
|
||||
private void requireLegacyWriter() {
|
||||
if (formalCommandEnabled) throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"正式命令模式已启用,请迁移到 /oapi/flow/commands;旧发起接口已关闭");
|
||||
}
|
||||
|
||||
/** New-command validation only. Never call this while recovering a settled receipt. */
|
||||
FlowInstanceService.ApplyCommand prepareFormal(Long schId, FlowSourcePrincipalResolver.Principal source,
|
||||
com.yunhai.flowcenter.model.FormalApplyModels.Request body) {
|
||||
BoaUser user;
|
||||
try { user = userClient.getUserById(String.valueOf(body.getSubmitterId())); }
|
||||
catch (RuntimeException unavailable) { throw new IllegalStateException("提交人权威查询不可用", unavailable); }
|
||||
if (user == null || !Integer.valueOf(1).equals(user.getStatus()) || user.getSchool() == null || user.getSchool().getId() == null
|
||||
|| !schId.equals(Long.valueOf(user.getSchool().getId()))) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "提交人不存在或不属于该学校");
|
||||
}
|
||||
if (body.getAssigns() != null && !body.getAssigns().isEmpty() && body.getExpectedDefVersion() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "正式指派须携带预览定义版本锚");
|
||||
}
|
||||
FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
|
||||
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程版本已改变,请刷新后使用新的请求 ID");
|
||||
}
|
||||
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
|
||||
// D11:固定 USERS 属审批责任人,正式命令在受理阶段整体终审——任一非法即 VALIDATION_REJECTED,
|
||||
// 不落实例、不做“过滤后继续”。运行期建任务仍由 FlowEngine/AssigneeResolver 再兜底。
|
||||
assigneeResolver.requireEligibleFixedAssignees(schId, FlowDefModel.parse(objectMapper, def.getContentJson()));
|
||||
String assigns = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
|
||||
return new FlowInstanceService.ApplyCommand(schId, def, body.getBizType(), body.getBizId(),
|
||||
body.getBizTitle(), body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
|
||||
body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()),
|
||||
source.appKey, source.appCode, assigns);
|
||||
}
|
||||
|
||||
private boolean canReadFormalDefinition(Long school, String flow) {
|
||||
String source = FlowOapiSourceScope.current();
|
||||
return formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, source, 1)
|
||||
|| formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(school, source, flow, "ACCEPTED");
|
||||
}
|
||||
|
||||
private void requireFormalPreviewGrant(Long school, String flow) {
|
||||
if (!formalCommandEnabled) return;
|
||||
FlowSourcePrincipalResolver.Principal principal = sourcePrincipalResolver.resolve(
|
||||
(String)((org.springframework.web.context.request.ServletRequestAttributes)org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes()).getRequest().getAttribute("yh.app.auth"));
|
||||
if (!formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, principal.id, 1)
|
||||
|| !formalActivation.isAppActivatedStrict(principal.appId, school)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "没有此流程线的发起预览权限");
|
||||
}
|
||||
}
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired private UserBaseActivationClient formalActivation;
|
||||
|
||||
/** 已发布流程列表(每条流程线取最高已发布版本) */
|
||||
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
|
||||
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
|
||||
for (FlwDef def : defRepository.findPublishedLatestPerGuid(schId)) {
|
||||
if (formalCommandEnabled && !canReadFormalDefinition(schId, def.getFlowGuid())) continue;
|
||||
out.add(toSummary(def));
|
||||
}
|
||||
return out;
|
||||
@@ -122,6 +221,7 @@ public class OapiFlowService {
|
||||
|
||||
/** 流程详情回显(未发布/不存在返回 null,消费方按"未绑定流程"降级) */
|
||||
public OapiModels.FlowSummaryVo getFlow(Long schId, String flowGuid) {
|
||||
if (formalCommandEnabled && !canReadFormalDefinition(schId, flowGuid)) return null;
|
||||
return defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.map(this::toSummary).orElse(null);
|
||||
@@ -131,16 +231,20 @@ public class OapiFlowService {
|
||||
* 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */
|
||||
@Transactional
|
||||
public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) {
|
||||
requireLegacyWriter();
|
||||
if (schId == null || body.getFlowGuid() == null || body.getBizType() == null
|
||||
|| body.getBizId() == null || body.getSubmitterId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
|
||||
}
|
||||
requireSchoolMember(schId, body.getSubmitterId());
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程不存在或未发布:" + body.getFlowGuid()));
|
||||
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||||
// T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报,
|
||||
// 必须持有效使用许可。恢复感知的分支判定与 FlowInstanceService.apply 保持同键同序。
|
||||
requireUsageGrantLegacyApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(), body.getBizId());
|
||||
FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
|
||||
// T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行
|
||||
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
|
||||
// S2.7 版本竞态(可选、opt-in):调用方回带预览时的定义版本,改版即人类可读拒绝,
|
||||
// 免得选好的 nodeId/候选人在新版本里对不上而报「指派节点不存在」。不传=现状逐字不变。
|
||||
if (body.getExpectedDefVersion() != null
|
||||
@@ -150,7 +254,6 @@ public class OapiFlowService {
|
||||
+ ",当前已发布版本 " + def.getVersion() + ")");
|
||||
}
|
||||
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
|
||||
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||||
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
|
||||
schId, def,
|
||||
body.getBizType(), body.getBizId(), body.getBizTitle(),
|
||||
@@ -184,23 +287,40 @@ public class OapiFlowService {
|
||||
*/
|
||||
public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey,
|
||||
FlowApplyRetryModels.ApplyRequestV2 body) {
|
||||
requireLegacyWriter();
|
||||
if (schId == null || body == null || body.getFlowGuid() == null || body.getBizType() == null
|
||||
|| body.getBizId() == null || body.getSubmitterId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
|
||||
}
|
||||
requireSchoolMember(schId, body.getSubmitterId());
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程不存在或未发布:" + body.getFlowGuid()));
|
||||
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
|
||||
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||||
// T01 使用许可(v2 路径):恢复感知——SAME_REQUEST/REPLAY_ONLY/IDEMPOTENT/STALE_ROUND
|
||||
// 四类回执分支不拦(已受理命令的回执不因许可撤销丢失);CREATED/RESTARTED(新申报/新轮)
|
||||
// 必须持有效使用许可。分支判定与 FlowInstanceService.applyGuarded 同键同序;
|
||||
// 完整命令协议(requestId/指纹/服务端轮次)统一归 T04 重排,此处不重构。
|
||||
requireUsageGrantGuardedApply(schId, body.getFlowGuid(), cleanAppKey, body.getBizType(),
|
||||
body.getBizId(), body.getRequestToken(), body.getAttempt(),
|
||||
Boolean.TRUE.equals(body.getRetryOnly()));
|
||||
FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId(
|
||||
schId, body.getBizType(), body.getBizId()).orElse(null);
|
||||
boolean replay = existing != null && body.getRequestToken() != null
|
||||
&& !body.getRequestToken().trim().isEmpty()
|
||||
&& body.getRequestToken().trim().equals(existing.getApplyRequestToken());
|
||||
// 同 token 用已受理版本构造原指纹;归属和异载荷冲突仍由 applyGuarded 原守卫裁决。
|
||||
FlwDef def = replay ? defRepository.findById(existing.getDefId())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "原请求的定义版本不存在"))
|
||||
: requirePublishedDefForApply(schId, body.getFlowGuid());
|
||||
if (replay && !body.getFlowGuid().equals(def.getFlowGuid())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件绑定的流程线不同");
|
||||
}
|
||||
if (!replay) requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
|
||||
if (!replay && body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
|
||||
+ ",当前已发布版本 " + def.getVersion() + ")");
|
||||
}
|
||||
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
|
||||
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||||
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
|
||||
schId, def,
|
||||
body.getBizType(), body.getBizId(), body.getBizTitle(),
|
||||
@@ -208,8 +328,11 @@ public class OapiFlowService {
|
||||
body.getSubmitterId(), body.getSubmitterName(),
|
||||
asFormDataString(body.getFormData()),
|
||||
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
|
||||
// T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝
|
||||
cmd.payloadHash = payloadHash(cmd);
|
||||
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
|
||||
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()));
|
||||
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()),
|
||||
body.getExpectedPrevRound());
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome;
|
||||
try {
|
||||
@@ -218,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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发撞键后的收口:重读既有实例 → <b>先核归属</b> → 同 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<String> missingRequiredAssignNodes(FlowDefModel model, List<OapiModels.AssignItem> assigns) {
|
||||
java.util.Set<String> submitted = new java.util.HashSet<>();
|
||||
if (assigns != null) {
|
||||
for (OapiModels.AssignItem item : assigns) {
|
||||
if (item.getNodeId() != null) {
|
||||
submitted.add(item.getNodeId());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (n == null || !FlowDefModel.TYPE_APPROVAL.equals(n.type) || n.assignPolicy == null
|
||||
|| !Boolean.TRUE.equals(n.assignPolicy.required)) {
|
||||
continue;
|
||||
}
|
||||
if (!submitted.contains(n.id)) {
|
||||
missing.add(n.id);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private String renderAssignJson(Long schId, FlwDef def, List<OapiModels.AssignItem> assigns,
|
||||
Integer submitterId) {
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
// T06(2026-10-25)required 覆盖终审:遍历<b>定义里全部</b>开启且必选的指派节点——
|
||||
// 不能只遍历请求里出现的项,更不能让空 assigns 绕过 required 检查(审计 §7.4 点名)。
|
||||
List<String> missing = missingRequiredAssignNodes(model, assigns);
|
||||
if (!missing.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"必选指派节点未提交人选(node=" + String.join(", ", missing) + "),请重新选择后提交");
|
||||
}
|
||||
if (assigns == null || assigns.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>();
|
||||
Set<String> seenNodeIds = new LinkedHashSet<>();
|
||||
for (OapiModels.AssignItem item : assigns) {
|
||||
@@ -329,6 +625,16 @@ public class OapiFlowService {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"不允许指派发起人本人:" + item.getNodeId());
|
||||
}
|
||||
// D11:手选人先逐人资格终审(存在/启用/校域),再核候选集——任一人不合格整体拒绝,
|
||||
// 快照与实例均不落;不能只信前端候选列表,也不能以“在候选 ID 中”替代资格。
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Integer uid : uids) {
|
||||
String userName = assigneeResolver.requireEligibleAssigneeName(schId, uid);
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("userId", uid);
|
||||
row.put("userName", userName);
|
||||
rows.add(row);
|
||||
}
|
||||
// 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝
|
||||
List<AssigneeRef> candidates = assigneeResolver.resolve(schId, node);
|
||||
Set<Integer> candidateIds = new LinkedHashSet<>();
|
||||
@@ -355,14 +661,6 @@ public class OapiFlowService {
|
||||
"指派人数越界[" + min + "~" + max + "]:" + item.getNodeId());
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Integer uid : uids) {
|
||||
String userName = assigneeResolver.requireUserName(uid);
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("userId", uid);
|
||||
row.put("userName", userName);
|
||||
rows.add(row);
|
||||
}
|
||||
snapshot.put(item.getNodeId(), rows);
|
||||
}
|
||||
try {
|
||||
@@ -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<FlwTask> p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType),
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
Page<FlwTask> p = formalCommandEnabled
|
||||
? taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)))
|
||||
: taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)));
|
||||
List<Long> instanceIds = p.getContent().stream()
|
||||
.map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList());
|
||||
Map<Long, FlwInstance> instances = instanceIds.isEmpty()
|
||||
@@ -468,8 +765,9 @@ public class OapiFlowService {
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
|
||||
}
|
||||
Page<FlwInstance> p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status,
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
Page<FlwInstance> p = formalCommandEnabled
|
||||
? instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)))
|
||||
: instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)));
|
||||
List<OapiModels.InstanceItemVo> items = p.getContent().stream()
|
||||
.map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList());
|
||||
return toPageResult(p.getTotalElements(), items, page, size);
|
||||
@@ -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<FlwInstance> findByBizForOapi(Long school, String type, String biz) {
|
||||
return formalCommandEnabled
|
||||
? instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(school, FlowOapiSourceScope.current(), type, biz)
|
||||
: instanceRepository.findBySchIdAndBizTypeAndBizId(school, type, biz);
|
||||
}
|
||||
|
||||
private FlwInstance requireInstance(Long schId, Long instanceId) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
|
||||
@@ -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());
|
||||
|
||||
@@ -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 侧)。
|
||||
*
|
||||
* <p>事实源=UserBase {@code sys_group_app_rel}(学校组单点开通,不沿祖先),
|
||||
* 开放端点 {@code GET /oapi/app/activated?appId=&schoolId=}。<b>enforce 开启时
|
||||
* 不可达/异常一律抛错(fail-closed)</b>——"UserBase 不可达"不得降级为"仅本地 grant 存在即放行"。</p>
|
||||
*/
|
||||
@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<String, String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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) 既有索引后过滤,暂不加列索引
|
||||
@@ -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`;
|
||||
@@ -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`;
|
||||
@@ -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 白名单)';
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<Long> logIds=new ArrayList<>(), regIds=new ArrayList<>();
|
||||
final AtomicReference<Receiver> receiver=new AtomicReference<>();
|
||||
final AtomicInteger hits=new AtomicInteger(), active=new AtomicInteger(), maxActive=new AtomicInteger();
|
||||
final List<String> 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<Long> 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<Integer> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,9 +171,10 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
org.springframework.http.ResponseEntity<JsonNode> applyResp =
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
// 3. 轮询 stub:submitted + task.created 两事件都到齐(探活 ping 不计)
|
||||
// 3. 轮询 stub:submitted + task.created 两事件都到齐(探活 ping 不计;异步首推
|
||||
// 可能被启动扫描的遗留行恢复推送短暂占池,等待上限放宽——P1-1)
|
||||
waitUntil(() -> received.stream().anyMatch(r -> r.body.contains("flow.submitted"))
|
||||
&& received.stream().anyMatch(r -> r.body.contains("flow.task.created")), 10_000);
|
||||
&& received.stream().anyMatch(r -> r.body.contains("flow.task.created")), 60_000);
|
||||
boolean hasSubmitted = false;
|
||||
boolean hasTaskCreated = false;
|
||||
for (Received r : received) {
|
||||
@@ -221,10 +222,13 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
.filter(r -> r.body != null && r.body.contains("\"ping\"")).findFirst().orElse(null);
|
||||
assertNotNull(ping, "注册探活应命中 stub");
|
||||
assertAppSignature("/callback/flow", ping);
|
||||
// 4. 日志表成功态(异步推送落定,轮询等待)
|
||||
assertTrue(logRepository.findAll().size() >= 2);
|
||||
// 4. 日志表成功态(异步推送落定,轮询等待——只认本用例两行,残留行不计)
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()), 10_000);
|
||||
.filter(l -> "cb-1".equals(l.getBizId())).count() >= 2, 60_000);
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.filter(l -> "cb-1".equals(l.getBizId()))
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()),
|
||||
60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,7 +265,7 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
|
||||
waitUntil(() -> hits.stream().anyMatch(r -> r.body != null && r.body.contains("flow.submitted")), 10_000);
|
||||
waitUntil(() -> hits.stream().anyMatch(r -> r.body != null && r.body.contains("flow.submitted")), 60_000);
|
||||
Received push = hits.stream()
|
||||
.filter(r -> r.body != null && r.body.contains("flow.submitted"))
|
||||
.findFirst().orElseThrow(IllegalStateException::new);
|
||||
@@ -288,16 +292,14 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
|
||||
// 首推失败:retryCount=1(异步首推,轮询等待)
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
// 首推失败:retryCount=1(异步首推,轮询等待——只认本用例行)
|
||||
waitFirstFailureLanded("cb-2");
|
||||
// 手工逐次触发扫描语义再失败 5 轮(共 6 次,越过退避表 5 项)
|
||||
FlwCallbackLog hung = soleRow("cb-2");
|
||||
for (int round = 0; round < 5; round++) {
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
pushService.pushAsync(hung.getId());
|
||||
hung = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new);
|
||||
}
|
||||
FlwCallbackLog hung = logRepository.findAll().get(0);
|
||||
// T1.1 核心语义:持续重试不自动死信——status 仍=0、retryCount=6、nextRetry≈now+60min(封顶)
|
||||
assertEquals(FlwEnums.CallbackLogStatus.PENDING.getCode(), hung.getStatus(),
|
||||
"越过退避表后必须保持待推持续重试,不得自动死信");
|
||||
@@ -308,8 +310,9 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
&& hung.getNextRetryTime().isBefore(expectedNext.plusMinutes(5)),
|
||||
"越过退避表后 nextRetryTime 应封顶 +60 分钟: " + hung.getNextRetryTime());
|
||||
assertTrue(logRepository.findAll().stream()
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()),
|
||||
"任何行都不应出现死信(status=2)");
|
||||
.filter(l -> "cb-2".equals(l.getBizId()))
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()),
|
||||
"本用例行不应出现死信(status=2)");
|
||||
assertNotNull(hung.getLastError());
|
||||
}
|
||||
|
||||
@@ -330,23 +333,21 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-4\",\"bizTitle\":\"张三的告警件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
waitFirstFailureLanded("cb-4");
|
||||
|
||||
// 失败到第 6 次(=挂起阈值):告警恰一次
|
||||
FlwCallbackLog row = soleRow("cb-4");
|
||||
for (int round = 0; round < 5; round++) {
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
pushService.pushAsync(row.getId());
|
||||
row = logRepository.findById(row.getId()).orElseThrow(IllegalStateException::new);
|
||||
}
|
||||
assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 6 次失败后挂起告警应恰发送一次");
|
||||
|
||||
// 第 7 次失败:仍是 1(恰一次)
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
pushService.pushAsync(row.getId());
|
||||
row = logRepository.findById(row.getId()).orElseThrow(IllegalStateException::new);
|
||||
assertEquals(1, STUB_USER_MSG.hangAlerts.get(), "第 7 次失败后告警计数仍应为 1");
|
||||
assertEquals(7, logRepository.findAll().get(0).getRetryCount());
|
||||
assertEquals(7, row.getRetryCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -373,13 +374,12 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
|
||||
// 首推 500 → 进入重试(status=0, retryCount=1)
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
waitFirstFailureLanded("cb-5");
|
||||
// 消费方恢复后(扫描/重推触发)→ 200 → status=1 + pushTime 落定
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
FlwCallbackLog recovered = logRepository.findAll().get(0);
|
||||
FlwCallbackLog failed = soleRow("cb-5");
|
||||
pushService.pushAsync(failed.getId());
|
||||
FlwCallbackLog recovered = logRepository.findById(failed.getId())
|
||||
.orElseThrow(IllegalStateException::new);
|
||||
assertEquals(FlwEnums.CallbackLogStatus.SUCCESS.getCode(), recovered.getStatus(),
|
||||
"瞬时失败恢复后应自动治愈为成功");
|
||||
assertNotNull(recovered.getPushTime());
|
||||
@@ -402,37 +402,39 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"cb-6\",\"bizTitle\":\"张三的管理件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}";
|
||||
client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
waitUntil(() -> logRepository.findAll().stream()
|
||||
.anyMatch(l -> l.getRetryCount() != null && l.getRetryCount() >= 1), 10_000);
|
||||
waitFirstFailureLanded("cb-6");
|
||||
FlwCallbackLog hung = soleRow("cb-6");
|
||||
for (int round = 0; round < 5; round++) {
|
||||
for (FlwCallbackLog row : logRepository.findAll()) {
|
||||
pushService.pushAsync(row.getId());
|
||||
}
|
||||
pushService.pushAsync(hung.getId());
|
||||
hung = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new);
|
||||
}
|
||||
FlwCallbackLog hung = logRepository.findAll().get(0);
|
||||
assertEquals(6, hung.getRetryCount());
|
||||
final FlwCallbackLog hungRow = hung;
|
||||
|
||||
// 挂起列表:status=0 且 retryCount≥阈值的分页行,不含正常行
|
||||
// 挂起列表:包含本用例挂起行,且该行合法(status=0、retryCount≥阈值)
|
||||
com.yunhai.platform.utils.model.BasePageResult<FlwCallbackLog> hungPage =
|
||||
callbackAdminService.pageHung(SCH, 1, 10);
|
||||
assertTrue(hungPage.getContent().stream().anyMatch(l -> l.getId().equals(hung.getId())));
|
||||
assertTrue(hungPage.getContent().stream()
|
||||
.allMatch(l -> l.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()
|
||||
&& l.getRetryCount() >= 6));
|
||||
assertTrue(hungPage.getContent().stream().anyMatch(l -> l.getId().equals(hungRow.getId())));
|
||||
FlwCallbackLog listed = hungPage.getContent().stream()
|
||||
.filter(l -> l.getId().equals(hungRow.getId())).findFirst()
|
||||
.orElseThrow(IllegalStateException::new);
|
||||
assertTrue(listed.getStatus() == FlwEnums.CallbackLogStatus.PENDING.getCode()
|
||||
&& listed.getRetryCount() >= 6,
|
||||
"挂起列表中的本用例行必须待推且达阈值");
|
||||
|
||||
// 管理端手工标记死信(唯一死信入口)
|
||||
callbackAdminService.markDead(SCH, hung.getId());
|
||||
FlwCallbackLog dead = logRepository.findById(hung.getId()).orElseThrow(IllegalStateException::new);
|
||||
callbackAdminService.markDead(SCH, hungRow.getId());
|
||||
FlwCallbackLog dead = logRepository.findById(hungRow.getId()).orElseThrow(IllegalStateException::new);
|
||||
assertEquals(FlwEnums.CallbackLogStatus.DEAD.getCode(), dead.getStatus());
|
||||
|
||||
// 恢复消费方地址后既有 rePush 复活:置回待推立即推送 → 成功
|
||||
regRow.setCallbackUrl("http://localhost:" + stubPort + "/callback/flow");
|
||||
regRepository.save(regRow);
|
||||
callbackAdminService.rePush(SCH, java.util.Collections.singletonList(hung.getId()));
|
||||
callbackAdminService.rePush(SCH, java.util.Collections.singletonList(hungRow.getId()));
|
||||
waitUntil(() -> {
|
||||
FlwCallbackLog row = logRepository.findById(hung.getId()).orElse(null);
|
||||
FlwCallbackLog row = logRepository.findById(hungRow.getId()).orElse(null);
|
||||
return row != null && row.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode();
|
||||
}, 10_000);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -465,6 +467,29 @@ class CallbackDispatchIT extends FlowTestSupport {
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本用例自建投递行(bizId 每用例唯一)。P1-1 教训:库中会残留上一轮运行未推完的
|
||||
* PENDING 行(崩溃/中断运行遗产),启动扫描会立即恢复推送它们——等待谓词与行定位
|
||||
* 一律以本用例 bizId 过滤,不与残留行混淆。
|
||||
*/
|
||||
private FlwCallbackLog soleRow(String bizId) {
|
||||
java.util.List<FlwCallbackLog> rows = logRepository.findAll().stream()
|
||||
.filter(l -> bizId.equals(l.getBizId()))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
assertEquals(1, rows.size(), "本用例应恰有一条投递行:bizId=" + bizId);
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
/** 等待本用例行首推失败落定(retryCount≥1)且首推线程已完全退出 IN_FLIGHT——
|
||||
* 否则紧随的手动重推首轮会被在途跳过静默吞计(P1-1 实测根因)。 */
|
||||
private void waitFirstFailureLanded(String bizId) {
|
||||
waitUntil(() -> logRepository.findAll().stream().anyMatch(l ->
|
||||
bizId.equals(l.getBizId()) && l.getRetryCount() != null && l.getRetryCount() >= 1),
|
||||
60_000);
|
||||
FlwCallbackLog row = soleRow(bizId);
|
||||
waitUntil(() -> !pushService.inFlight(row.getId()), 10_000);
|
||||
}
|
||||
|
||||
/** 应用签名断言:canonical = METHOD + "\n" + 剥离 base-path 后的 path(+排序 query) + "\n" + ts */
|
||||
private void assertAppSignature(String expectedTarget, Received r) {
|
||||
assertNotNull(r.appKey, "推送必须带 X-App-Key");
|
||||
|
||||
@@ -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<Object> businessEntityManager = new AtomicReference<>();
|
||||
private final List<Long> 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<Long> 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<Long> 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<Long> 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<Long> deliveryIds(FlowEvent event) throws Exception {
|
||||
List<Long> 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<Long> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)投递可靠性契约单测:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>统一冻结信封</b>:正常链复用事实冻结体;恢复链对冻结体原样复用、对 R2 前存量行
|
||||
* 幂等补齐 eventId/eventSeq/flowGuid——两条路径消息体同构;</li>
|
||||
* <li><b>恢复消息实际到达真实 Receiver</b>:用 BasePlatform starter 的
|
||||
* {@link FlowCallbackReceiver}(非替身)验证正常体/恢复体均被接收、落事件行并推进
|
||||
* Handler;修复前的残缺恢复体(消息缺 eventId)被按脏消息丢弃作反例对照;</li>
|
||||
* <li><b>未可靠建行不记账</b>:任一订阅方建行失败→事实保持 dispatched=0;全部建行
|
||||
* (含 uk 并发兜底 DuplicateKeyException=已建行)→才标记 dispatched=1。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<FlwCallbackLog> 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<FlwCallbackLog> 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<String, Object> 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<FlwCallbackLog> 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<FlwCallbackLog> 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<FlwCallbackLog> 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<FlwCallbackLog> 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<String> 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<FlowCallbackEvent> events = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void onEvent(FlowCallbackEvent event) {
|
||||
events.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 返修增补)规则单测:
|
||||
*
|
||||
* <ul>
|
||||
* <li>事实与状态<b>同事务</b>落库(同步监听器语义)并为事件赋稳定身份;</li>
|
||||
* <li>eventId 全链复用(事实行=投递行=重试);同事实重复发布不重复落行;</li>
|
||||
* <li>同事务事实写失败=状态事务一并失败(不吞);</li>
|
||||
* <li>URGE 站内信不入事件流;</li>
|
||||
* <li>R2:事实载荷=完整冻结信封(含 eventId/eventSeq/flowGuid),并随身份冻结到事件对象——
|
||||
* 正常分发与崩溃恢复共用同一冻结体。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<FlwStateEvent> 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<FlwStateEvent> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<AssigneeRef> 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<AssigneeRef> 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<AssigneeRef> 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;
|
||||
}
|
||||
}
|
||||
@@ -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 也不查注册表)
|
||||
|
||||
@@ -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<String,Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.yunhai.flowcenter.entity.FlwBranch;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.entity.FlwAdvanceIntent;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.service.FlowInstanceService;
|
||||
import com.yunhai.flowcenter.service.FlowTaskService;
|
||||
@@ -37,6 +38,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@ActiveProfiles("test")
|
||||
class FlowEngineIT extends FlowTestSupport {
|
||||
|
||||
@org.springframework.boot.test.mock.mockito.SpyBean
|
||||
private FlowNodeAdvanceListener advanceListener;
|
||||
@org.springframework.boot.test.mock.mockito.SpyBean
|
||||
private com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository intents;
|
||||
@org.springframework.boot.test.mock.mockito.MockBean
|
||||
private org.springframework.scheduling.TaskScheduler controlledScheduler;
|
||||
@Autowired private org.springframework.jdbc.core.JdbcTemplate d09Jdbc;
|
||||
@org.springframework.boot.test.mock.mockito.SpyBean private FlowAdvanceService advanceService;
|
||||
@org.springframework.boot.test.mock.mockito.SpyBean private com.yunhai.flowcenter.repository.FlwTaskRepository controlledTasks;
|
||||
@org.springframework.boot.test.mock.mockito.SpyBean private FlowEngine controlledEngine;
|
||||
|
||||
@Autowired
|
||||
private FlowInstanceService instanceService;
|
||||
@Autowired
|
||||
@@ -92,6 +104,9 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
assertTrue(d09Jdbc.queryForObject("SELECT @@version", String.class).startsWith("8."));
|
||||
assertEquals(13429, d09Jdbc.queryForObject("SELECT @@port", Integer.class));
|
||||
assertEquals("fl_d09_20261026", d09Jdbc.queryForObject("SELECT DATABASE()", String.class));
|
||||
cleanDb();
|
||||
StubJudge.reset("甲线");
|
||||
}
|
||||
@@ -112,6 +127,362 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
return instanceService.apply(cmd);
|
||||
}
|
||||
|
||||
@Test
|
||||
void d09W_lastHandlerWithLowerTaskIdWinsAfterRecovery() {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
mapDept("d09a",201,"First");mapDept("d09a",202,"Last");mapDept("d09b",301,"Next");
|
||||
FlwDef def=saveAndPublishDef("d09w","D09 W",multiOutletDef("d09a","d09b"));
|
||||
Long id=apply(def.getFlowGuid(),"d09-w",201,"Applicant").getInstanceId();
|
||||
List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
tasks.sort(java.util.Comparator.comparing(FlwTask::getId));
|
||||
FlwTask first=tasks.get(1),last=tasks.get(0); // Completion order deliberately reverses ID order.
|
||||
taskService.handle(SCH,first.getId(),true,"first",first.getAssigneeId(),"通过");
|
||||
taskService.handle(SCH,last.getId(),true,"last",last.getAssigneeId(),"转乙科");
|
||||
d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE instance_id=? AND status='PENDING'",id);
|
||||
advanceListener.sweepIntents();
|
||||
assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
com.yunhai.flowcenter.entity.FlwStateEvent created=stateEventRepository.findAll().stream()
|
||||
.filter(e->id.equals(e.getInstanceId()) && "flow.task.created".equals(e.getEventType()))
|
||||
.max(java.util.Comparator.comparing(com.yunhai.flowcenter.entity.FlwStateEvent::getId)).get();
|
||||
assertTrue(created.getPayloadJson().contains("\"operatorUserId\":"+last.getAssigneeId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void d09V_claimedOldIntentCannotAdvanceRestartedDefinitionAndNewIntentTakesOver() throws Exception {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next");
|
||||
FlwDef def=saveAndPublishDef("d09v","D09 V",multiOutletDef("d09a","d09b"));
|
||||
Long id=apply(def.getFlowGuid(),"d09-v",201,"Applicant").getInstanceId();
|
||||
List<FlwTask> old=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
taskService.handle(SCH,old.get(0).getId(),true,"old",old.get(0).getAssigneeId(),"通过");
|
||||
taskService.handle(SCH,old.get(1).getId(),false,"reject",old.get(1).getAssigneeId(),null);
|
||||
d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE instance_id=? AND status='PENDING'",id);
|
||||
FlwDef next=saveAndPublishDef("d09v2","D09 V2",multiOutletDef("d09a","d09b"));
|
||||
next.setFlowGuid(def.getFlowGuid());next.setVersion(2);defRepository.saveAndFlush(next);
|
||||
assertEquals(id,apply(def.getFlowGuid(),"d09-v",201,"Applicant").getInstanceId());
|
||||
java.util.concurrent.CountDownLatch claimed=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
|
||||
org.mockito.Mockito.doAnswer(inv->{
|
||||
Object rows=d09DueFromDatabase();claimed.countDown();
|
||||
assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));return rows;
|
||||
}).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
|
||||
org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
|
||||
java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
java.util.concurrent.Future<?> stale=pool.submit(()->advanceListener.sweepIntents());
|
||||
assertTrue(claimed.await(20,java.util.concurrent.TimeUnit.SECONDS));
|
||||
List<FlwTask> current=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
for(FlwTask task:current)taskService.handle(SCH,task.getId(),true,"new",task.getAssigneeId(),"转乙科");
|
||||
FlwInstance before=instanceRepository.findById(id).get();
|
||||
release.countDown();stale.get(20,java.util.concurrent.TimeUnit.SECONDS);
|
||||
FlwInstance after=instanceRepository.findById(id).get();
|
||||
assertEquals(before.getVersion(),after.getVersion());assertEquals(10,after.getStatus());
|
||||
assertEquals("n1",after.getCurrentNodes());assertEquals(2,after.getDefVersion());
|
||||
assertEquals(0,taskRepository.findByInstanceIdAndStatus(id,0).size());
|
||||
org.mockito.Mockito.doAnswer(inv->d09DueFromDatabase()).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
|
||||
org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
|
||||
advanceListener.sweepIntents();
|
||||
assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
} finally {release.countDown();pool.shutdownNow();}
|
||||
}
|
||||
|
||||
private List<com.yunhai.flowcenter.entity.FlwAdvanceIntent> d09DueFromDatabase() {
|
||||
return intents.findAll().stream().filter(i->"PENDING".equals(i.getStatus()))
|
||||
.filter(i->i.getNextRetryAt()==null || !i.getNextRetryAt().isAfter(java.time.LocalDateTime.now()))
|
||||
.limit(50).collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
private Long d09Multi(String biz) {
|
||||
mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next");
|
||||
FlwDef def=saveAndPublishDef(biz,biz,multiOutletDef("d09a","d09b"));
|
||||
return apply(def.getFlowGuid(),biz,201,"Applicant").getInstanceId();
|
||||
}
|
||||
|
||||
private FlwAdvanceIntent d09Head(Long id) {
|
||||
return intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(id,"n1").get();
|
||||
}
|
||||
|
||||
@Test void d09W_retryExhaustionRefreshesLastEdgeAndOperatorBeforeSweep() {
|
||||
Long id=d09Multi("d09-retry");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
org.mockito.Mockito.doThrow(new org.springframework.orm.ObjectOptimisticLockingFailureException(FlwInstance.class,id))
|
||||
.when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
|
||||
FlwTask first=tasks.get(1),last=tasks.get(0);
|
||||
taskService.handle(SCH,first.getId(),true,"first",first.getAssigneeId(),"通过");
|
||||
taskService.handle(SCH,last.getId(),true,"last",last.getAssigneeId(),"转乙科");
|
||||
FlwAdvanceIntent head=d09Head(id);
|
||||
assertEquals(last.getId(),head.getTriggerTaskId());assertEquals(last.getAssigneeId(),head.getAssigneeId());
|
||||
assertEquals("转乙科",head.getEdgeName());assertEquals("PENDING",head.getStatus());assertEquals(1,head.getAttempts());
|
||||
org.mockito.Mockito.verify(advanceService,org.mockito.Mockito.times(6)).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
|
||||
org.mockito.Mockito.doCallRealMethod().when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
|
||||
d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE id=?",head.getId());
|
||||
advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
assertEquals("DONE",intents.findById(head.getId()).get().getStatus());
|
||||
assertEquals(last.getAssigneeId(),stateEventRepository.findAll().stream()
|
||||
.filter(e->id.equals(e.getInstanceId()) && "flow.task.created".equals(e.getEventType()))
|
||||
.max(java.util.Comparator.comparing(com.yunhai.flowcenter.entity.FlwStateEvent::getId)).get().getOperatorId());
|
||||
}
|
||||
|
||||
@Test void d09_claimSnapshotCannotSettleARefreshedSameGeneration() throws Exception {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
Long id=d09Multi("d09-snapshot");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
|
||||
FlwAdvanceIntent observed=d09Head(id);
|
||||
java.util.concurrent.CountDownLatch claimed=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
|
||||
org.mockito.Mockito.doAnswer(inv->{Object rows=d09DueFromDatabase();claimed.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));return rows;})
|
||||
.when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
|
||||
java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
java.util.concurrent.Future<?> old=pool.submit(()->advanceListener.sweepIntents());assertTrue(claimed.await(20,java.util.concurrent.TimeUnit.SECONDS));
|
||||
taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科");
|
||||
FlwAdvanceIntent fresh=d09Head(id);assertTrue(fresh.getRevision()>observed.getRevision());
|
||||
release.countDown();old.get(20,java.util.concurrent.TimeUnit.SECONDS);
|
||||
FlwAdvanceIntent preserved=d09Head(id);assertEquals(fresh.getRevision(),preserved.getRevision());
|
||||
assertEquals("PENDING",preserved.getStatus());assertEquals(tasks.get(0).getId(),preserved.getTriggerTaskId());assertEquals("转乙科",preserved.getEdgeName());
|
||||
assertEquals(0,preserved.getAttempts());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
org.mockito.Mockito.doAnswer(inv->d09DueFromDatabase()).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
|
||||
advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
} finally {release.countDown();pool.shutdownNow();}
|
||||
}
|
||||
|
||||
@Test void d09_staleFailureCannotDeferOrOverwriteLastHandlerRefresh() throws Exception {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
Long id=d09Multi("d09-defer-race");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
|
||||
java.util.concurrent.CountDownLatch entered=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
|
||||
org.mockito.Mockito.doAnswer(inv->{entered.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));
|
||||
throw new org.springframework.orm.ObjectOptimisticLockingFailureException(FlwInstance.class,id);})
|
||||
.when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
|
||||
java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
java.util.concurrent.Future<?> old=pool.submit(()->advanceListener.sweepIntents());assertTrue(entered.await(20,java.util.concurrent.TimeUnit.SECONDS));
|
||||
taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科");
|
||||
FlwAdvanceIntent fresh=d09Head(id);release.countDown();old.get(20,java.util.concurrent.TimeUnit.SECONDS);
|
||||
FlwAdvanceIntent preserved=d09Head(id);assertEquals(fresh.getRevision(),preserved.getRevision());assertEquals(0,preserved.getAttempts());
|
||||
assertNull(preserved.getLastError());assertEquals("PENDING",preserved.getStatus());assertEquals(tasks.get(0).getId(),preserved.getTriggerTaskId());
|
||||
org.mockito.Mockito.doCallRealMethod().when(advanceService).tryAdvance(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyLong());
|
||||
advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
} finally {release.countDown();pool.shutdownNow();}
|
||||
}
|
||||
|
||||
@Test void d09_lockedSettlementThenLastHandlerCommitRearmsTheHead() throws Exception {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
Long id=d09Multi("d09-settlement");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
|
||||
java.util.concurrent.CountDownLatch locked=new java.util.concurrent.CountDownLatch(1),handlerEntered=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
|
||||
org.mockito.Mockito.doAnswer(inv->{
|
||||
if ("d09-settler".equals(Thread.currentThread().getName())) {locked.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));}
|
||||
return controlledTasks.findAll().stream().filter(t->id.equals(t.getInstanceId()) && Integer.valueOf(0).equals(t.getStatus())).collect(java.util.stream.Collectors.toList());
|
||||
}).when(controlledTasks).findByInstanceIdAndStatus(id,0);
|
||||
FlowAdvanceService target=org.springframework.test.util.AopTestUtils.getUltimateTargetObject(advanceService);
|
||||
org.mockito.Mockito.doAnswer(inv->{handlerEntered.countDown();return inv.callRealMethod();})
|
||||
.when(target).recordHandled(org.mockito.ArgumentMatchers.any());
|
||||
java.util.concurrent.ExecutorService worker=java.util.concurrent.Executors.newSingleThreadExecutor(r->new Thread(r,"d09-settler"));
|
||||
java.util.concurrent.ExecutorService handler=java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
java.util.concurrent.Future<?> settling=worker.submit(()->advanceListener.sweepIntents());assertTrue(locked.await(20,java.util.concurrent.TimeUnit.SECONDS));
|
||||
java.util.concurrent.Future<?> last=handler.submit(()->taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科"));
|
||||
assertTrue(handlerEntered.await(20,java.util.concurrent.TimeUnit.SECONDS));assertFalse(last.isDone());
|
||||
release.countDown();settling.get(20,java.util.concurrent.TimeUnit.SECONDS);last.get(20,java.util.concurrent.TimeUnit.SECONDS);
|
||||
FlwAdvanceIntent head=d09Head(id);assertEquals("PENDING",head.getStatus());assertEquals(tasks.get(0).getId(),head.getTriggerTaskId());
|
||||
assertEquals("转乙科",head.getEdgeName());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
} finally {release.countDown();worker.shutdownNow();handler.shutdownNow();}
|
||||
}
|
||||
|
||||
@Test void d09H_oldClaimAcrossReturnBackHasNoSideEffectsAndNewGenerationTakesOver() throws Exception {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
mapDept("km",103,"Main");mapDept("ka",101,"A");mapDept("kb",102,"B");
|
||||
FlwDef def=saveAndPublishDef("d09h","D09 H",mainlineParallelTailDef("km","km"));
|
||||
Long id=apply(def.getFlowGuid(),"d09-h",201,"Applicant").getInstanceId();FlwTask first=tasksOf(id,103).get(0);
|
||||
taskService.handle(SCH,first.getId(),true,"first",103);
|
||||
Long oldActivationId=d09Head(id).getId();
|
||||
java.util.concurrent.CountDownLatch claimed=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
|
||||
org.mockito.Mockito.doAnswer(inv->{Object rows=d09DueFromDatabase();claimed.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));return rows;})
|
||||
.when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
|
||||
java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
java.util.concurrent.Future<?> stale=pool.submit(()->advanceListener.sweepIntents());assertTrue(claimed.await(20,java.util.concurrent.TimeUnit.SECONDS));
|
||||
// A competing fast worker legitimately advances the old activation before the return.
|
||||
org.mockito.Mockito.doCallRealMethod().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
java.util.Map<String,Object> data=new java.util.HashMap<>();data.put("nodeId","n1");
|
||||
advanceListener.onTaskHandled(FlowEvent.of(FlowEvent.Type.TASK_HANDLED,id,first.getId(),data));
|
||||
taskService.handle(SCH,tasksOf(id,101).get(0).getId(),true,"A",101);
|
||||
taskService.handle(SCH,tasksOf(id,102).get(0).getId(),true,"B",102);
|
||||
FlwTask tail=tasksOf(id,103).get(0);assertEquals("n3",tail.getNodeId());
|
||||
taskService.returnBack(SCH,tail.getId(),"n1","return",103);
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
FlwTask current=tasksOf(id,103).get(0);assertNotEquals(first.getId(),current.getId());
|
||||
taskService.handle(SCH,current.getId(),true,"new generation",103);
|
||||
FlwInstance before=instanceRepository.findById(id).get();List<FlwTask> beforeTasks=allTasks(id);
|
||||
release.countDown();stale.get(20,java.util.concurrent.TimeUnit.SECONDS);
|
||||
FlwInstance after=instanceRepository.findById(id).get();assertEquals(before.getVersion(),after.getVersion());
|
||||
assertEquals(10,after.getStatus());assertEquals("n1",after.getCurrentNodes());assertEquals(def.getId(),after.getDefId());
|
||||
assertEquals(before.getApplyRound(),after.getApplyRound());assertEquals(beforeTasks.size(),allTasks(id).size());
|
||||
for(FlwTask t:beforeTasks)assertEquals(t.getStatus(),taskRepository.findById(t.getId()).get().getStatus());
|
||||
// Also model a historical stranded PENDING row with a CURRENT revision: only activation identity can reject it.
|
||||
FlwAdvanceIntent historical=intents.findById(oldActivationId).get();historical.setStatus("PENDING");historical=intents.saveAndFlush(historical);
|
||||
assertFalse(advanceService.tryAdvance(historical.getId(),historical.getRevision()));
|
||||
assertTrue(intents.findById(oldActivationId).get().getLastError().contains("STALE_ACTIVATION"));
|
||||
assertEquals(before.getVersion(),instanceRepository.findById(id).get().getVersion());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
assertEquals("PENDING",d09Head(id).getStatus());
|
||||
org.mockito.Mockito.doAnswer(inv->d09DueFromDatabase()).when(intents).findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
|
||||
advanceListener.sweepIntents();assertFalse(instanceRepository.findById(id).get().getCurrentNodes().contains("n1"));
|
||||
assertEquals(2,taskRepository.findByInstanceIdAndStatus(id,0).size());
|
||||
} finally {release.countDown();pool.shutdownNow();}
|
||||
}
|
||||
|
||||
@Test void d09V_guardedRoundAndDefinitionChangeRejectsOldPayloadWithoutLosingNewRoute() {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Next");
|
||||
FlwDef def=saveAndPublishDef("d09round","D09 round",multiOutletDef("d09a","d09b"));
|
||||
FlowInstanceService.ApplyCommand command=new FlowInstanceService.ApplyCommand(SCH,def,"svc_item","d09-round","Title","/",null,201,"Applicant",null,"100001","notice",null);
|
||||
Long id=instanceService.applyGuarded(command,new FlowInstanceService.ApplyRetryPolicy("d09-round-1",1,false)).instanceId;
|
||||
List<FlwTask> old=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
taskService.handle(SCH,old.get(0).getId(),true,"old",old.get(0).getAssigneeId(),"通过");
|
||||
taskService.handle(SCH,old.get(1).getId(),false,"reject",old.get(1).getAssigneeId(),null);
|
||||
FlwAdvanceIntent stale=d09Head(id);assertEquals(1,stale.getActivationApplyRound());
|
||||
FlwDef next=saveAndPublishDef("d09round2","D09 new line",multiOutletDef("d09a","d09b").replace("转乙科","新边").replace("通过","完成"));
|
||||
next.setFlowGuid(def.getFlowGuid());next.setVersion(2);defRepository.saveAndFlush(next);
|
||||
FlowInstanceService.ApplyCommand nextCommand=new FlowInstanceService.ApplyCommand(SCH,next,"svc_item","d09-round","Title","/",null,201,"Applicant",null,"100001","notice",null);
|
||||
FlowInstanceService.GuardedOutcome restarted=instanceService.applyGuarded(nextCommand,new FlowInstanceService.ApplyRetryPolicy("d09-round-2",2,false));
|
||||
assertEquals(id,restarted.instanceId);assertEquals(2,restarted.applyRound);assertEquals("RESTARTED",restarted.branch);
|
||||
for(FlwTask task:taskRepository.findByInstanceIdAndStatus(id,0))taskService.handle(SCH,task.getId(),true,"new",task.getAssigneeId(),"新边");
|
||||
FlwInstance before=instanceRepository.findById(id).get();
|
||||
assertFalse(advanceService.tryAdvance(stale.getId(),stale.getRevision()));
|
||||
FlwInstance after=instanceRepository.findById(id).get();assertEquals(before.getVersion(),after.getVersion());
|
||||
assertEquals("n1",after.getCurrentNodes());assertEquals(10,after.getStatus());assertEquals(2,after.getApplyRound());
|
||||
assertEquals(next.getId(),after.getDefId());assertEquals(2,after.getDefVersion());assertEquals(0,taskRepository.findByInstanceIdAndStatus(id,0).size());
|
||||
assertEquals("DONE",intents.findById(stale.getId()).get().getStatus());assertTrue(intents.findById(stale.getId()).get().getLastError().contains("STALE_ACTIVATION"));
|
||||
advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
}
|
||||
|
||||
private FlwAdvanceIntent d09Legacy(Long id) {
|
||||
FlwAdvanceIntent legacy=new FlwAdvanceIntent();legacy.setInstanceId(id);legacy.setNodeId("n1");legacy.setEdgeName("untrusted-old-edge");
|
||||
return intents.saveAndFlush(legacy); // NULL activation and NULL retry time deliberately model existing data.
|
||||
}
|
||||
|
||||
@Test void d09_nullLegacyIntentHandsOffToRegisteredSuccessorWithoutUsingOldPayload() {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
Long id=d09Multi("d09-null-successor");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
for(FlwTask task:tasks)taskService.handle(SCH,task.getId(),true,"current",task.getAssigneeId(),"转乙科");
|
||||
FlwAdvanceIntent successor=d09Head(id),legacy=d09Legacy(id);FlwInstance before=instanceRepository.findById(id).get();
|
||||
assertFalse(advanceService.tryAdvance(legacy.getId(),legacy.getRevision()));
|
||||
assertEquals(before.getVersion(),instanceRepository.findById(id).get().getVersion());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
assertEquals("DONE",intents.findById(legacy.getId()).get().getStatus());
|
||||
assertEquals("PENDING",intents.findById(successor.getId()).get().getStatus());
|
||||
advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
}
|
||||
|
||||
private void d09InstallBackfillProcedure() throws Exception {
|
||||
String sql=org.springframework.util.StreamUtils.copyToString(new org.springframework.core.io.ClassPathResource(
|
||||
"sql/20261026_d09_activation_intent_draft.sql").getInputStream(),java.nio.charset.StandardCharsets.UTF_8);
|
||||
String procedure=sql.split("-- D09_BACKFILL_BEGIN",2)[1].split("-- D09_BACKFILL_END",2)[0].trim();
|
||||
d09Jdbc.execute("DROP PROCEDURE IF EXISTS d09_backfill_activation");d09Jdbc.execute(procedure);
|
||||
}
|
||||
|
||||
@Test void d09_nullLegacyPendingCohortIsHeldThenAuditedBackfillAndRealHandlingResume() throws Exception {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
Long id=d09Multi("d09-null-pending");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
intents.delete(d09Head(id));FlwAdvanceIntent legacy=d09Legacy(id);
|
||||
advanceListener.sweepIntents();assertEquals("EXHAUSTED",intents.findById(legacy.getId()).get().getStatus());
|
||||
assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());assertEquals(2,taskRepository.findByInstanceIdAndStatus(id,0).size());
|
||||
assertThrows(IllegalStateException.class,()->taskService.handle(SCH,tasks.get(0).getId(),true,"must roll back",tasks.get(0).getAssigneeId(),"通过"));
|
||||
assertEquals(0,taskRepository.findById(tasks.get(0).getId()).get().getStatus());
|
||||
d09InstallBackfillProcedure();
|
||||
assertThrows(org.springframework.dao.DataAccessException.class,()->d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1","["+tasks.get(0).getId()+"]",null,"IT omitted member"));
|
||||
assertFalse(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(id,"n1").isPresent());
|
||||
String cohort="["+tasks.get(0).getId()+","+tasks.get(1).getId()+"]";
|
||||
d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,null,"IT audited pending cohort");
|
||||
assertEquals("DONE",intents.findById(legacy.getId()).get().getStatus());assertTrue(intents.findById(legacy.getId()).get().getLastError().contains("HANDOFF"));
|
||||
assertEquals("DONE",d09Head(id).getStatus());assertNull(d09Head(id).getTriggerTaskId());
|
||||
taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
|
||||
taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科");
|
||||
advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
}
|
||||
|
||||
@Test void d09_nullLegacyCompletedCohortRequiresProvenLastHandlerBeforeBackfill() throws Exception {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
Long id=d09Multi("d09-null-completed");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
taskService.handle(SCH,tasks.get(1).getId(),true,"first",tasks.get(1).getAssigneeId(),"通过");
|
||||
taskService.handle(SCH,tasks.get(0).getId(),true,"last",tasks.get(0).getAssigneeId(),"转乙科");
|
||||
intents.delete(d09Head(id));FlwAdvanceIntent legacy=d09Legacy(id);advanceListener.sweepIntents();
|
||||
assertEquals("EXHAUSTED",intents.findById(legacy.getId()).get().getStatus());assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
d09InstallBackfillProcedure();String cohort="["+tasks.get(0).getId()+","+tasks.get(1).getId()+"]";
|
||||
assertThrows(org.springframework.dao.DataAccessException.class,()->d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,null,"IT missing last-handler evidence"));
|
||||
assertEquals("EXHAUSTED",intents.findById(legacy.getId()).get().getStatus());
|
||||
d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,tasks.get(0).getId(),"IT witnessed lower-ID last handler");
|
||||
assertEquals(tasks.get(0).getId(),d09Head(id).getTriggerTaskId());assertEquals("PENDING",d09Head(id).getStatus());
|
||||
assertThrows(org.springframework.dao.DataAccessException.class,()->d09Jdbc.update("CALL d09_backfill_activation(?,?,?,?,?)",id,"n1",cohort,tasks.get(0).getId(),"IT cannot overwrite successor"));
|
||||
advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
}
|
||||
|
||||
@Test void d09_oldWorkerAlreadyPastGenerationGuardRollsBackAcrossRestart() throws Exception {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
mapDept("d09a",201,"A");mapDept("d09a",202,"B");mapDept("d09b",301,"Cancel");
|
||||
FlwDef def=saveAndPublishDef("d09-fence","D09 fence",parallelDef("d09a","d09b"));
|
||||
Long id=apply(def.getFlowGuid(),"d09-fence",201,"Applicant").getInstanceId();
|
||||
List<FlwTask> initial=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
String node=initial.stream().filter(t->t.getAssigneeId()==201).findFirst().get().getNodeId();
|
||||
for(FlwTask task:initial)if(node.equals(task.getNodeId()))taskService.handle(SCH,task.getId(),true,"old approved",task.getAssigneeId());
|
||||
FlwAdvanceIntent oldHead=intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(id,node).get();
|
||||
java.util.concurrent.CountDownLatch validated=new java.util.concurrent.CountDownLatch(1),release=new java.util.concurrent.CountDownLatch(1);
|
||||
org.mockito.Mockito.doAnswer(inv->{
|
||||
if("d09-old-guard".equals(Thread.currentThread().getName())) {validated.countDown();assertTrue(release.await(20,java.util.concurrent.TimeUnit.SECONDS));}
|
||||
return inv.callRealMethod();
|
||||
}).when(controlledEngine).continueAdvance(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq(node),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any());
|
||||
java.util.concurrent.ExecutorService pool=java.util.concurrent.Executors.newSingleThreadExecutor(r->new Thread(r,"d09-old-guard"));
|
||||
try {
|
||||
java.util.concurrent.Future<?> old=pool.submit(()->advanceListener.sweepIntents());assertTrue(validated.await(20,java.util.concurrent.TimeUnit.SECONDS));
|
||||
FlwTask cancel=initial.stream().filter(t->t.getAssigneeId()==301).findFirst().get();
|
||||
taskService.handle(SCH,cancel.getId(),false,"reject other branch",301);
|
||||
assertEquals(30,instanceRepository.findById(id).get().getStatus());
|
||||
assertEquals(id,apply(def.getFlowGuid(),"d09-fence",201,"Applicant").getInstanceId());
|
||||
for(FlwTask task:taskRepository.findByInstanceIdAndStatus(id,0))if(node.equals(task.getNodeId()))taskService.handle(SCH,task.getId(),true,"new approved",task.getAssigneeId());
|
||||
FlwInstance before=instanceRepository.findById(id).get();List<FlwTask> tasksBefore=allTasks(id);
|
||||
java.util.List<java.util.Map<String,Object>> branchesBefore=d09Jdbc.queryForList("SELECT id,status FROM flw_branch WHERE instance_id=? ORDER BY id",id);
|
||||
release.countDown();old.get(20,java.util.concurrent.TimeUnit.SECONDS);
|
||||
FlwInstance after=instanceRepository.findById(id).get();assertEquals(before.getVersion(),after.getVersion());assertEquals(before.getCurrentNodes(),after.getCurrentNodes());
|
||||
assertEquals(10,after.getStatus());assertEquals(tasksBefore.size(),allTasks(id).size());
|
||||
for(FlwTask task:tasksBefore)assertEquals(task.getStatus(),taskRepository.findById(task.getId()).get().getStatus());
|
||||
assertEquals(branchesBefore,d09Jdbc.queryForList("SELECT id,status FROM flw_branch WHERE instance_id=? ORDER BY id",id));
|
||||
// Its failed settlement did not consume the new generation. That generation advances independently.
|
||||
advanceListener.sweepIntents();assertFalse(instanceRepository.findById(id).get().getCurrentNodes().contains(node));
|
||||
d09Jdbc.update("UPDATE flw_advance_intent SET next_retry_at=DATE_SUB(NOW(),INTERVAL 1 SECOND) WHERE id=?",oldHead.getId());
|
||||
advanceListener.sweepIntents();assertEquals("DONE",intents.findById(oldHead.getId()).get().getStatus());
|
||||
assertTrue(intents.findById(oldHead.getId()).get().getLastError().contains("STALE_ACTIVATION"));
|
||||
} finally {release.countDown();pool.shutdownNow();}
|
||||
}
|
||||
|
||||
@Test void d09_transferRemainsInItsActivationAndLowerIdLastHandlerStillWins() {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
Long id=d09Multi("d09-transfer");List<FlwTask> tasks=taskRepository.findByInstanceIdAndStatus(id,0);
|
||||
FlwAdvanceIntent original=d09Head(id);FlwTask transferred=tasks.get(0),last=tasks.get(1);
|
||||
taskService.transfer(SCH,transferred.getId(),301,"transfer",transferred.getAssigneeId());
|
||||
FlwTask replacement=taskRepository.findByInstanceIdAndStatus(id,0).stream().filter(t->t.getAssigneeId()==301).findFirst().get();
|
||||
assertTrue(replacement.getId()>last.getId());assertEquals(original.getId(),d09Head(id).getId());assertTrue(d09Head(id).containsTask(replacement.getId()));
|
||||
taskService.handle(SCH,replacement.getId(),true,"first",301,"通过");
|
||||
taskService.handle(SCH,last.getId(),true,"last",last.getAssigneeId(),"转乙科");
|
||||
advanceListener.sweepIntents();assertEquals("n2",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
assertEquals(last.getId(),d09Head(id).getTriggerTaskId());assertEquals(last.getAssigneeId(),d09Head(id).getAssigneeId());
|
||||
}
|
||||
|
||||
@Test void d09_ambiguousTransferTimestampCannotGuessAcrossActivations() {
|
||||
org.mockito.Mockito.doNothing().when(advanceListener).onTaskHandled(org.mockito.ArgumentMatchers.any());
|
||||
Long id=d09Multi("d09-transfer-ambiguous");FlwInstance instance=instanceRepository.findById(id).get();
|
||||
FlwTask source=tasksOf(id,201).get(0);taskService.transfer(SCH,source.getId(),301,"old transfer",201);
|
||||
FlwTask reject=tasksOf(id,202).get(0);taskService.handle(SCH,reject.getId(),false,"reject",202);
|
||||
FlwDef def=defRepository.findById(instance.getDefId()).get();apply(def.getFlowGuid(),"d09-transfer-ambiguous",201,"Applicant");
|
||||
FlwTask current=tasksOf(id,201).get(0);int before=allTasks(id).size();
|
||||
FlowAdvanceService target=org.springframework.test.util.AopTestUtils.getUltimateTargetObject(advanceService);
|
||||
org.mockito.Mockito.doAnswer(inv->{FlowEvent event=inv.getArgument(0);
|
||||
// Force equal millisecond timestamps in two generations; a timestamp is only a candidate filter.
|
||||
d09Jdbc.update("UPDATE flw_task SET handle_time=(SELECT stamp FROM (SELECT arrive_time stamp FROM flw_task WHERE id=?) s) WHERE id=?",event.getTaskId(),source.getId());
|
||||
return inv.callRealMethod();
|
||||
}).when(target).registerTransfer(org.mockito.ArgumentMatchers.any());
|
||||
assertThrows(IllegalStateException.class,()->taskService.transfer(SCH,current.getId(),301,"ambiguous transfer",201));
|
||||
assertEquals(0,taskRepository.findById(current.getId()).get().getStatus());assertEquals(before,allTasks(id).size());
|
||||
assertFalse(d09Head(id).containsTask(source.getId()));assertEquals("n1",instanceRepository.findById(id).get().getCurrentNodes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apply_createsInstanceAndCountersignTasks_allApprove_completes() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwRouter;
|
||||
import com.yunhai.flowcenter.service.DiagramTokenService;
|
||||
import com.yunhai.flowcenter.service.FlowRouterAdminService;
|
||||
import com.yunhai.flowcenter.service.FlowTaskService;
|
||||
import com.yunhai.flowcenter.service.OapiFlowService;
|
||||
import com.yunhai.flowcenter.support.FlowTestSupport;
|
||||
import com.yunhai.flowcenter.support.OapiSignedClient;
|
||||
@@ -52,6 +53,9 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
@Autowired
|
||||
private OapiFlowService flowService;
|
||||
|
||||
@Autowired
|
||||
private FlowTaskService taskService;
|
||||
|
||||
@Autowired
|
||||
private FlowRouterAdminService routerAdminService;
|
||||
|
||||
@@ -431,7 +435,12 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
assertEquals(0, finished.get("total").asInt());
|
||||
}
|
||||
|
||||
/** 办理(第三步 T-C1b):非办理人业务码拒绝不 500;同意推进+opinion 进回调 payload;拒绝必填意见 */
|
||||
/**
|
||||
* 办理(第三步 T-C1b;2026-10-25 R1 返修重写):oapi approve/reject 无可验证用户证明
|
||||
* 一律 fail-closed(即便自报办理人 ID)——缺 operatorName/非办理人/办理人本人均业务码拒绝、
|
||||
* 任务保持待办;合法办理经 <b>CAS 会话路径</b>(FlowTaskService.handle,与
|
||||
* FlowUserController 同入口)完成:同意推进 + opinion 进回调 payload、拒绝必填意见。
|
||||
*/
|
||||
@Test
|
||||
void approveAndReject_operatorChecked_opinionPropagates() throws Exception {
|
||||
mapDept("ybb", 101, "甲");
|
||||
@@ -464,12 +473,12 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
.get("instanceId").asLong();
|
||||
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
|
||||
|
||||
// 缺 operatorName → 业务码拒绝
|
||||
// R1:oapi 缺 operatorName → 业务码拒绝(门禁先拒,fail-closed 优先于参数校验)
|
||||
ResponseEntity<JsonNode> noName = client.post(
|
||||
"/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101", "{}", JsonNode.class);
|
||||
assertNotEquals(0, noName.getBody().get("code").asInt(), "缺 operatorName 应被拒绝");
|
||||
|
||||
// 非办理人(999)→ 业务码拒绝(code!=0,HTTP 200),任务仍在待办
|
||||
// R1:非办理人(999)自报 → 业务码拒绝(code!=0,HTTP 200),任务仍在待办
|
||||
ResponseEntity<JsonNode> forged = client.post(
|
||||
"/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=999&operatorName=路人",
|
||||
"{\"opinion\":\"越权\"}", JsonNode.class);
|
||||
@@ -478,31 +487,41 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(),
|
||||
"越权后任务仍在待办");
|
||||
|
||||
// 办理人(101)同意 → 任务 1、单节点单人会签放行 → 办件 20;opinion 进回调 payload
|
||||
ResponseEntity<JsonNode> ok = client.post(
|
||||
// R1:办理人(101)本人自报 operatorId → 同样拒绝(知道办理人 ID ≠ 该用户本人在操作)
|
||||
ResponseEntity<JsonNode> selfReported = client.post(
|
||||
"/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101&operatorName=甲",
|
||||
"{\"opinion\":\"同意,情况属实\"}", JsonNode.class);
|
||||
assertEquals(0, ok.getBody().get("code").asInt(), "办理人同意应成功");
|
||||
assertNotEquals(0, selfReported.getBody().get("code").asInt(),
|
||||
"R1:oapi 快捷审批即便自报真实办理人也不得放行");
|
||||
assertTrue(selfReported.getBody().get("msg").asText().contains("快捷审批未放行"),
|
||||
"拒绝文案须指明迁移路径(CAS 人工办理)");
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(),
|
||||
"拒绝后任务仍在待办");
|
||||
|
||||
// 合法办理改走 CAS 会话路径(FlowUserController 同入口):同意 → 任务 1、办件 20;
|
||||
// opinion 进回调 payload
|
||||
taskService.handle(1L, taskId, true, "同意,情况属实", 101, null);
|
||||
assertEquals(1, taskRepository.findById(taskId).get().getStatus());
|
||||
assertEquals(20, instanceRepository.findById(instanceId).get().getStatus());
|
||||
assertTrue(awaitPayloadContains(receivedPayloads, "同意,情况属实"),
|
||||
"opinion 应透传到回调 payload");
|
||||
|
||||
// 第二件:拒绝无意见 → 业务码拒绝;带意见 → 任务 2、办件 30
|
||||
// 第二件:oapi 拒绝(无意见/有意见)→ R1 一律业务码拒绝(门禁先于意见必填校验);
|
||||
// 会话路径拒绝无意见 → 业务码拒绝;带意见 → 任务 2、办件 30
|
||||
Long id2 = unwrap(client.post("/oapi/flow/apply?schId=1", "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"ap-2\",\"bizTitle\":\"驳回件\","
|
||||
+ "\"submitterId\":202,\"submitterName\":\"李四\"}", JsonNode.class).getBody())
|
||||
.get("instanceId").asLong();
|
||||
Long taskId2 = taskRepository.findByInstanceIdAndStatus(id2, 0).get(0).getId();
|
||||
ResponseEntity<JsonNode> noOpinion = client.post(
|
||||
ResponseEntity<JsonNode> oapiNoOpinion = client.post(
|
||||
"/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲",
|
||||
"{}", JsonNode.class);
|
||||
assertNotEquals(0, noOpinion.getBody().get("code").asInt(), "拒绝必须填写意见");
|
||||
ResponseEntity<JsonNode> rejected = client.post(
|
||||
"/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲",
|
||||
"{\"opinion\":\"材料不全\"}", JsonNode.class);
|
||||
assertEquals(0, rejected.getBody().get("code").asInt());
|
||||
assertNotEquals(0, oapiNoOpinion.getBody().get("code").asInt(), "R1:oapi 拒绝同样不放行");
|
||||
assertThrows(BizException.class,
|
||||
() -> taskService.handle(1L, taskId2, false, null, 101),
|
||||
"会话路径拒绝必须填写意见");
|
||||
taskService.handle(1L, taskId2, false, "材料不全", 101);
|
||||
assertEquals(2, taskRepository.findById(taskId2).get().getStatus());
|
||||
assertEquals(30, instanceRepository.findById(id2).get().getStatus());
|
||||
assertTrue(awaitPayloadContains(receivedPayloads, "材料不全"),
|
||||
@@ -649,7 +668,7 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
assertEquals(0, n1.get("candidates").size(), "解析异常节点降级空候选");
|
||||
}
|
||||
|
||||
/** 合法 assigns:200 + 快照落库(姓名走 requireUserName 严格口径)+ 只为被指派人建待办 */
|
||||
/** 合法 assigns:200 + 快照落库(姓名走 requireEligibleAssignee 严格口径)+ 只为被指派人建待办 */
|
||||
@Test
|
||||
void apply_withAssigns_ok() throws Exception {
|
||||
mapDept("ybb", 101, "甲");
|
||||
|
||||
@@ -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(), "终态可展示:回带办件状态");
|
||||
|
||||
@@ -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 的既有宽容口径未被收紧。
|
||||
*
|
||||
* <p>真实 MySQL 隔离库(同 D09/D10 门禁:8.x / 13429 / fl_d09_20261026);每例清表。</p>
|
||||
*/
|
||||
@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<Integer> 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<FlwTask> 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<FlwTask> 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<FlwTask> 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<FlwTask> 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<FlwCc> 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();
|
||||
}
|
||||
}
|
||||
@@ -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 委托)落地前
|
||||
* 快捷审批无条件不放行:<b>不存在放行配置</b>(原 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<com.yunhai.flowcenter.entity.FlwCallbackLog> log = ArgumentCaptor.forClass(com.yunhai.flowcenter.entity.FlwCallbackLog.class);
|
||||
|
||||
@@ -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):
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>线级停发</b>:整线停止新发起/预览;不存在"停发后回退旧版"(停发不是降级);
|
||||
* 在途实例不经新发起解析路径,天然不受影响;恢复只归零标志,不改发布内容。</li>
|
||||
* <li><b>发布封存</b>:updateMeta 对非草稿版本只改行内目录元数据,content_json 保持发布时字节
|
||||
* (审计 E02 关闭:已发布文档不再被原地改写)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<FlowInstanceService.ApplyCommand> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>钉住的不变量:</p>
|
||||
* <ul>
|
||||
* <li><b>新发起必须有生效许可</b>:无行/已撤销/主体解析失败/未在学校开通/核验不可达——五路全拒;</li>
|
||||
* <li><b>已受理命令回执可恢复</b>:同 token、retryOnly 探询、在途/通过幂等、陈旧轮次四类
|
||||
* 回执分支不触许可闸(P0-3 收口顺序:认证 → 回执 → 未命中才查资格);</li>
|
||||
* <li><b>appKey 不是主体</b>:appKey 可重置/复用,许可与审计挂 UserBase 稳定主键 appId。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
+114
-6
@@ -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<String, Object> changed = snapshot();
|
||||
changed.put("fields", Arrays.asList(field("other", "text", true, null)));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(changed);
|
||||
service.enable(SCH, 9L);
|
||||
assertEquals(frozen, d.getContentJson());
|
||||
d.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||
d.setContentJson(frozen.replace("grade == 'g1'", "grade == 'g9'"));
|
||||
assertThrows(BizException.class, () -> service.enable(SCH, 9L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalDisabledVersionWithoutDeclarationsCanBeEnabledUnchanged() {
|
||||
FlwDef d = draft(null);
|
||||
d.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
service.enable(SCH, 9L);
|
||||
assertEquals(JSON, d.getContentJson());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fcVariablesAreDerivedEvenIfDraftTriesToSupplyEmptyDeclarations() throws Exception {
|
||||
FlwDef d = draft("FU1");
|
||||
d.setContentJson(JSON.replaceFirst("\\{", "{\"variables\":[],"));
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
|
||||
service.publish(SCH, 9L);
|
||||
JsonNode vars = mapper.readTree(d.getContentJson()).path("variables");
|
||||
assertEquals(3, vars.size());
|
||||
assertEquals("number", vars.get(0).path("type").asText());
|
||||
assertTrue(vars.get(2).path("required").asBoolean());
|
||||
assertEquals("g1", vars.get(1).path("enum").get(0).asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allFcTypesPreserveActualSerializedShapes() {
|
||||
java.util.ArrayList<Map<String, Object>> fields = new java.util.ArrayList<>();
|
||||
for (String type : Arrays.asList("text", "textarea", "phone", "number", "date", "select", "radio",
|
||||
"checkbox", "multiselect", "upload", "image", "address", "user", "users")) {
|
||||
fields.add(field(type, type, false, Arrays.asList("select", "radio", "checkbox", "multiselect").contains(type)
|
||||
? Arrays.asList("v1", "v2") : null));
|
||||
}
|
||||
Map<String, Object> snap = snapshot(); snap.put("fields", fields);
|
||||
FlwDef d = draft("FU1");
|
||||
d.setContentJson(JSON.replace("days > 3 && schId > 0", "schId > 0").replace("grade == 'g1'", "true"));
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snap);
|
||||
service.publish(SCH, 9L);
|
||||
com.yunhai.flowcenter.engine.FlowDefModel model = com.yunhai.flowcenter.engine.FlowDefModel.parse(mapper, d.getContentJson());
|
||||
ApplyVariableValidator validator = new ApplyVariableValidator(mapper);
|
||||
validator.requireVariablesPresent(model, "{\"text\":\"ok\",\"textarea\":\"ok\",\"phone\":\"123\",\"number\":1.5,"
|
||||
+ "\"date\":\"2024-02-29\",\"select\":\"v1\",\"radio\":\"v2\",\"checkbox\":[\"v1\"],\"multiselect\":[\"v2\"],"
|
||||
+ "\"upload\":[{\"id\":1,\"fileName\":\"a.pdf\",\"key\":\"form/a\"}],\"image\":[],\"address\":[\"广东省\"],"
|
||||
+ "\"user\":[{\"id\":101,\"name\":\"A\"}],\"users\":[]}");
|
||||
validator.requireVariablesPresent(model, "{\"date\":\"\",\"select\":\"\",\"number\":null,\"checkbox\":[]}");
|
||||
assertThrows(BizException.class, () -> validator.requireVariablesPresent(model, "{\"checkbox\":[\"forged\"]}"));
|
||||
assertThrows(BizException.class, () -> validator.requireVariablesPresent(model, "{\"number\":\"\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unboundEmptyDeclarationsCannotHideExpressionReferencesAndNullCannotOptOut() {
|
||||
for (String variables : Arrays.asList("[]", "null")) {
|
||||
FlwDef d = draft(null);
|
||||
d.setContentJson("{\"variables\":" + variables + "," + JSON.substring(1));
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||
}
|
||||
verify(defRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaUnavailableBlocksPublishAsRetryableBizError() {
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
|
||||
|
||||
@@ -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);
|
||||
|
||||
+36
-50
@@ -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<FlwDef> defCap = ArgumentCaptor.forClass(FlwDef.class);
|
||||
verify(flowEngine).rerouteCondition(any(), defCap.capture(), eq("n1"));
|
||||
assertEquals(12L, defCap.getValue().getId(), "useLatestDef=true 引擎按 v2 续走");
|
||||
assertEquals(12L, instance.getDefId(), "定义快照三列同步刷新(R4 同语义)");
|
||||
assertEquals(Integer.valueOf(2), instance.getDefVersion());
|
||||
ArgumentCaptor<FlwTask> trace = ArgumentCaptor.forClass(FlwTask.class);
|
||||
verify(taskRepository).save(trace.capture());
|
||||
assertEquals(FlwEnums.TaskStatus.NODE_REROUTE_DEF, trace.getValue().getNodeId(),
|
||||
"trace 落保留节点 id 的留痕行");
|
||||
assertTrue(trace.getValue().getNodeName().contains("v1→v2"), "留痕行载明版本迁移");
|
||||
verify(instanceRepository).save(instance);
|
||||
}
|
||||
|
||||
@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(), "定义快照零刷新");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String,Object> snapshot = new LinkedHashMap<>();
|
||||
snapshot.put("formUuid","d10-form"); snapshot.put("formVersion",1);
|
||||
List<Map<String,Object>> fields = new ArrayList<>();
|
||||
Map<String,Object> amount = new LinkedHashMap<>(); amount.put("key","amount"); amount.put("type","number"); amount.put("required",true);
|
||||
Map<String,Object> 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<FormalApplyModels.Receipt> a=pool.submit(()->{go.await();return service.submit(1L,"100018",valid);});
|
||||
Future<FormalApplyModels.Receipt> 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<FormalApplyModels.Receipt> a=pool.submit(()->{go.await();return service.submit(1L,"100018",request("a"));});
|
||||
Future<FormalApplyModels.Receipt> 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<FormalApplyModels.Receipt> 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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ public abstract class FlowTestSupport {
|
||||
protected FlwRouterRepository routerRepository;
|
||||
@Autowired
|
||||
protected FlwRouteLogRepository routeLogRepository;
|
||||
@Autowired
|
||||
protected com.yunhai.flowcenter.repository.FlwStateEventRepository stateEventRepository;
|
||||
@Autowired
|
||||
protected com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository advanceIntentRepository;
|
||||
|
||||
/** tag → 实际 deptKey(flw_dept.id 字符串);mapDept 注册,def helper 消费 */
|
||||
private final Map<String, String> deptKeyByTag = new HashMap<>();
|
||||
@@ -64,6 +68,11 @@ public abstract class FlowTestSupport {
|
||||
}
|
||||
|
||||
protected void cleanDb() {
|
||||
// D09: intents must not outlive the tasks/instances whose activations they describe.
|
||||
advanceIntentRepository.deleteAll();
|
||||
// 事实行先清:否则上一用例/上一轮遗留的 dispatched=0 事实会被 60s 补分发扫描
|
||||
// 按实例行复活重投(跨类注入并发推送者;FormalApplyCommandIT 此前被迫自清即此缺口)
|
||||
stateEventRepository.deleteAll();
|
||||
logRepository.deleteAll();
|
||||
regRepository.deleteAll();
|
||||
ccRepository.deleteAll();
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user