From d72128672f82ab184d599033e49a499118f1abe6 Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 30 Oct 2026 10:15:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(fl2):=20=E8=90=BD=E5=9C=B0=E9=83=A8?= =?UTF-8?q?=E9=97=A8=E7=9B=AE=E5=BD=95=20/=20=E9=80=80=E5=9B=9E=E9=87=8D?= =?UTF-8?q?=E6=8F=90(D14)=20/=20=E6=8E=88=E6=9D=83=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=88=E5=9F=BA=E7=BA=BF=20origin/main?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 b369527 之上增量移植 FL2.0 后端能力,不改既有线级停发/指派预览/流程图入口: - 部门目录:FlowDepartmentController(/admin/department/{groups,options,resolve,members}) + UserBaseDepartmentDirectory(先按会话校域解析引用,再读成员;目录不可用 fail-closed) - 退回重提 D14:POST /oapi/flow/instance/{instanceId}/resubmit,仅原提交人 CAS 路径放行, OAPI 侧 submitterId 路径继续 fail-closed - 授权管理:/api/flow/admin/usage-grant/{schoolList,summary}(校域取管理员会话,无 schId 入参) - 来源主体:解析器派生 ub-app:{applicationInfo.id},不再读取 UserBase 的 sourcePrincipalId 字段 - 回调来源策略:SELF 身份强校验;NULL 来源不广播;复合幂等键 (event_id,reg_id) - 测试夹具:许可/主体/开通桩按新契约修正,FlowEngineIT/FormalApplyCommandIT/ CallbackDispatchIT/OapiFlowContractIT 共 117 例在真实 MySQL 8 通过 不含:FlSourceAuthorizationTest(其断言的两个 Repository 派生方法属另一实现线) --- .gitea/workflows/build.yml | 33 +- ci/README.md | 12 +- ci/flow-test-schema.sql | 318 ++++++------- .../callback/CallbackSourcePolicy.java | 13 +- .../controller/api/FlowAdminController.java | 15 + .../api/FlowDepartmentController.java | 83 ++++ .../controller/api/FlowUserController.java | 26 ++ .../controller/oapi/OapiFlowController.java | 19 +- .../flowcenter/engine/AssigneeResolver.java | 123 ++++- .../flowcenter/engine/DefValidator.java | 106 +++-- .../flowcenter/engine/FlowDefModel.java | 59 ++- .../yunhai/flowcenter/engine/FlowEngine.java | 11 +- .../yunhai/flowcenter/entity/FlwInstance.java | 4 +- .../com/yunhai/flowcenter/enums/FlwEnums.java | 20 +- .../repository/FlwInstanceRepository.java | 39 +- .../repository/FlwTaskRepository.java | 11 +- .../repository/FlwUsageGrantRepository.java | 2 + .../service/FlowDefAdminService.java | 102 ++++- .../service/FlowInstanceService.java | 212 +++++++-- .../service/FlowOapiSourceScope.java | 83 +++- .../service/FlowSourcePrincipalResolver.java | 39 +- .../flowcenter/service/FlowTaskService.java | 49 ++ .../service/FlowUsageGrantAdminService.java | 39 +- .../service/FlowUsageGrantGuard.java | 49 +- .../service/FormalApplyCommandService.java | 5 +- .../flowcenter/service/OapiFlowService.java | 203 ++++++--- .../service/UserBaseActivationClient.java | 44 +- .../service/UserBaseDepartmentDirectory.java | 170 +++++++ .../sql/20261029_fl2_preflight_readonly.sql | 22 + .../sql/20261029_fl2_source_scope_unique.sql | 82 ++++ .../callback/CallbackDispatchIT.java | 46 ++ .../callback/CallbackSourcePolicyTest.java | 17 +- .../flowcenter/engine/FlowEngineIT.java | 14 + .../flowcenter/oapi/OapiFlowContractIT.java | 36 +- .../oapi/OapiFlowP35DoneCcContractTest.java | 15 +- .../oapi/OapiFlowTasksByBizTest.java | 27 -- .../service/CodexFl2BoundaryTest.java | 53 +++ .../service/D11AssigneeEligibilityIT.java | 24 +- .../service/FlApplyAssignAndVariableTest.java | 18 +- .../service/FlApplyRecoveryTest.java | 52 +-- .../service/FlDefLineOfflineTest.java | 14 +- .../service/FlDepartmentSubjectTest.java | 423 ++++++++++++++++++ .../flowcenter/service/FlFlowCodeTest.java | 225 ++++++++++ .../service/FlNewInstancePathTest.java | 5 +- .../service/FlReturnToSubmitterTest.java | 377 ++++++++++++++++ .../service/FlSourceAuthorizationTest.java | 183 -------- .../service/FlSourceIsolationTest.java | 240 ++++++++++ .../flowcenter/service/FlUsageGrantTest.java | 241 +++++++--- .../service/FlowDefAdminServiceSaveTest.java | 26 +- .../service/FormalApplyCommandIT.java | 6 +- .../service/OapiFlowApplyDefVersionTest.java | 27 +- .../flowcenter/support/FlowTestSupport.java | 5 + 52 files changed, 3208 insertions(+), 859 deletions(-) create mode 100644 src/main/java/com/yunhai/flowcenter/controller/api/FlowDepartmentController.java create mode 100644 src/main/java/com/yunhai/flowcenter/service/UserBaseDepartmentDirectory.java create mode 100644 src/main/resources/sql/20261029_fl2_preflight_readonly.sql create mode 100644 src/main/resources/sql/20261029_fl2_source_scope_unique.sql create mode 100644 src/test/java/com/yunhai/flowcenter/service/CodexFl2BoundaryTest.java create mode 100644 src/test/java/com/yunhai/flowcenter/service/FlDepartmentSubjectTest.java create mode 100644 src/test/java/com/yunhai/flowcenter/service/FlFlowCodeTest.java create mode 100644 src/test/java/com/yunhai/flowcenter/service/FlReturnToSubmitterTest.java delete mode 100644 src/test/java/com/yunhai/flowcenter/service/FlSourceAuthorizationTest.java create mode 100644 src/test/java/com/yunhai/flowcenter/service/FlSourceIsolationTest.java diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 4afa17c..e529d58 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -9,7 +9,7 @@ name: build on: push: - branches: [ main, codex/core-rc-20261023 ] + branches: [ main ] workflow_dispatch: inputs: cache_bust: @@ -42,8 +42,6 @@ jobs: git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src git -C /tmp/src fetch --depth 1 origin "$GITHUB_SHA" git -C /tmp/src checkout --detach FETCH_HEAD - test "$(git -C /tmp/src rev-parse HEAD)" = "$GITHUB_SHA" - echo "CI_SOURCE_SHA=$GITHUB_SHA" cp -a /tmp/src/. . rm -rf /tmp/src # 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符 @@ -82,19 +80,7 @@ jobs: docker exec "$BUILD_CONTAINER" sh -c "printf '%s' 'nexus-reponexus-internalhttp://nexus:8081/repository/maven-public/truetruealwaysnexus-repo' > /root/.m2/settings.xml" # 为真实 MySQL 测试创建本次 job 独占的临时数据库;不映射宿主端口、不连接业务库。 DB_CONTAINER="${BUILD_CONTAINER}-db" - cleanup_ci() { - result=$? - trap - EXIT - docker rm -fv "$BUILD_CONTAINER" "$DB_CONTAINER" >/dev/null 2>&1 || true - docker info >/dev/null 2>&1 || { echo 'CI_CONTAINER_CLEANUP=UNVERIFIED' >&2; exit 1; } - if docker inspect "$BUILD_CONTAINER" >/dev/null 2>&1 || docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then - echo 'CI_CONTAINER_CLEANUP=FAILED' >&2 - exit 1 - fi - echo 'CI_CONTAINER_CLEANUP=PASS' - exit "$result" - } - trap cleanup_ci EXIT + trap 'docker rm -f "$BUILD_CONTAINER" "$DB_CONTAINER" >/dev/null 2>&1 || true' EXIT export MYSQL_ROOT_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') export MYSQL_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') docker create --name "$DB_CONTAINER" --network infra-infra-net \ @@ -110,17 +96,6 @@ jobs: [ "$ready" -eq 1 ] || { echo 'Isolated CI MySQL did not become ready' >&2; exit 1; } export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true" export CI_TEST_DB_USER=flow_ci CI_TEST_DB_PASSWORD="$MYSQL_PASSWORD" - 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" = 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'@'%';" | \ - docker exec -i "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot' - positive_db_url="$CI_TEST_DB_URL" - export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci_invalid?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true" - docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \ - "$BUILD_CONTAINER" sh -c 'cd /build && sh ci/verify-failure-guard.sh' - export CI_TEST_DB_URL="$positive_db_url" docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \ "$BUILD_CONTAINER" sh -c 'cd /build && sh ci/test-gate.sh clean package' docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar' @@ -129,7 +104,7 @@ jobs: docker rm -f "$BUILD_CONTAINER" - name: 构建并推送镜像 - if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }} + if: ${{ inputs.tests_only != 'true' }} run: | set -e TAG=$(echo "$GITHUB_SHA" | cut -c1-8) @@ -140,7 +115,7 @@ jobs: # 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual) - name: 通知 projman 建档(部署由运维手动触发) - if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }} + if: ${{ inputs.tests_only != 'true' }} run: | curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \ -H "Content-Type: application/json" \ diff --git a/ci/README.md b/ci/README.md index 66e657d..a98425c 100644 --- a/ci/README.md +++ b/ci/README.md @@ -5,7 +5,7 @@ 所有指定类必须存在;Surefire 不允许零测试;Hibernate 固定 `ddl-auto=validate`。 工作流在 `infra-infra-net` 内新建 `${BUILD_CONTAINER}-db`,镜像 `mysql:8.0`,不映射宿主端口。 -在启动之前把 `flow-test-schema.sql` 复制进初始化目录;该文件只含 20 张表的结构,不含业务数据。 +在启动之前把 `flow-test-schema.sql` 复制进初始化目录;该文件只含 14 张表的结构,不含业务数据。 镜像初始化阶段建立测试 schema,测试阶段只 validate。每个 job 的数据库容器名和随机凭据独立, EXIT trap 回收 Maven/MySQL 容器;不访问 160 业务库。 @@ -27,13 +27,3 @@ CI 场景必须实际连接 `flowcenter_ci*`,不能悄悄改用默认开发库 不能将等价 Maven 证据描述为实际 Gitea workflow 已通过。 实体结构变更时应显式刷新 SQL 快照并重新验证;不能把 validate 改为 update 来绕过缺表或缺列。 - - -## 核心 RC 功能分支实跑 - -`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;随后对 20 表正常 schema 执行正向门禁。 -空库检查的预期失败不属于 380 项正向用例。回收检查打印 `CI_CONTAINER_CLEANUP=PASS`,同时删除本次容器的匿名卷。 -本文件是运行约定;只有取得对应远端准确 SHA 的实际日志,才能宣称 runner 已完成验证。 diff --git a/ci/flow-test-schema.sql b/ci/flow-test-schema.sql index 0f16c4c..6ba9bdb 100644 --- a/ci/flow-test-schema.sql +++ b/ci/flow-test-schema.sql @@ -1,6 +1,3 @@ --- 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 */; @@ -14,37 +11,6 @@ /*!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, @@ -63,34 +29,6 @@ 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, @@ -130,25 +68,6 @@ 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, @@ -181,10 +100,10 @@ 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_reg` (`event_id`,`reg_id`), KEY `idx_flw_log_scan` (`status`,`next_retry_time`), @@ -200,14 +119,13 @@ 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`) @@ -217,29 +135,21 @@ CREATE TABLE `flw_callback_reg` ( /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `flw_cc` ( `id` bigint NOT NULL AUTO_INCREMENT, - `create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `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_time` datetime DEFAULT NULL, - `update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, + `update_by` varchar(64) 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_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; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='抄送留痕(NOTIFY 节点逐接收人一行)'; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -249,21 +159,22 @@ 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_uuid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, - `form_version` int DEFAULT NULL, + `form_id` bigint 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; @@ -310,34 +221,34 @@ 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_id` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL, `biz_snapshot` longtext COLLATE utf8mb4_general_ci, `biz_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, - `biz_type` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL, + `biz_type` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL, `biz_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL, `current_nodes` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, `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, + `apply_payload_hash` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, + `event_seq_counter` int DEFAULT NULL, + `source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL, + `legacy_slot` int DEFAULT 1, PRIMARY KEY (`id`), UNIQUE KEY `uk_flw_instance_legacy` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`,`legacy_slot`), UNIQUE KEY `uk_flw_instance_round` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`,`apply_round`), @@ -375,56 +286,30 @@ 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, + `arrive_time` datetime(3) NOT NULL COMMENT '到达时间(毫秒)', `assignee_id` int NOT NULL, `assignee_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL, `deadline` datetime DEFAULT NULL, - `edge_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL, - `handle_time` datetime(3) DEFAULT NULL, + `handle_time` datetime(3) DEFAULT NULL COMMENT '办理时间(毫秒)', `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, @@ -432,33 +317,12 @@ 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 */; @@ -469,3 +333,117 @@ CREATE TABLE `flw_usage_grant` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Codex C audit: isolated CI initialization only, no business-row DML. +-- 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) 既有索引后过滤,暂不加列索引 + +-- 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(255) NULL, `create_time` DATETIME(6) NULL, + `update_by` VARCHAR(255) 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(255) NULL, `create_time` DATETIME(6) NULL, + `update_by` VARCHAR(255) 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)'; + + +-- 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 白名单)'; + +ALTER TABLE flw_usage_grant ADD COLUMN source_principal_id VARCHAR(36) NULL; +ALTER TABLE flw_callback_reg ADD COLUMN source_principal_id VARCHAR(36) NULL; +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; + +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); + diff --git a/src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java b/src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java index 3011cc8..16c9481 100644 --- a/src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java +++ b/src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java @@ -16,8 +16,9 @@ public class CallbackSourcePolicy { 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. + return false; // Unknown provenance must not fan out to unrelated subscriptions. } + if (instance.getSchId() == null || !instance.getSchId().equals(reg.getSchId())) return false; 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); @@ -41,6 +42,16 @@ public class CallbackSourcePolicy { try { JsonNode body = mapper.readTree(payload); JsonNode data = body.path("data"); + if (instance.getLegacySlot() != null) { + // v1 has no request token/hash/round; legacy R4 can change those snapshots. + // Delivery facts are persisted by FL, while stable SELF coordinates remain mandatory. + return instance.getSourcePrincipalId().equals(body.path("sourcePrincipalId").asText()) + && instance.getSourcePrincipalId().equals(data.path("sourcePrincipalId").asText()) + && instance.getId().equals(body.path("instanceId").asLong()) + && instance.getSchId().equals(body.path("schId").asLong()) + && java.util.Objects.equals(instance.getBizType(), body.path("bizType").asText()) + && java.util.Objects.equals(instance.getBizId(), body.path("bizId").asText()); + } return instance.getSourcePrincipalId().equals(body.path("sourcePrincipalId").asText()) && instance.getApplyRound().equals(body.path("applyRound").asInt()) && instance.getSourcePrincipalId().equals(data.path("sourcePrincipalId").asText()) diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java index cdbe98c..1dd49a5 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -125,6 +125,21 @@ public class FlowAdminController { return usageGrantAdminService.listByFlow(adminGuard.requireAdmin(user, null), flowGuid); } + /** + * 本校全部授权行(应用使用许可管理页数据源,E6):校域取自管理员会话, + * 不接受 body/query 指定学校,也不跨校拉取。 + */ + @GetMapping("/usage-grant/schoolList") + public List usageGrantSchoolList(BoaUser user) { + return usageGrantAdminService.listBySchool(adminGuard.requireAdmin(user, null)); + } + + /** 流程线 → 生效授权应用数(流程列表的授权摘要列;仅本校范围) */ + @GetMapping("/usage-grant/summary") + public java.util.Map usageGrantSummary(BoaUser user) { + return usageGrantAdminService.grantedAppCounts(adminGuard.requireAdmin(user, null)); + } + /** 授予/重申使用授权(appKey 当场解析为 UserBase 稳定主体落库;enforce 开启时校学校开通) */ @PostMapping("/usage-grant/grant") public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantGrant( diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowDepartmentController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowDepartmentController.java new file mode 100644 index 0000000..6427e57 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowDepartmentController.java @@ -0,0 +1,83 @@ +package com.yunhai.flowcenter.controller.api; + +import com.yunhai.flowcenter.service.FlowAdminGuard; +import com.yunhai.flowcenter.service.UserBaseDepartmentDirectory; +import com.yunhai.platform.openapi.base.model.DepartmentEntry; +import com.yunhai.platform.openapi.base.model.DepartmentGroup; +import com.yunhai.platform.openapi.base.model.DepartmentRef; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; + +/** + * 部门目录读端点(C1/C4,2026-10-29):流程编辑器"选择部门"、候选人预览与已存引用的名称回显 + * 统一以 UserBase 新部门目录为权威来源,不再读写 FL 本地科室。 + * + *

校域一律由 {@link FlowAdminGuard#requireAdmin} 从 CAS 会话派生,前端没有可越界的 schId 入口; + * 全部只读,不接受写操作,也不回落本地 {@code flw_dept}。

+ */ +@RestController +@RequestMapping("/api/flow/admin/department") +@RequiredArgsConstructor +public class FlowDepartmentController { + + private final FlowAdminGuard adminGuard; + private final UserBaseDepartmentDirectory directory; + + /** 第一级「部门组」:系统组织(SYSTEM)+ 该校自定义分类(CUSTOM) */ + @GetMapping("/groups") + public List groups(BoaUser user) { + return directory.groups(adminGuard.requireAdmin(user, null)); + } + + /** + * 第二级可选部门。 + * + * @param type SYSTEM / CUSTOM(可空=两类都返回) + * @param categoryId 仅 CUSTOM 有效 + * @param withChildren 仅 SYSTEM 有效,true 时扁平带回下级(前端按 parentId 重建层级) + */ + @GetMapping("/options") + public List options(BoaUser user, + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "categoryId", required = false) Integer categoryId, + @RequestParam(value = "withChildren", required = false) Boolean withChildren) { + return directory.options(adminGuard.requireAdmin(user, null), type, categoryId, withChildren); + } + + /** + * 已持久化引用的名称/状态回显(含停用与软删,走 UserBase 的 resolve-batch 历史口径)。 + * 编辑已有流程时用,避免"停用部门"在编辑器里显示成空白。 + */ + @GetMapping("/resolve") + public List resolve(BoaUser user, + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "departmentId", required = false) Integer departmentId) { + Long schId = adminGuard.requireAdmin(user, null); + List refs = new ArrayList<>(); + if (departmentId != null) { + refs.add(new DepartmentRef(schId.intValue(), type, departmentId)); + } + return directory.resolve(schId, refs); + } + + /** + * 部门成员预览(C4:只读预览,不是发起授权)。 + * + * @param scope select(缺省,新指派严档)/ history(既有实例历史解析宽档) + */ + @GetMapping("/members") + public List members(BoaUser user, + @RequestParam("type") String type, + @RequestParam("departmentId") Integer departmentId, + @RequestParam(value = "scope", required = false) String scope) { + Long school = adminGuard.requireAdmin(user, null); + return directory.membersAtSchool(school, type, departmentId, scope); + } +} diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java index d414af4..88bc686 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java @@ -150,6 +150,32 @@ public class FlowUserController { reason == null ? null : String.valueOf(reason), user.getId()); } + /** + * D14 退回发起人修改(审批人动作;opinion 必填)。 + * 与普通退回({@code /returnBack},退回上游审批节点续办)分开:本动作让办件进入 + * {@code 50 退回待修改},等待发起人本人重提。 + */ + @PostMapping("/task/{id}/returnToSubmitter") + public void returnToSubmitter(BoaUser user, @PathVariable("id") Long id, + @RequestBody(required = false) Map body) { + String opinion = body == null ? null : body.get("opinion"); + taskService.returnToSubmitter(requireSchId(user), id, opinion, user.getId()); + } + + /** + * D14 发起人重提(仅发起人本人、仅 50 退回待修改状态)。 + * 同一实例、同一申报轮次,从第一级审批节点重新生成待办;服务端复核身份与归属, + * 前端不猜状态——只有本接口成功才更新页面。 + */ + @PostMapping("/instance/{id}/resubmit") + public void resubmit(BoaUser user, @PathVariable("id") Long id) { + FlwInstance instance = instanceService.requireInstance(requireSchId(user), id); + if (instance.getSubmitterId() == null || !instance.getSubmitterId().equals(user.getId())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅提交人本人可重新提交"); + } + instanceService.resubmitAfterReturn(requireSchId(user), id, user.getId(), null); + } + /** 撤回(提交人;仅尚无任务被办理时) */ @PostMapping("/instance/{id}/withdraw") public void withdraw(BoaUser user, @PathVariable("id") Long id) { diff --git a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java index 24fb747..409b93c 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java @@ -151,6 +151,21 @@ public class OapiFlowController { flowService.withdraw(schId, instanceId, submitterId); } + /** + * D14 发起人重提(同一实例、同一申报轮次,从第一级重新审批)。 + * 来源归属由 {@code FlowOapiSourceScope} 拦截器核验(非本应用发起的办件直接拒), + * 提交人身份由业务应用按其 CAS 会话传入并与办件提交人逐字复核——应用签名不替代"谁在提"。 + */ + @PostMapping("/instance/{instanceId}/resubmit") + public void resubmit(@RequestParam("schId") Long schId, + @RequestParam("submitterId") Integer submitterId, + @PathVariable("instanceId") Long instanceId, + @RequestBody(required = false) java.util.Map body) { + Object formData = body == null ? null : body.get("formData"); + flowService.resubmit(schId, instanceId, submitterId, + formData == null ? null : String.valueOf(formData)); + } + @PostMapping("/callback/reg") public OapiModels.CallbackRegResultVo registerCallback( @RequestParam("schId") Long schId, @@ -208,7 +223,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, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(appKey), page, size); + return queryService.pageDone(schId, assigneeId, com.yunhai.flowcenter.service.FlowOapiSourceScope.current(), page, size); } /** @@ -222,7 +237,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, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(appKey), page, size); + return queryService.pageCc(schId, userId, com.yunhai.flowcenter.service.FlowOapiSourceScope.current(), page, size); } /** 办件分页(oapi):bizType/status 可选过滤 */ diff --git a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java index 8c187b5..a868cfd 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java +++ b/src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java @@ -2,6 +2,7 @@ package com.yunhai.flowcenter.engine; import com.yunhai.flowcenter.entity.FlwDeptMember; import com.yunhai.flowcenter.service.FlowDeptAdminService; +import com.yunhai.flowcenter.service.UserBaseDepartmentDirectory; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.utils.exception.ErrorCode; @@ -11,12 +12,25 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; /** - * 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源, - * 零跨服务依赖);USERS 定义直配。审批责任人统一走 D11 资格终审(存在+启用+校域); - * 仅 NOTIFY 抄送接收人保留宽容解析(姓名补全失败降级空名、不校验资格)。 + * 办理人解析(概览 §六 + C1/C2 收口,2026-10-29)。 + * + *

三种主体口径:

+ *
    + *
  • {@code DEPT}(新配置唯一口径):UserBase 新部门目录(SYSTEM/CUSTOM 类型化引用) + * 解析出候选人员,再由节点策略决定一人办理/会签/或签;
  • + *
  • {@code USERS}:定义直配固定人员;同样支持会签/或签;
  • + *
  • {@code DEPT_KEY}(历史只读):FL 本地 {@code flw_dept}/{@code flw_dept_member}。 + * 仅为不破坏既有在途定义而保留读取,新配置不再写入、也不是静默兜底: + * {@code DEPT} 解析失败照抛,不回退本地科室表。
  • + *
+ * + *

审批责任人统一走 D11 资格终审(存在+启用+校域);仅 NOTIFY 抄送接收人保留宽容口径 + * (解析不出人只记日志继续走——抄送是知会不是审批)。

*/ @Slf4j @Service @@ -26,13 +40,48 @@ public class AssigneeResolver { private final FlowDeptAdminService deptAdminService; private final OpenApiUserClient userClient; + /** + * UserBase 部门目录(字段注入:构造被 {@code AssigneeResolverEligibilityTest} 逐位钉住, + * 加构造参数会让整棵测试树编译失败——同 {@code OapiFlowService#usageGrantGuard} 先例)。 + * 未装配时 {@code DEPT} 主体解析按 fail-closed 拒绝,不降级成本地科室。 + */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setDepartmentDirectory(UserBaseDepartmentDirectory directory) { + this.departmentDirectory = directory; + } + + private UserBaseDepartmentDirectory departmentDirectory; + public List resolve(Long schId, FlowDefModel.Node node) { FlowDefModel.Assignee a = node.assignee; if (a == null) { throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少办理人配置:" + node.id); } List refs = new ArrayList<>(); - if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { + if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) { + // 部门是授权主体,候选人仍逐人过 D11 资格终审:不合格者剔除并留痕(部门里有停用账号 + // 不该卡死整条流程),但权威源不可达(UNAUTHORIZED)照抛,不伪装成"无人可用"。 + List ineligible = new ArrayList<>(); + for (AssigneeRef ref : resolveDepartmentCandidates(schId, node, a, false)) { + try { + BoaUser user = requireEligibleAssignee(schId, ref.getUserId()); + refs.add(new AssigneeRef(ref.getUserId(), + user.getName() == null ? ref.getUserName() : user.getName())); + } catch (BizException e) { + if (e.getErrorCode() == ErrorCode.UNAUTHORIZED) { + throw e; + } + ineligible.add(ref.getUserId() + "=" + e.getMessage()); + log.warn("[flow-center] 部门成员不具备办理资格,跳过:schId={} node={} userId={} reason={}", + schId, node.id, ref.getUserId(), e.getMessage()); + } + } + if (refs.isEmpty() && !ineligible.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "节点[" + node.id + "]无可用办理人:部门成员均不具备办理资格(" + + String.join(";", ineligible) + ")"); + } + } else if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { // D11:科室启用 ≠ 成员都可办理——逐人走同一资格终审;不合格成员按既有 // “只取启用成员”口径剔除(local status 已在 resolveEnabled 过滤),并留日志。 List rejected = new ArrayList<>(); @@ -65,11 +114,71 @@ public class AssigneeResolver { } if (refs.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, - "节点[" + node.id + "]无可用办理人(科室可能已被停用或成员为空)"); + "节点[" + node.id + "]无可用办理人(部门/科室可能已被停用或成员为空)"); } return refs; } + /** + * UserBase 部门候选解析(C2:部门是审批授权主体,不是"前端筛选人员的临时集合")。 + * + *

一个用户可属多个部门,多部门/多引用解析后按用户去重,避免同一人收到两条待办。 + * 目录不可用照抛(空候选不等于成功)。{@code tolerant=true}(抄送)时把"某部门查不到人" + * 记日志跳过,但通道整体不可用仍然拒绝——不能把故障伪装成"没人需要知会"。

+ */ + private List resolveDepartmentCandidates(Long schId, FlowDefModel.Node node, + FlowDefModel.Assignee a, boolean tolerant) { + if (departmentDirectory == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, + "UserBase 部门目录通道未装配,节点[" + node.id + "]不能解析部门办理人"); + } + if (a.deptRefs == null || a.deptRefs.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "节点[" + node.id + "] DEPT 办理人缺少 deptRefs"); + } + List out = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (FlowDefModel.DeptRef ref : a.deptRefs) { + if (ref == null || ref.departmentId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "节点[" + node.id + "] 部门引用缺少 departmentType/departmentId"); + } + List members; + try { + members = departmentDirectory.membersAtSchool(schId, ref.departmentType, ref.departmentId, + UserBaseDepartmentDirectory.SCOPE_SELECT); + } catch (BizException e) { + if (tolerant && e.getErrorCode() != ErrorCode.UNAUTHORIZED) { + log.warn("[flow-center] 抄送部门解析失败(不阻塞推进):node={} ref={}:{}", + node.id, ref.directoryKey(), e.getMessage()); + continue; + } + throw new BizException(e.getErrorCode(), + "节点[" + node.id + "] 部门 " + ref.directoryKey() + " 解析失败:" + e.getMessage()); + } + if (members == null) { + continue; + } + for (BoaUser user : members) { + if (user == null || user.getId() == null || !seen.add(user.getId())) { + continue; // 多部门归属同一人:只保留一条 + } + out.add(new AssigneeRef(user.getId(), user.getName() == null ? "" : user.getName())); + } + } + return out; + } + + /** 抄送接收人用的部门解析(宽容口径,见 {@link #resolveDepartmentCandidates}) */ + private List resolveDepartmentReceivers(Long schId, FlowDefModel.Node node, + FlowDefModel.Assignee a) { + List out = new ArrayList<>(); + for (AssigneeRef ref : resolveDepartmentCandidates(schId, node, a, true)) { + out.add(new AssigneeRef(ref.getUserId(), ref.getUserName().isEmpty() + ? fetchName(ref.getUserId()) : ref.getUserName())); + } + return out; + } + /** * D11 固定 USERS 整体预检:流程启动前逐节点核验全部固定审批人,任何一人不存在/停用/跨校 * 即整体拒绝——不允许“过滤掉非法人后继续”。运行时建任务仍复用 {@link #resolve} 兜底。 @@ -101,7 +210,9 @@ public class AssigneeResolver { throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少抄送对象配置:" + node.id); } List refs = new ArrayList<>(); - if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { + if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) { + refs.addAll(resolveDepartmentReceivers(schId, node, a)); + } else if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) { refs.add(new AssigneeRef(m.getUserId(), m.getUserName())); } diff --git a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java index aab4031..6a85348 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java +++ b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java @@ -24,10 +24,23 @@ public final class DefValidator { } /** - * @param enabledDeptKeys 本校 flw_assignee_map 中启用映射的 dept_key 全集 + * @param enabledDeptKeys 本校 flw_assignee_map 中启用映射的 dept_key 全集(历史 DEPT_KEY 口径) * @param enabledRouterKeys flw_router 启用注册键全集(CONDITION routerKey 白名单;null=跳过注册检查) */ public static void validate(FlowDefModel model, Set enabledDeptKeys, Set enabledRouterKeys) { + validate(model, enabledDeptKeys, enabledRouterKeys, null); + } + + /** + * C1 发布校验(2026-10-29):新增 UserBase 新部门目录白名单。 + * + * @param selectableDeptRefs 本校可被新选中的部门引用键({@code TYPE:id})全集; + * null=跳过(单测与不带目录上下文的调用面)。目录不可用时调用方应 + * 直接抛错阻断发布,而不是传 null 放行——见 + * {@code FlowDefAdminService#selectableDeptRefs}。 + */ + public static void validate(FlowDefModel model, Set enabledDeptKeys, Set enabledRouterKeys, + Set selectableDeptRefs) { List problems = new ArrayList<>(); VariableContract.validateDefinition(model, false); @@ -61,10 +74,10 @@ public final class DefValidator { ends++; break; case FlowDefModel.TYPE_APPROVAL: - validateApproval(n, enabledDeptKeys, problems); + validateApproval(n, enabledDeptKeys, selectableDeptRefs, problems); break; case FlowDefModel.TYPE_NOTIFY: - validateNotify(n, enabledDeptKeys, problems); + validateNotify(n, enabledDeptKeys, selectableDeptRefs, problems); break; case FlowDefModel.TYPE_CONDITION: validateCondition(model, n, enabledRouterKeys, problems); @@ -222,25 +235,14 @@ public final class DefValidator { * NOTIFY 抄送节点校验:receivers 必填且类型合法(复用 Assignee 结构),DEPT_KEY 须有启用映射 * ——运行时解析是宽容口径(无人只记日志不阻塞),发布期拦一道防"配错科室静默抄送空"。 */ - private static void validateNotify(FlowDefModel.Node n, Set enabledDeptKeys, List problems) { + private static void validateNotify(FlowDefModel.Node n, Set enabledDeptKeys, + Set selectableDeptRefs, List problems) { FlowDefModel.Assignee r = n.receivers; if (r == null || r.type == null) { problems.add(nodeLabel(n) + " 缺少抄送对象(receivers)配置"); return; } - if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(r.type)) { - if (r.deptKey == null || r.deptKey.trim().isEmpty()) { - problems.add(nodeLabel(n) + " DEPT_KEY 抄送对象缺少 deptKey"); - } else if (enabledDeptKeys != null && !enabledDeptKeys.contains(r.deptKey)) { - problems.add(nodeLabel(n) + " 的 DEPT_KEY=" + r.deptKey + " 无启用中的办理人映射"); - } - } else if (FlowDefModel.ASSIGNEE_USERS.equals(r.type)) { - if (r.userIds == null || r.userIds.isEmpty()) { - problems.add(nodeLabel(n) + " USERS 抄送对象缺少 userIds"); - } - } else { - problems.add(nodeLabel(n) + " 未知抄送对象类型:" + r.type + "(支持 DEPT_KEY/USERS)"); - } + validateSubject(n, r, "抄送对象", enabledDeptKeys, selectableDeptRefs, problems); } /** @@ -315,23 +317,75 @@ public final class DefValidator { private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(DefValidator.class); - private static void validateApproval(FlowDefModel.Node n, Set enabledDeptKeys, List problems) { FlowDefModel.Assignee a = n.assignee; - if (a == null || a.type == null) { - problems.add(nodeLabel(n) + " 缺少 assignee 配置"); - return; - } - if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { + /** + * 审批主体校验(C2:部门与人员是两种主体,部门不能降级成"前端筛选出来的一堆人")。 + * DEPT=UserBase 新部门目录的类型化引用(新配置唯一口径);DEPT_KEY=历史本地科室(只读兼容); + * USERS=固定人员。会签/或签由 {@code countersign} 独立决定,对 DEPT 与 USERS 同样有效。 + */ + private static void validateSubject(FlowDefModel.Node n, FlowDefModel.Assignee a, String what, + Set enabledDeptKeys, Set selectableDeptRefs, + List problems) { + if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) { + if (a.deptRefs == null || a.deptRefs.isEmpty()) { + problems.add(nodeLabel(n) + " DEPT " + what + "缺少 deptRefs"); + return; + } + Set duplicates = new HashSet<>(); + for (FlowDefModel.DeptRef ref : a.deptRefs) { + if (ref == null || ref.departmentId == null || ref.departmentId <= 0) { + problems.add(nodeLabel(n) + " DEPT " + what + "引用缺少 departmentId"); + continue; + } + String type; + try { + type = ref.departmentType == null ? "" : ref.departmentType.trim().toUpperCase(); + } catch (RuntimeException invalid) { + problems.add(nodeLabel(n) + " DEPT " + what + "来源类型非法:" + ref.departmentType); + continue; + } + if (!"SYSTEM".equals(type) && !"CUSTOM".equals(type)) { + problems.add(nodeLabel(n) + " DEPT " + what + "来源类型必须是 SYSTEM/CUSTOM:" + + ref.departmentType); + continue; + } + String key = type + ":" + ref.departmentId; + if (!duplicates.add(key)) { + problems.add(nodeLabel(n) + " " + what + "重复引用同一部门:" + key); + } + if (selectableDeptRefs != null && !selectableDeptRefs.contains(key)) { + problems.add(nodeLabel(n) + " 的部门 " + key + " 不在本校可选部门目录内(已停用/已删除/跨校)"); + } + } + } else if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { if (a.deptKey == null || a.deptKey.trim().isEmpty()) { - problems.add(nodeLabel(n) + " DEPT_KEY 办理人缺少 deptKey"); + problems.add(nodeLabel(n) + " DEPT_KEY " + what + "缺少 deptKey"); } else if (enabledDeptKeys != null && !enabledDeptKeys.contains(a.deptKey)) { problems.add(nodeLabel(n) + " 的 DEPT_KEY=" + a.deptKey + " 无启用中的办理人映射"); } } else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) { if (a.userIds == null || a.userIds.isEmpty()) { - problems.add(nodeLabel(n) + " USERS 办理人缺少 userIds"); + problems.add(nodeLabel(n) + " USERS " + what + "缺少 userIds"); } } else { - problems.add(nodeLabel(n) + " 未知办理人类型:" + a.type + "(一期支持 DEPT_KEY/USERS)"); + problems.add(nodeLabel(n) + " 未知" + what + "类型:" + a.type + "(支持 DEPT/DEPT_KEY/USERS)"); + } + } + + private static void validateApproval(FlowDefModel.Node n, Set enabledDeptKeys, + Set selectableDeptRefs, List problems) { + FlowDefModel.Assignee a = n.assignee; + if (a == null || a.type == null) { + problems.add(nodeLabel(n) + " 缺少 assignee 配置"); + } else { + validateSubject(n, a, "办理人", enabledDeptKeys, selectableDeptRefs, problems); + } + // C3 展示规则:主体与显示方式分开配置。按部门展示必须有独立名称(不靠遍历人员部门猜) + if (n.displayMode != null && !FlowDefModel.DISPLAY_MODE_USER.equals(n.displayMode) + && !FlowDefModel.DISPLAY_MODE_DEPT.equals(n.displayMode)) { + problems.add(nodeLabel(n) + " displayMode 取值非法:" + n.displayMode + "(支持 USER/DEPT)"); + } + if (n.displayByDept() && (n.displayLabel == null || n.displayLabel.trim().isEmpty())) { + problems.add(nodeLabel(n) + " 选择「显示部门」时必须填写展示名称"); } if (n.countersign != null && !FlowDefModel.COUNTERSIGN_ALL.equals(n.countersign) && !FlowDefModel.COUNTERSIGN_ANY.equals(n.countersign)) { diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java index 64c2f67..bfd8a14 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java @@ -45,6 +45,18 @@ public class FlowDefModel { /** 办理人类型(概览 §六:一期 DEPT_KEY/USERS;SUBMITTER_DEPT/FORM_FIELD 二期) */ public static final String ASSIGNEE_DEPT_KEY = "DEPT_KEY"; public static final String ASSIGNEE_USERS = "USERS"; + /** + * UserBase 新部门目录引用(C1,2026-10-29)。{@link Assignee#deptRefs} 承载, + * 学校范围由定义行的 schId 租户键决定(不由客户端提交)。 + * 新配置一律写本类型;{@link #ASSIGNEE_DEPT_KEY} 只作历史定义的读取口径, + * 不再作为新流程的权威来源,也不存在"新目录失败就静默退回本地科室"的双权威兜底。 + */ + public static final String ASSIGNEE_DEPT = "DEPT"; + + /** 节点展示方式:按人员(缺省) */ + public static final String DISPLAY_MODE_USER = "USER"; + /** 节点展示方式:按部门(独立展示名,见 {@link Node#displayLabel}) */ + public static final String DISPLAY_MODE_DEPT = "DEPT"; /** 发起时指派选择模式:SINGLE=恰 1 人;MULTI=多选(min~max) */ public static final String ASSIGN_MODE_SINGLE = "SINGLE"; @@ -99,14 +111,59 @@ public class FlowDefModel { public Integer deadlineHours; /** 发起时指派策略(2026-10-13 方案):存在即允许指派;缺省/缺失=不允许(向前兼容) */ public AssignPolicy assignPolicy; + /** + * 节点展示方式(C3,2026-10-29,可选):{@link #DISPLAY_MODE_USER}=按人员(缺省, + * 含所有历史定义)、{@link #DISPLAY_MODE_DEPT}=按部门。 + * 与"审批主体"是两件事:本字段只改显示口径,不改变候选人范围与会签/或签规则。 + */ + public String displayMode; + /** 展示部门时的独立名称;主体是部门时前端默认填入所选部门名,管理员可改;主体是人员时必须手工填 */ + public String displayLabel; + + /** 展示口径是否为"按部门"(null/其它值一律按人员,旧 contentJson 零行为变化) */ + public boolean displayByDept() { + return DISPLAY_MODE_DEPT.equals(displayMode); + } + + /** + * 落进任务/抄送/轨迹的节点名(C3 收口):按部门展示且有独立名称时用该名称, + * 否则沿用节点名——当前节点与历史记录同口径,消除"配置 A、显示 B"的错位。 + */ + public String displayName() { + if (displayByDept() && displayLabel != null && !displayLabel.trim().isEmpty()) { + return displayLabel.trim(); + } + return name; + } + } + + /** + * UserBase 部门引用项(C1):类型化三要素中的两要素落定义 JSON,学校范围取定义/实例的 schId。 + * {@code departmentType}=SYSTEM/CUSTOM 是必带消歧位——同值 id 在两类目录里不等价, + * 不再把 UserBase 组 ID 当 FL 本地自增 deptKey 用。 + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DeptRef { + /** SYSTEM | CUSTOM */ + public String departmentType; + /** SYSTEM=sys_group.id;CUSTOM=ub_custom_department.id */ + public Integer departmentId; + + /** 目录消歧键:{@code TYPE:id} */ + public String directoryKey() { + return (departmentType == null ? "" : departmentType.trim().toUpperCase()) + + ":" + departmentId; + } } @JsonIgnoreProperties(ignoreUnknown = true) public static class Assignee { - /** DEPT_KEY | USERS */ + /** DEPT_KEY(历史只读)| USERS | DEPT(UserBase 新部门目录,新配置唯一写入口径) */ public String type; public String deptKey; public List userIds; + /** {@link #type}=DEPT 时的部门引用集合(可多选;一个用户可属多个部门,解析后按人去重) */ + public List deptRefs; } /** 发起时指派策略:mode 必填(容错 null=MULTI);min/max 可空(缺省 1/候选全量数);required 仅子应用 UI 用 */ diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java index d1b7543..40f4f61 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java @@ -224,12 +224,14 @@ public class FlowEngine { LocalDateTime deadline = node.deadlineHours == null ? null : now.plusHours(node.deadlineHours); FlowOperator op = operator != null ? operator : FlowOperator.of(instance.getSubmitterId(), instance.getSubmitterName()); + // C3 节点展示口径在建任务时定格:显示部门就用独立名称,当前节点与后续轨迹/记录同口径 + String displayName = node.displayName(); for (AssigneeRef ref : assignees) { FlwTask task = new FlwTask(); task.setInstanceId(instance.getId()); task.setSchId(instance.getSchId()); task.setNodeId(node.id); - task.setNodeName(node.name); + task.setNodeName(displayName); task.setAssigneeId(ref.getUserId()); task.setAssigneeName(ref.getUserName()); task.setStatus(FlwEnums.TaskStatus.PENDING.getCode()); @@ -239,7 +241,7 @@ public class FlowEngine { taskRepository.save(task); Map data = new HashMap<>(); data.put("nodeId", node.id); - data.put("nodeName", node.name); + data.put("nodeName", displayName); data.put("nodeSummary", nodeSummary(model)); data.put("totalTasks", assignees.size()); data.put("doneTasks", 0); @@ -265,19 +267,20 @@ public class FlowEngine { FlowOperator op = operator != null ? operator : FlowOperator.of(instance.getSubmitterId(), instance.getSubmitterName()); LocalDateTime now = LocalDateTime.now(); + String displayName = node.displayName(); for (AssigneeRef ref : receivers) { FlwCc cc = new FlwCc(); cc.setSchId(instance.getSchId()); cc.setInstanceId(instance.getId()); cc.setNodeId(node.id); - cc.setNodeName(node.name); + cc.setNodeName(displayName); cc.setUserId(ref.getUserId()); cc.setUserName(ref.getUserName()); cc.setSentTime(now); ccRepository.save(cc); Map data = new HashMap<>(); data.put("nodeId", node.id); - data.put("nodeName", node.name); + data.put("nodeName", displayName); data.put("nodeSummary", nodeSummary(model)); data.put("ccId", cc.getId()); data.put("ccUserId", ref.getUserId()); diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java index 67e9426..1b6162a 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java @@ -16,7 +16,7 @@ import java.time.LocalDateTime; * 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存 * biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为 * (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。 - * 旧入口以 sourcePrincipalId + legacySlot=1 实现每来源一业务一行;正式命令 legacySlot=NULL。 + * sourcePrincipalId=NULL / legacySlot=1 仅保留迁移前旧入口的一业务一行语义;正式模式关闭旧写入口。 * *

version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件), * 由 JPA @Version 在 flush 时自增。 @@ -34,7 +34,7 @@ import java.time.LocalDateTime; public class FlwInstance extends BaseEntity { @Column(name="source_principal_id",length=36,updatable=false) private String sourcePrincipalId; - /** 1 for source-scoped legacy API writers, NULL for formal commands. */ + /** 1 for legacy writers, NULL for formal commands. Migration must drop the old three-column UK. */ @Column(name="legacy_slot") private Integer legacySlot = 1; diff --git a/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java index da6388c..8cb37ca 100644 --- a/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java +++ b/src/main/java/com/yunhai/flowcenter/enums/FlwEnums.java @@ -8,9 +8,18 @@ public final class FlwEnums { private FlwEnums() { } - /** 办件状态:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 */ + /** + * 办件状态:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 / 50 退回待修改。 + * + *

50 是 D14「退回发起人修改后重提」的等待态(2026-10-29):审批人把办件退回发起人后, + * 实例仍在同一轮次、同一实例上等待发起人重提,不是终态——不能复用 20/30/40 表达, + * 否则"退回待修改"会被 R4 重启、幂等回读与在途统计误判成新一轮申报。 + * 10 与 50 一起构成"在途"口径(见 {@link #inFlightCodes()})。

+ */ public enum InstanceStatus { - APPROVING(10), COMPLETED(20), REJECTED(30), WITHDRAWN(40); + APPROVING(10), COMPLETED(20), REJECTED(30), WITHDRAWN(40), + /** 50 已退回发起人,等待修改后重提(在途,非终态) */ + RETURNED_TO_SUBMITTER(50); private final int code; @@ -21,6 +30,11 @@ public final class FlwEnums { public int getCode() { return code; } + + /** 在途状态集(10 审批中、50 退回待修改):撤回守卫、在途统计、幂等回读共用口径 */ + public static java.util.List inFlightCodes() { + return java.util.Arrays.asList(APPROVING.getCode(), RETURNED_TO_SUBMITTER.getCode()); + } } /** @@ -50,6 +64,8 @@ public final class FlwEnums { public static final String NODE_WITHDRAW = "withdraw"; /** 留痕事件行保留节点 id:管理端按新版定义重跑路由(F5-4,nodeName 载明 v旧→v新) */ public static final String NODE_REROUTE_DEF = "reroute-def"; + /** D14 保留节点 id:退回发起人修改的去向(定义模型里不存在,示意图着色/退回目标解析天然跳过) */ + public static final String NODE_RETURN_SUBMITTER = "submitter"; private final int code; diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java index 0bdcb45..ef0d50c 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java @@ -12,6 +12,10 @@ import java.util.List; import java.util.Optional; public interface FlwInstanceRepository extends JpaRepository { + @Query(value = "select id from flw_instance where sch_id=:school and source_principal_id=:source " + + "and biz_type=:type and biz_id=:biz and legacy_slot is null for update", nativeQuery = true) + List formalIdsForUpdate(@Param("school") Long school, @Param("source") String source, + @Param("type") String type, @Param("biz") String biz); @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) @Query("select i from FlwInstance i where i.id=:id") Optional findFormalPreviousForUpdate(@Param("id") Long id); @@ -34,17 +38,22 @@ public interface FlwInstanceRepository extends JpaRepository value = "update flw_instance set event_seq_counter = :v where id = :id", nativeQuery = true) int setEventSeqCounter(@Param("id") Long id, @Param("v") Integer v); - Optional findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(Long school, String source, String type, String biz); + Optional findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByIdDesc( + Long school, String source, String type, String biz); @Query("select i from FlwInstance i where i.schId=:school and i.sourcePrincipalId=:source and (:bizType is null or i.bizType=:bizType) and (:status is null or i.status=:status) order by i.id desc") Page pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable); - Optional findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( - Long school, String source, String bizType, String bizId, Integer slot); - - /** Unknown-source rows are never used to recover a caller's instance. */ - @Query("select i from FlwInstance i where i.schId=?1 and i.bizType=?2 and i.bizId=?3 and i.sourcePrincipalId is null") - Optional findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId); + /** + * D11/F02 业务坐标主查询:可信来源应用 + 学校 + 业务类型 + 业务 ID + 入口槽位。 + * 与 {@code uk_flw_instance_legacy} 逐列同构——应用 A 的在途件不会因为撞上应用 B 的同名业务键 + * 而被 B 读到、复用或拿到 A 的 instanceId。 + * + *

原 3 参 {@code findBySchIdAndBizTypeAndBizId}(只按裸坐标、且限定 {@code sourcePrincipalId is null}) + * 已删除:它既可能把他人办件回给本应用,也读不到改造后的新行。

+ */ + Optional findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + Long schId, String sourcePrincipalId, String bizType, String bizId, Integer legacySlot); List findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status); @@ -75,14 +84,14 @@ public interface FlwInstanceRepository extends JpaRepository @Param("keyword") String keyword, @Param("hung") Boolean hung, Pageable pageable); - /** 按业务类型/状态分页(oapi instances 查询;bizType/status 均可空=不过滤) */ - @Query("select i from FlwInstance i where i.schId = :schId " - + "and (:bizType is null or i.bizType = :bizType) " - + "and (:status is null or i.status = :status) order by i.id desc") - Page pageByBiz(@Param("schId") Long schId, @Param("bizType") String bizType, - @Param("status") Integer status, Pageable pageable); + /** + * 跨来源审计读:同一裸业务坐标在本校的全部归属行(不参与幂等复用,只用于"这个业务键被谁占了" + * 的核查与测试断言零状态)。幂等与归属判定一律走上面的来源分区方法。 + */ + List findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId); - /** 在途办件数(议题二④):按 def_id 分组统计审批中(10)办件,一行一版本 */ - @Query("select i.defId, count(i) from FlwInstance i where i.schId = :schId and i.defId in :defIds and i.status = 10 group by i.defId") + /** 在途办件数(议题二④):按 def_id 分组统计在途(10 审批中 / 50 退回待修改)办件,一行一版本 */ + @Query("select i.defId, count(i) from FlwInstance i where i.schId = :schId and i.defId in :defIds " + + "and i.status in (10, 50) group by i.defId") List countApprovingByDefIds(@Param("schId") Long schId, @Param("defIds") Collection defIds); } diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java index f196414..882d0eb 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java @@ -12,6 +12,7 @@ import java.time.LocalDateTime; import java.util.List; public interface FlwTaskRepository extends JpaRepository { + java.util.Optional findFirstByInstanceIdAndStatusOrderByIdDesc(Long instanceId, Integer status); /** 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 " @@ -69,16 +70,6 @@ public interface FlwTaskRepository extends JpaRepository { Page pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, @Param("appKey") String appKey, Pageable pageable); - /** - * 我的待办(oapi 版,bizType 可选过滤):join 办件表过滤业务类型,排序口径与 {@link #pageTodo} - * 一致(到达时间→id)。bizType 传 null 即全量(与 pageTodo 等价)。 - */ - @Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId " - + "where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 " - + "and (:bizType is null or i.bizType = :bizType) " - + "order by t.arriveTime asc, t.id asc") - Page pageTodoBiz(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, - @Param("bizType") String bizType, Pageable pageable); /** * 我的已办(一个办件一行,2026-09-18):同一办件被本人办理多次(多节点/退回重办/移交后 diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java index ee3cd37..64429f7 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java @@ -15,4 +15,6 @@ public interface FlwUsageGrantRepository extends JpaRepository findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(Long schId, String flowGuid); List findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(Long schId, Integer appId); + + List findBySchIdOrderByFlowGuidAscUpdateTimeDesc(Long schId); } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java index 3837c1a..5dbaf92 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -47,6 +47,18 @@ public class FlowDefAdminService { private final ObjectMapper objectMapper; private final FormCenterSchemaClient formCenterSchemaClient; + /** + * UserBase 新部门目录(C1,字段注入:构造被 {@code FlDefLineOfflineTest} 等基线测试逐位钉住)。 + * 发布/启用校验用它取"本校可被新选中"的部门白名单;不可用时照抛阻断发布, + * 不静默退回 {@code flw_dept}(双权威 fallback 已被禁)。 + */ + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setDepartmentDirectory(UserBaseDepartmentDirectory directory) { + this.departmentDirectory = directory; + } + + private UserBaseDepartmentDirectory departmentDirectory; + /** 定义列表:status/appKey 可选过滤(各流程线各版本都展示;appKey 精确匹配,2026-10-10 按应用筛选) */ public List list(Long schId, Integer status, String appKey) { List rows = status != null @@ -82,8 +94,10 @@ public class FlowDefAdminService { * - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1) * - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本" * - id 空:永远新开一条流程线(flowGuid 生成,version=1);同编码多条线合法,前端保存前有软提示 - * appKey=归属应用(筛选维度,2026-10-12 拍板必填):新建必须显式传;升版本/直改可继承源行, - * 但源行为空时必须显式补填——存量 NULL 线随编辑自然收敛(白名单只校验显式传入值,继承值不重校) + * appKey=归属应用(可选的创建方/审计元数据,D02 收口 2026-10-29):缺省留空合法,显式传入时 + * 必须 ∈ 本校已开启应用白名单(继承值不重校)。它不是使用许可,也不控制定义的可见性—— + * 授权关系归 {@code flw_usage_grant} 独立管理(见 {@link FlowUsageGrantGuard})。 + * code=内部编码:新建可缺省(后端生成稳定唯一码),更新/升版本缺省继承既有编码,编码不随名称变化。 * changeLog=升版本改动说明(议题二③):只随新建行落库(三个建新行分支),草稿直改行不写也不清空 */ @Transactional @@ -100,9 +114,12 @@ public class FlowDefAdminService { } // JSON 先解析再落库(语法错误不进库) FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson); - if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) { - throw new BizException(ErrorCode.PARAM_ERROR, "code/name 必填"); + if (name == null || name.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程名称必填"); } + // A3(2026-10-29):内部编码不再要求管理员手填。新建缺省由后端生成稳定唯一码; + // 更新/升版本缺省继承既有行的编码——编码不随名称或编辑变化,旧客户端显式提交的合法编码仍兼容。 + String cleanCode = trimToNull(code); FlwDef def; if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) { // 在既有流程线上开新版本草稿(appKey 缺省继承源线) @@ -111,24 +128,24 @@ public class FlowDefAdminService { def = new FlwDef(); def.setSchId(schId); def.setFlowGuid(baseGuid.trim()); - def.setCode(code.trim()); def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1); def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey()); def.setFormUuid(base.getFormUuid()); def.setFormVersion(base.getFormVersion()); + def.setCode(cleanCode != null ? cleanCode : base.getCode()); } else if (id == null) { // 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用) def = new FlwDef(); def.setSchId(schId); def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase()); - def.setCode(code.trim()); def.setVersion(1); def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); def.setAppKey(cleanAppKey); + def.setCode(cleanCode != null ? cleanCode : generateFlowCode()); } else { def = requireDef(schId, id); - if (!def.getCode().equals(code.trim())) { + if (cleanCode != null && !def.getCode().equals(cleanCode)) { throw new BizException(ErrorCode.PARAM_ERROR, "流程编码不可修改"); } if (cleanAppKey != null) { @@ -148,14 +165,9 @@ public class FlowDefAdminService { def = next; } } - def.setCode(code.trim()); def.setName(name.trim()); - // 归属应用必填(2026-10-12 拍板):各分支归一后落库值不得为空——新建必须显式传; - // 升版本/直改继承源行,源行为空的存量线编辑时强制补填(19 条存量随编辑自然收敛) - if (def.getAppKey() == null || def.getAppKey().trim().isEmpty()) { - throw new BizException(ErrorCode.PARAM_ERROR, - "归属应用必填:请选择归属应用后再保存(应用需先在 流程管理 > 应用设置 中开启)"); - } + // 归属应用可选(D02,2026-10-29 收口):只是"创建方/审计元数据",既不控制定义的可见性, + // 也不是应用使用许可——授权关系归 flw_usage_grant 独立管理。空值合法,不再拦保存。 // 改动说明只写新建行(新建/baseGuid 开版/已发布复制升版三分支 id 必为 null);草稿直改不动 if (def.getId() == null) { def.setChangeLog(cleanLog); @@ -235,14 +247,16 @@ public class FlowDefAdminService { def.getFormVersion(), "发布"); model.variables = VariableContract.fromSnapshot(snapshot); VariableContract.validateDefinition(model, true); - DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); + DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys(), + selectableDeptRefs(schId, model)); 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()); + DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys(), + selectableDeptRefs(schId, model)); } def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setPublishTime(LocalDateTime.now()); @@ -320,7 +334,8 @@ public class FlowDefAdminService { throw new BizException(ErrorCode.PARAM_ERROR, "仅停用的流程可启用;草稿请走发布"); } FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); - DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); + DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys(), + selectableDeptRefs(schId, model)); if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) { checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "启用"); } else if (def.getFormVersion() != null) { @@ -440,9 +455,42 @@ public class FlowDefAdminService { private Set enabledDeptKeys(Long schId) { // 2026-10-10 科室管理切源:发布校验白名单改查 flw_dept/flw_dept_member(拍板 D1) + // —— 该口径只服务历史 DEPT_KEY 定义的读取;新配置一律走 UserBase 目录(见下) return deptAdminService.enabledDeptKeys(schId); } + /** + * UserBase 新部门目录白名单(C1):定义里出现 {@code DEPT} 主体才出站查询—— + * 不用部门的简单串行流程不因目录不可用而被无谓拦下;一旦用了,目录不可用即拒(不退回本地科室)。 + */ + private Set selectableDeptRefs(Long schId, FlowDefModel model) { + if (!referencesUserBaseDepartment(model)) { + return null; + } + if (departmentDirectory == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, + "UserBase 部门目录通道未装配,含部门审批主体的流程不能发布/启用"); + } + return departmentDirectory.selectableKeys(schId); + } + + /** 定义是否引用了 UserBase 部门主体(审批 assignee 与抄送 receivers 都算) */ + static boolean referencesUserBaseDepartment(FlowDefModel model) { + if (model == null || model.nodes == null) { + return false; + } + for (FlowDefModel.Node node : model.nodes) { + if (node == null) { + continue; + } + if (node.assignee != null && FlowDefModel.ASSIGNEE_DEPT.equals(node.assignee.type) + || node.receivers != null && FlowDefModel.ASSIGNEE_DEPT.equals(node.receivers.type)) { + return true; + } + } + return false; + } + /** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */ private Set enabledRouterKeys() { return routerRepository.findByStatusOrderByIdAsc(1).stream() @@ -459,4 +507,24 @@ public class FlowDefAdminService { return def; } + private static String trimToNull(String raw) { + if (raw == null) { + return null; + } + String value = raw.trim(); + return value.isEmpty() ? null : value; + } + + /** + * 内部编码生成(A3,2026-10-29):固定前缀 + 32 位 UUID hex,与 {@code flowGuid} 同族约定但 + * 语义不同——编码是可重名的分类标签(唯一键是 uk(sch,flow_guid,version),见 {@link FlwDef#getCode()}), + * 因此这里不依赖数据库唯一约束即可稳定唯一;生成后随行落库、不随名称或后续编辑变化。 + */ + static String generateFlowCode() { + return FLOW_CODE_PREFIX + UUID.randomUUID().toString().replace("-", "").toLowerCase(); + } + + /** 编码前缀:与 flowGuid 的裸 hex 区分,便于人读与排障。 */ + private static final String FLOW_CODE_PREFIX = "flow-"; + } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java index 5f843d1..856bfcd 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java @@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; +import java.util.Optional; /** * 引擎实例生命周期与迁移前代发起。正式命令通过 create/startFormalInstance 建立独立轮次实例, @@ -38,12 +39,21 @@ import java.util.Map; @RequiredArgsConstructor public class FlowInstanceService { + /** 旧 OAPI 入口的坐标槽位(正式命令走 NULL 槽位,两条分区互不复用)。 */ + private static final Integer LEGACY_SLOT = 1; + private final FlwInstanceRepository instanceRepository; private final FlwDefRepository defRepository; private final FlwTaskRepository taskRepository; private final FlwBranchRepository branchRepository; private final FlowEngine flowEngine; private final ApplicationEventPublisher eventPublisher; + @org.springframework.beans.factory.annotation.Autowired + private com.yunhai.flowcenter.repository.FlwCommandSourceRepository commandSources; + @org.springframework.beans.factory.annotation.Autowired + private FlowUsageGrantGuard creationGrants; + @javax.persistence.PersistenceContext + private javax.persistence.EntityManager entityManager; /** 代发起申请体(oapi apply 与未来原生发起共用字段) */ @RequiredArgsConstructor @@ -67,48 +77,66 @@ public class FlowInstanceService { /** * 受理载荷指纹(2026-10-25 T04,SHA-256 hex;非构造参数——13 参构造被 - * {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值; - * null=存量调用方,指纹核对缺省关)。 + * {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值;null=存量调用方, + * 指纹核对缺省关)。 */ public String payloadHash; - /** Assigned only by authenticated server-side source resolution; not a request DTO field. */ + + /** + * 可信来源主体(D11/F02,2026-10-29;同样非构造参数,理由同上)。 + * 由服务端从验签 appKey 解析,绝不接收客户端自报值。业务坐标的幂等复用、重启与 + * 按业务键回读都以它为分区维度;为空即拒(缺失来源不得静默补全)。 + */ public String sourcePrincipalId; } - /** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */ + /** 代发起结果值对象:instanceId + duplicated(命中既有办件=true,含重启重提) */ @Transactional public ApplyOutcome apply(ApplyCommand cmd) { - FlwInstance existing = findOwned(cmd); + FlwInstance existing = requireSourceScopedLookup(cmd); if (existing != null) { requireSameOwner(existing, cmd); int status = existing.getStatus(); if (status == FlwEnums.InstanceStatus.APPROVING.getCode() - || status == FlwEnums.InstanceStatus.COMPLETED.getCode()) { + || status == FlwEnums.InstanceStatus.COMPLETED.getCode() + || status == FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()) { return new ApplyOutcome(existing.getId(), true); } + // 30/40:R4 重启推进(复用办件行,定义/快照/时间刷新,重新走 START) restart(existing, cmd); return new ApplyOutcome(existing.getId(), true); } - FlwInstance created = createInstance(cmd, java.util.UUID.randomUUID().toString(), 1, cmd.sourcePrincipalId, 1, true); - return new ApplyOutcome(created.getId(), false); - } - - private FlwInstance findOwned(ApplyCommand cmd) { - if (cmd.schId == null || cmd.schId <= 0 || cmd.sourcePrincipalId == null - || !cmd.sourcePrincipalId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) { - throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源或有效申报学校"); - } - FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( - cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, 1).orElse(null); - if (existing == null && instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc( - cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId).isPresent()) { - throw new BizException(ErrorCode.FORBIDDEN, "该业务对象已有正式命令实例,请使用原命令通道"); - } - return existing; + requireLegacyCreationGrant(cmd); + FlwInstance instance = new FlwInstance(); + instance.setSourcePrincipalId(cmd.sourcePrincipalId); + instance.setLegacySlot(LEGACY_SLOT); + instance.setSchId(cmd.schId); + instance.setDefId(cmd.def.getId()); + instance.setDefCode(cmd.def.getCode()); + instance.setDefVersion(cmd.def.getVersion()); + instance.setBizType(cmd.bizType); + instance.setBizId(cmd.bizId); + instance.setBizTitle(cmd.bizTitle); + instance.setBizUrl(cmd.bizUrl); + instance.setBizSnapshot(cmd.bizSnapshot); + instance.setFormDataJson(cmd.formDataJson); + instance.setAssignJson(cmd.assignJson); + instance.setApplyAppKey(cmd.applyAppKey); + instance.setApplyAppCode(cmd.applyAppCode); + instance.setSubmitterId(cmd.submitterId); + instance.setSubmitterName(cmd.submitterName); + instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); + instance.setSubmitTime(LocalDateTime.now()); + instanceRepository.saveAndFlush(instance); + appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请", + FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName); + flowEngine.startAdvance(instance, cmd.def); + publishSubmitted(instance); + return new ApplyOutcome(instance.getId(), false); } private void restart(FlwInstance instance, ApplyCommand cmd) { - restart(instance, cmd, java.util.UUID.randomUUID().toString(), instance.getApplyRound()); + restart(instance, cmd, null, null); } /** @@ -117,6 +145,7 @@ public class FlowInstanceService { * 不为修复幂等而重写流程节点算法)。旧 apply 路径传 null ⇒ 行为与历史逐字一致。 */ private void restart(FlwInstance instance, ApplyCommand cmd, String requestToken, Integer round) { + requireLegacyCreationGrant(cmd); log.info("[flow-center] R4 重启推进:instance={} 原 status={} biz={}/{}", instance.getId(), instance.getStatus(), cmd.bizType, cmd.bizId); instance.setDefId(cmd.def.getId()); @@ -155,14 +184,67 @@ public class FlowInstanceService { publishSubmitted(instance); } + /** + * D14 发起人重提(2026-10-29):同一实例、同一申报轮次从第一级重新审批。 + * + *

与"终态后重新申报"({@link #apply} 的 R4 重启,会刷新定义版本并留 重新提交 轨迹) + * 和"申请人撤回"完全区分:本方法只接受 {@code 50 退回待修改} 的办件,不改 defId/defVersion + * (D04/D05 在途实例固定发起时版本),不自增 applyRound,不删任何历史任务/意见/事件行。

+ * + *

并发重提使用数据库行锁并刷新已加载实体;状态 10 还必须有最新 D14 提交留痕, + * 才可作为回执恢复。普通审批中与旧 R4 留痕都不构成 D14 重提成功证明。

+ * + * @param formDataJson 业务应用可选回传的更新后表单快照(null=沿用原快照,FL 不做字段 Diff) + * @return 重提后的实例 id(幂等:已重提时同样返回原实例) + */ + @Transactional + public Long resubmitAfterReturn(Long schId, Long instanceId, Integer submitterId, String formDataJson) { + FlwInstance instance = instanceRepository.findFormalPreviousForUpdate(instanceId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + // A controller may already have loaded this row into the request's persistence context. + // Refresh under the database lock so a waiting request cannot act on its stale status=50. + if (entityManager != null) entityManager.refresh(instance, javax.persistence.LockModeType.PESSIMISTIC_WRITE); + if (!instance.getSchId().equals(schId)) throw new BizException(ErrorCode.FORBIDDEN, "办件不属于该学校"); + if (instance.getSubmitterId() == null || !instance.getSubmitterId().equals(submitterId)) { + throw new BizException(ErrorCode.FORBIDDEN, "仅发起人本人可重提"); + } + if (instance.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode() + && taskRepository.findFirstByInstanceIdAndStatusOrderByIdDesc(instanceId, + FlwEnums.TaskStatus.SUBMITTED.getCode()) + .filter(t -> FlwEnums.TaskStatus.NODE_RESUBMIT.equals(t.getNodeId()) + && "修改后重新提交".equals(t.getNodeName())).isPresent() + && (formDataJson == null || formDataJson.equals(instance.getFormDataJson()))) { + // 重复点击/响应丢失后的重试:已有一路重提成功,回原实例而不是再启一代任务 + log.info("[flow-center] D14 重提幂等命中(已重提):instance={}", instanceId); + return instance.getId(); + } + if (instance.getStatus() != FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "办件不处于退回待修改状态,不能重提"); + } + FlwDef def = requireDef(instance.getDefId()); + if (formDataJson != null && !formDataJson.trim().isEmpty()) { + instance.setFormDataJson(formDataJson); + } + instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); + instance.setCurrentNodes(""); + instance.setSubmitTime(LocalDateTime.now()); + instanceRepository.saveAndFlush(instance); + appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_RESUBMIT, "修改后重新提交", + FlwEnums.TaskStatus.SUBMITTED, instance.getSubmitterId(), instance.getSubmitterName()); + // 从 START 重新推进=第一级审批节点重建一代新任务;旧任务行仍是 1/2/4/3,与新任务可区分 + flowEngine.startAdvance(instance, def); + publishSubmitted(instance); + return instance.getId(); + } + /** * 撤回(概览 §5.3):仅当尚无任何任务被办理时允许;全部待办失效、分支失效、办件=40。 */ @Transactional public void withdraw(Long schId, Long instanceId) { FlwInstance instance = requireInstance(schId, instanceId); - if (instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) { - throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的办件可撤回"); + if (!FlwEnums.InstanceStatus.inFlightCodes().contains(instance.getStatus())) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅在途办件可撤回(审批中或退回待修改)"); } if (!taskRepository.findByInstanceIdAndStatusIn(instanceId, FlwEnums.TaskStatus.handledCodes()).isEmpty()) { @@ -342,13 +424,13 @@ public class FlowInstanceService { */ @Transactional public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) { - FlwInstance existing = findOwned(cmd); + FlwInstance existing = requireSourceScopedLookup(cmd); if (existing != null) { return settleExisting(existing, cmd, policy); } String token = policy == null ? null : trimToNull(policy.requestToken); Integer round = policy == null || policy.attempt == null ? Integer.valueOf(1) : policy.attempt; - FlwInstance created = createInstance(cmd, token, round, cmd.sourcePrincipalId, 1, true); + FlwInstance created = createInstance(cmd, token, round, cmd.sourcePrincipalId, LEGACY_SLOT); return new GuardedOutcome(created.getId(), false, FlowApplyRetryModels.Branch.CREATED, round); } @@ -375,7 +457,8 @@ public class FlowInstanceService { } int status = existing.getStatus(); if (status == FlwEnums.InstanceStatus.APPROVING.getCode() - || status == FlwEnums.InstanceStatus.COMPLETED.getCode()) { + || status == FlwEnums.InstanceStatus.COMPLETED.getCode() + || status == FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()) { // T04:带 token 的请求撞上在途/通过件且 token 不符=另一份命令(审计 P0-2: // "10/20 即使异命令也回 IDEMPOTENT"是把他人命令的成功当自己的)。无 token 的 // 存量调用方保持 IDEMPOTENT(守卫缺省关)。 @@ -424,7 +507,7 @@ public class FlowInstanceService { /** 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); + return createInstance(cmd, token, round, source, null); } @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) @@ -433,12 +516,16 @@ public class FlowInstanceService { publishSubmitted(instance); } - private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) { - return createInstance(cmd, token, round, source, null, false); - } - + /** + * 建件。来源主体与槽位都是入参:旧入口传(解析出的来源, {@code LEGACY_SLOT}), + * 正式命令传(来源, null)——两条分区不互认。来源缺失直接拒,不落"归属不明"的行。 + */ private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source, - Integer legacySlot, boolean start) { + Integer legacySlot) { + if (source == null || source.trim().isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源应用,不能创建办件"); + } + if (legacySlot != null) requireLegacyCreationGrant(cmd); FlwInstance instance = new FlwInstance(); instance.setSourcePrincipalId(source); instance.setLegacySlot(legacySlot); @@ -465,21 +552,70 @@ public class FlowInstanceService { instanceRepository.saveAndFlush(instance); appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请", FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName); - if (start) { + if (legacySlot != null) { flowEngine.startAdvance(instance, cmd.def); publishSubmitted(instance); } return instance; } + /** + * 业务坐标的来源分区读(F02 收口):可信来源缺失即拒,命中行必然属于本来源。 + * + *

D11 的业务对象身份=可信来源应用+学校+bizType+bizId,因此应用 A 已占用同名业务键时, + * 应用 B 命中的是"本来源无行"→ 走新建,各自得到独立实例;B 既拿不到 A 的 instanceId, + * 也不会被当成 duplicated 成功。同来源重投才回原实例。

+ */ + private FlwInstance requireSourceScopedLookup(ApplyCommand cmd) { + if (cmd.sourcePrincipalId == null || cmd.sourcePrincipalId.trim().isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源应用,不能按业务坐标定位或新建办件"); + } + lockLegacyAdmission(cmd); + Optional mine = instanceRepository + .findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, LEGACY_SLOT); + return mine.orElse(null); + } + + /** All accepting entry points share the formal source mutex, including first-insert races. */ + private void lockLegacyAdmission(ApplyCommand cmd) { + if (commandSources == null) throw new BizException(ErrorCode.FORBIDDEN, "来源互斥门禁未装配"); + String source = cmd.sourcePrincipalId; + Integer appId; + try { appId = Integer.valueOf(source.substring(FlowSourcePrincipalResolver.SOURCE_PREFIX.length())); } + catch (RuntimeException invalid) { throw new BizException(ErrorCode.FORBIDDEN, "来源主体非法"); } + if (!FlowSourcePrincipalResolver.sourcePrincipalIdOf(appId).equals(source)) + throw new BizException(ErrorCode.FORBIDDEN, "来源主体非法"); + com.yunhai.flowcenter.entity.FlwCommandSource mutex = commandSources.lock(source).orElse(null); + if (mutex == null) { + mutex = new com.yunhai.flowcenter.entity.FlwCommandSource(); + mutex.setId(source); mutex.setAppId(appId); commandSources.saveAndFlush(mutex); + } else if (!appId.equals(mutex.getAppId())) { + throw new BizException(ErrorCode.FORBIDDEN, "来源主体绑定不一致"); + } + if (!instanceRepository.formalIdsForUpdate(cmd.schId, source, cmd.bizType, cmd.bizId).isEmpty()) + throw new BizException(ErrorCode.FORBIDDEN, "该业务坐标已使用正式命令,禁止旧入口另建实例"); + } + + /** Recheck in the locked creation/restart branch: an earlier receipt preview may be stale. */ + private void requireLegacyCreationGrant(ApplyCommand cmd) { + if (creationGrants == null) throw new BizException(ErrorCode.FORBIDDEN, "创建使用许可门禁未装配"); + Integer appId = Integer.valueOf(cmd.sourcePrincipalId.substring(FlowSourcePrincipalResolver.SOURCE_PREFIX.length())); + creationGrants.requireNewApplyGrant(cmd.schId, cmd.def.getFlowGuid(), + new FlowSourcePrincipalResolver.Principal(cmd.sourcePrincipalId, appId, cmd.applyAppKey, cmd.applyAppCode)); + } + /** * 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用 - * 相同 bizType 键当自己的结果")。来源必须存在且匹配,不用 appKey 快照替代来源 UUID。 + * 相同 bizType 键当自己的结果")。两侧都非空才判——历史行没有 token/appKey 快照, + * 不能因为"当年没记"就把存量件判成别人的。 */ private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) { - if (cmd.sourcePrincipalId == null || !cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) { + if (cmd.sourcePrincipalId != null && existing.getSourcePrincipalId() != null + && !cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) { throw new BizException(ErrorCode.FORBIDDEN, - "该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")"); + "该业务键的办件属于另一调用应用,不能当作本应用的幂等结果(bizType=" + + cmd.bizType + " bizId=" + cmd.bizId + ")"); } if (existing.getSubmitterId() != null && cmd.submitterId != null && !existing.getSubmitterId().equals(cmd.submitterId)) { diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java b/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java index 79a22dc..af1666d 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java @@ -14,10 +14,28 @@ 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 +/** + * OAPI 来源作用域(D11/F02 整改,2026-10-29 起无条件生效)。 + * + *

来源主体只从验签上下文推导({@code yh.app.auth} → UserBase 应用注册主键), + * 任何客户端自报的来源头/字段都不采信。原先本拦截器挂在 + * {@code flow-center.formal-command.enabled} 上,旧发起入口跑起来时来源维度整体缺位, + * 等于"A 应用能用同一业务键拿到 B 应用的实例"——现取消该开关。

+ * + *

本闸只做身份与归属:解析来源 + 按 id 访问时核验实例属于本来源。 + * "新发起的使用许可"是另一件事,归 {@link FlowUsageGrantGuard},只挂在创建新实例的路径上, + * 不误加到只读查询/候选人预览。

+ */ +@Component +@RequiredArgsConstructor public class FlowOapiSourceScope implements HandlerInterceptor { + private static final String ATTRIBUTE = FlowOapiSourceScope.class.getName(); + private static final java.util.regex.Pattern INSTANCE_PATH = + java.util.regex.Pattern.compile("/instance/(\\d+)(?:/|$)"); + private static final java.util.regex.Pattern TASK_PATH = + java.util.regex.Pattern.compile("/task/(\\d+)(?:/|$)"); + private final FlowSourcePrincipalResolver resolver; private final FlwInstanceRepository instances; private final FlwTaskRepository tasks; @@ -26,37 +44,66 @@ public class FlowOapiSourceScope implements HandlerInterceptor { public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { 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); + // Path and query identify independently protected resources; neither may mask the other. + String pathInstance = match(INSTANCE_PATH, request); + String pathTask = match(TASK_PATH, request); + if (pathInstance != null) requireOwn(parse(pathInstance), source); + if (pathTask != null) requireOwn(tasks.findById(parse(pathTask)) + .orElseThrow(FlowOapiSourceScope::denied).getInstanceId(), source); + String instanceId = firstNonBlank(request.getParameter("instanceId"), match(INSTANCE_PATH, request)); + String taskId = firstNonBlank(request.getParameter("taskId"), match(TASK_PATH, request)); + if (instanceId != null) { + requireOwn(parse(instanceId), source); + } if (taskId != null) { - Long task = parse(taskId); - requireOwn(tasks.findById(task).orElseThrow(() -> denied()).getInstanceId(), source); + requireOwn(tasks.findById(parse(taskId)).orElseThrow(FlowOapiSourceScope::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(); + FlwInstance instance = instances.findById(id).orElseThrow(FlowOapiSourceScope::denied); + if (!source.equals(instance.getSourcePrincipalId())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅允许访问本应用来源的流程事实"); + } } + /** 当前请求的可信来源;缺失即拒(调用方不得用猜测归属补全)。 */ public static String current() { String source = currentOr(null); - if (source == null) throw denied(); + 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); + if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes)) { + return fallback; + } + Object value = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()) + .getRequest().getAttribute(ATTRIBUTE); return value instanceof String ? (String) value : fallback; } + + private static String firstNonBlank(String a, String b) { + return a != null && !a.trim().isEmpty() ? a.trim() : b; + } + + private static String match(java.util.regex.Pattern pattern, HttpServletRequest request) { + java.util.regex.Matcher m = pattern.matcher(request.getRequestURI()); + return m.find() ? m.group(1) : null; + } + private Long parse(String value) { - try { return Long.valueOf(value); } catch (NumberFormatException e) { throw denied(); } + try { + return Long.valueOf(value); + } catch (NumberFormatException e) { + throw denied(); + } + } + + private static BizException denied() { + return new BizException(ErrorCode.FORBIDDEN, "仅允许访问本应用来源的流程事实"); } - private static BizException denied() { return new BizException(ErrorCode.FORBIDDEN, "仅允许访问本应用来源的流程事实"); } } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java b/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java index a2bc4c5..a000a63 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java @@ -7,32 +7,45 @@ import com.yunhai.platform.utils.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.UUID; - +/** + * 调用应用主体解析(D02/D09/D11:appKey 是验签凭据,不是主体)。 + * + *

主体=UserBase {@code application_info} 主键 id(稳定整型主键,appKey 可重置/复用不影响它)。 + * 加 {@link #SOURCE_PREFIX} 前缀落库,与未来可能引入的 UUID 主体空间不撞。 + * 不可达、查无、缺主键一律拒绝,不回退 appKey/数字自增猜测。

+ */ @Service @RequiredArgsConstructor public class FlowSourcePrincipalResolver { + + /** 来源主体列(varchar(36))的命名空间前缀:标明"id 来自 UserBase 应用注册表主键"。 */ + public static final String SOURCE_PREFIX = "ub-app:"; + private final OpenApiAppClient appClient; + /** 稳定主体字符串;appId 缺失即拒(调用方不得用猜测值补全)。 */ + public static String sourcePrincipalIdOf(Integer appId) { + if (appId == null || appId <= 0) { + throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体不可解析"); + } + return SOURCE_PREFIX + appId; + } + public Principal resolve(String authenticatedAppKey) { if (authenticatedAppKey == null || authenticatedAppKey.trim().isEmpty()) { throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已认证的调用应用"); } JsonNode app; - try { app = appClient.getAppByKey(authenticatedAppKey.trim()); } - catch (RuntimeException unavailable) { - throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体查询不可用"); + try { + app = appClient.getAppByKey(authenticatedAppKey.trim()); + } catch (RuntimeException unavailable) { + throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体解析不可用,请稍后重试"); } - if (app == null || !app.path("id").isIntegralNumber() || app.path("id").intValue() <= 0) { + if (app == null || app.isNull() || !app.path("id").isIntegralNumber() || !app.path("id").canConvertToInt()) { 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, + Integer appId = app.path("id").intValue(); + return new Principal(sourcePrincipalIdOf(appId), appId, authenticatedAppKey.trim(), app.path("appCode").isTextual() ? app.path("appCode").textValue() : null); } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java index ad9e74e..0419cca 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java @@ -243,6 +243,55 @@ public class FlowTaskService { "无可退回的上游节点,请使用拒绝")); } + /** + * D14 退回发起人修改(2026-10-29):与 {@link #returnBack}(退回到上游审批节点续办)是 + * 两个不同动作,绝不能混用。 + * + *

语义:当前节点待办与整条办件的在途代次全部失效(含并行分支),办件转入 + * {@code 50 退回待修改}——仍不是终态、仍在同一实例同一申报轮次;历史任务/意见/事件行一律保留。 + * 发起人修改业务表单后走 {@code FlowInstanceService#resubmitAfterReturn} 重提,从第一级重新审批。

+ * + *

幂等与防重:抢占用既有 {@code claimAndFinish}(UPDATE WHERE status=0 原子语义), + * 双击/并发第二次拿不到办理权即拒。事件复用 {@code RETURNED}(不新增、不重命名既有事件码), + * 以 {@code returnTo=SUBMITTER} 与 {@code instanceStatus=50} 表达真实去向。

+ */ + @Transactional + public void returnToSubmitter(Long schId, Long taskId, String opinion, Integer operatorId) { + FlwTask task = requirePendingTask(schId, taskId, operatorId); + FlwInstance instance = instanceRepository.findById(task.getInstanceId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + if (instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) { + throw new BizException(ErrorCode.PARAM_ERROR, "办件已不在审批中,无法退回发起人"); + } + if (opinion == null || opinion.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "退回发起人必须填写退回原因"); + } + LocalDateTime now = LocalDateTime.now(); + if (taskRepository.claimAndFinish(taskId, FlwEnums.TaskStatus.RETURNED.getCode(), now, + "退回发起人修改:" + opinion.trim(), null) != 1) { + throw new BizException(ErrorCode.PARAM_ERROR, "任务已被办理或已失效,请刷新"); + } + // 在途代次整体失效:同节点其余会签人、其它节点待办、并行分支都不允许继续推进新一轮 + taskRepository.invalidatePendingByInstance(instance.getId()); + branchRepository.invalidateRunningByInstance(instance.getId()); + instance.setCurrentNodes(""); + instance.setStatus(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()); + instanceRepository.saveAndFlush(instance); + + Map data = new HashMap<>(); + data.put("nodeId", task.getNodeId()); + data.put("nodeName", task.getNodeName()); + data.put("fromNodeId", task.getNodeId()); + data.put("fromNodeName", task.getNodeName() == null ? task.getNodeId() : task.getNodeName()); + data.put("returnTo", "SUBMITTER"); + data.put("targetNodeId", FlwEnums.TaskStatus.NODE_RETURN_SUBMITTER); + data.put("targetNodeName", "发起人"); + data.put("opinion", opinion.trim()); + data.put("instanceStatus", instance.getStatus()); + eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.RETURNED, instance.getId(), taskId, + task.getAssigneeId(), task.getAssigneeName(), data)); + } + /** * 移交(B4):办理权转给他人。当前任务置 TRANSFERRED 留痕,同节点新建一条待办给目标人 * (deadline 沿用原任务);推进监听器因新待办 pending>0 自然不放行,无需改推进逻辑。 diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java index a39df24..6b4ffea 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java @@ -12,7 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * 应用使用授权管理面(2026-10-25 T01):学校×流程线×稳定主体的授予/撤销/查询。 @@ -40,6 +42,28 @@ public class FlowUsageGrantAdminService { return grantRepository.findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(schId, appId); } + /** + * 本校全部授权行(应用使用许可管理页数据源,2026-10-29 E6):只按会话校域取, + * 不跨校、不带分页(授权行数与流程线数同阶,前端按流程线/应用筛选)。 + * 摘要口径:{@code enabled=1} 才计入"生效授权应用数",撤销行仍返回供审计。 + */ + public List listBySchool(Long schId) { + return schId == null ? java.util.Collections.emptyList() + : grantRepository.findBySchIdOrderByFlowGuidAscUpdateTimeDesc(schId); + } + + /** 流程线 → 生效授权应用数(列表页授权摘要列;无授权的线不出键,前端按缺省 0 显示)。 */ + public Map grantedAppCounts(Long schId) { + Map counts = new HashMap<>(); + for (FlwUsageGrant grant : listBySchool(schId)) { + if (!Integer.valueOf(1).equals(grant.getEnabled()) || grant.getFlowGuid() == null) { + continue; + } + counts.merge(grant.getFlowGuid(), 1L, Long::sum); + } + return counts; + } + /** * 授予/重申(三元键 upsert)。 * @@ -72,15 +96,9 @@ public class FlowUsageGrantAdminService { grant.setFlowGuid(flowGuid.trim()); grant.setAppId(appId); } - { - 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 稳定来源未迁移,不能发放正式许可"); - } - grant.setSourcePrincipalId(source); - } + // 来源主体=UserBase 应用注册表主键(稳定、可重置 appKey 不影响);grant 与实例来源列同一口径, + // 撤权/换 key 后旧授权行不会误配到新主体上。 + grant.setSourcePrincipalId(FlowSourcePrincipalResolver.sourcePrincipalIdOf(appId)); grant.setAppKeySnapshot(cleanKey); grant.setAppNameSnapshot(registered.path("appName").asText(null)); grant.setEnabled(1); @@ -123,6 +141,7 @@ public class FlowUsageGrantAdminService { private Integer readId(JsonNode registered) { JsonNode idNode = registered.path("id"); - return idNode.isNumber() ? idNode.intValue() : null; + return idNode.isIntegralNumber() && idNode.canConvertToInt() && idNode.intValue() > 0 + ? idNode.intValue() : null; } } diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java index f4d26c9..3e47fb4 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java @@ -1,20 +1,18 @@ 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) 许可。 + * 应用使用许可闸门(2026-10-25 T01,F01 整改 2026-10-29 起无条件生效): + * 新发起必须持有生效的 (school, flowGuid, appId) 许可。 * - *

三层独立事实,缺一不可(所有环境):

+ *

三层独立事实,缺一不可:

*
    *
  1. 稳定主体:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用, * 不是主体;主体解析不可达=fail-closed 拒绝);
  2. @@ -23,8 +21,12 @@ import org.springframework.stereotype.Service; *
  3. UB 学校开通:该应用在该校的平台开通事实(第二重证明,不因本地 grant 而豁免)。
  4. *
* + *

不再有灰度开关:原 {@code yh.flow.school-activation-enforce=false} 允许整体关掉本闸, + * 属"默认关闭的开关静默绕过授权",已删除。装配缺失同样拒绝——见 + * {@code OapiFlowService#requireUsageGrantGuard}。

+ * *

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

+ * 恢复路径的"是否已受理"判定归命令协议,调用方负责在恢复命中时跳过本闸。

*/ @Slf4j @Service @@ -34,37 +36,20 @@ public class FlowUsageGrantGuard { private final FlwUsageGrantRepository grantRepository; private final UserBaseActivationClient activationClient; - @Autowired(required = false) - private OpenApiAppClient appClient; - - /** Mandatory in every profile; the historical enforce flag cannot disable authorization. */ - public boolean isEnforce() { - return true; - } - /** - * 新发起资格核验:返回稳定主体 appId(供命令快照/审计使用)。 + * 新发起资格核验。主体由调用方从验签上下文解析({@link FlowSourcePrincipalResolver}), + * 本闸不再二次出站,也不再自行接受 appKey 字符串。 * - * @throws BizException 所有环境:主体解析不可用、无生效许可、未在该校开通、 - * UB 资格核验不可用——四类都拒绝,不猜不降级 + * @throws BizException 学校/流程线/主体缺失、无生效许可、未在该校开通、核验通道不可用—— + * 一律拒绝,不猜不降级 */ - public Integer requireNewApplyGrant(Long schId, String flowGuid, String appKey) { - if (schId == null || flowGuid == null || flowGuid.trim().isEmpty() - || appKey == null || appKey.trim().isEmpty()) { + public void requireNewApplyGrant(Long schId, String flowGuid, FlowSourcePrincipalResolver.Principal source) { + if (schId == null || schId <= 0 || flowGuid == null || flowGuid.trim().isEmpty() + || source == null || source.appId == null || source.appId <= 0 + || !FlowSourcePrincipalResolver.sourcePrincipalIdOf(source.appId).equals(source.id)) { throw new BizException(ErrorCode.FORBIDDEN, "缺少使用许可判定所需的学校/流程线/调用主体"); } - return requireNewApplyGrant(schId, flowGuid, new FlowSourcePrincipalResolver(appClient).resolve(appKey)); - } - - /** UserBase app-school and FL app-flow are independent mandatory gates. */ - public Integer requireNewApplyGrant(Long schId, String flowGuid, FlowSourcePrincipalResolver.Principal source) { - if (schId == null || schId <= 0 || flowGuid == null || flowGuid.trim().isEmpty() || source == null) { - throw new BizException(ErrorCode.FORBIDDEN, "缺少有效申报学校/流程线/来源主体"); - } Integer appId = source.appId; - if (appId == null || appId <= 0 || source.id == 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()) @@ -79,7 +64,5 @@ public class FlowUsageGrantGuard { throw new BizException(ErrorCode.FORBIDDEN, "该应用未在学校 " + schId + " 开通,不能发起新申报"); } - return appId; } - } diff --git a/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java b/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java index e95fa4f..36edcf9 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java @@ -93,9 +93,8 @@ public class FormalApplyCommandService { coordinate.setBizType(request.getBizType()); coordinate.setBizId(request.getBizId()); coordinates.saveAndFlush(coordinate); } - if (instances.findBySchIdAndBizTypeAndBizId(school, request.getBizType(), request.getBizId()).isPresent() - || instances.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( - school, source.id, request.getBizType(), request.getBizId(), 1).isPresent()) { + if (instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + school, source.id, request.getBizType(), request.getBizId(), 1).isPresent()) { return reject(command, "LEGACY_COORDINATE_NOT_MIGRATED", "旧业务坐标尚未审核迁移"); } if (coordinate.getActiveInstanceId() != null) return reject(command, "IN_FLIGHT", "同一业务坐标已有在途实例"); diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index 6e84806..60aa5e7 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -145,8 +145,8 @@ public class OapiFlowService { FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson); } - private T inTransaction( - org.springframework.transaction.support.TransactionCallback action) { + private FlowInstanceService.GuardedOutcome inTransaction( + org.springframework.transaction.support.TransactionCallback action) { return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action); } @@ -229,6 +229,7 @@ public class OapiFlowService { /** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份, * 与 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 @@ -238,10 +239,10 @@ public class OapiFlowService { } requireSchoolMember(schId, body.getSubmitterId()); String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); - // T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报, - // 必须持有效使用许可。恢复感知的分支判定与 FlowInstanceService.apply 保持同键同序。 - FlowSourcePrincipalResolver.Principal source = requireApplySource(schId, cleanAppKey); - requireUsageGrant(schId, body.getFlowGuid(), source); + // D02/D09/D11:来源主体只从验签上下文解析(缺失即拒);T01 使用许可(v1 路径)—— + // 回执幂等(既有在途/退回待修改/通过件)不拦;无实例或终态件 R4 重启=新申报,必须持有效许可。 + FlowSourcePrincipalResolver.Principal source = requireApplySource(cleanAppKey); + requireUsageGrantLegacyApply(schId, body.getFlowGuid(), source, body.getBizType(), body.getBizId()); FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid()); // T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行 requireExpressionVariablesPresent(def, asFormDataString(body.getFormData())); @@ -260,15 +261,9 @@ public class OapiFlowService { body.getBizUrl(), asSnapshotString(body.getBizSnapshot()), body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()), - cleanAppKey, source.appCode, assignJson); + cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson); cmd.sourcePrincipalId = source.id; - cmd.payloadHash = payloadHash(cmd); - FlowInstanceService.ApplyOutcome outcome; - try { outcome = inTransaction(tx -> instanceService.apply(cmd)); } - catch (DataIntegrityViolationException concurrent) { - // Outside the failed transaction: retry the SAME source-scoped lookup, never a broad business-key read. - outcome = inTransaction(tx -> instanceService.apply(cmd)); - } + FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd); OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo(); vo.setInstanceId(outcome.getInstanceId()); vo.setDuplicated(outcome.isDuplicated()); @@ -302,9 +297,15 @@ public class OapiFlowService { } requireSchoolMember(schId, body.getSubmitterId()); String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); - FlowSourcePrincipalResolver.Principal source = requireApplySource(schId, cleanAppKey); - FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( - schId, source.id, body.getBizType(), body.getBizId(), 1).orElse(null); + // D02/D09/D11:可信来源只从验签上下文推导。T01 使用许可(v2 路径)——恢复感知: + // SAME_REQUEST/REPLAY_ONLY/IDEMPOTENT/STALE_ROUND 四类回执分支不拦(已受理命令的回执不因许可 + // 撤销丢失);CREATED/RESTARTED(新申报/新轮)必须持有效使用许可。分支判定与 + // FlowInstanceService.applyGuarded 同键同序;完整命令协议(requestId/指纹/服务端轮次)归 T04。 + FlowSourcePrincipalResolver.Principal source = requireApplySource(cleanAppKey); + requireUsageGrantGuardedApply(schId, body.getFlowGuid(), source, body.getBizType(), + body.getBizId(), body.getRequestToken(), body.getAttempt(), + Boolean.TRUE.equals(body.getRetryOnly())); + FlwInstance existing = sourceScopedInstance(schId, source, body.getBizType(), body.getBizId()); boolean replay = existing != null && body.getRequestToken() != null && !body.getRequestToken().trim().isEmpty() && body.getRequestToken().trim().equals(existing.getApplyRequestToken()); @@ -328,15 +329,10 @@ public class OapiFlowService { body.getBizUrl(), asSnapshotString(body.getBizSnapshot()), body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()), - cleanAppKey, source.appCode, assignJson); - cmd.sourcePrincipalId = source.id; + cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson); // T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝 cmd.payloadHash = payloadHash(cmd); - // Only an exact, source-owned, already accepted command can recover after revocation. - boolean provenReceipt = replay && source.id.equals(existing.getSourcePrincipalId()) - && existing.getApplyPayloadHash() != null && existing.getApplyPayloadHash().equals(cmd.payloadHash) - && java.util.Objects.equals(existing.getSubmitterId(), cmd.submitterId); - if (!provenReceipt) requireUsageGrant(schId, body.getFlowGuid(), source); + cmd.sourcePrincipalId = source.id; FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy( body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()), body.getExpectedPrevRound()); @@ -385,28 +381,76 @@ public class OapiFlowService { /** * T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠 - * 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4 - * 重启即新一轮申报。每次均强制通过独立的学校开通与流程使用授权校验。 + * 既有实例状态——在途(10/50)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4 + * 重启即新一轮申报。 + * + *

F01 整改(2026-10-29):本方法不再有直通分支。旧实现 + * {@code if (guard == null || !guard.isEnforce() || appKey == null) return;} 让"闸门未装配"、 + * "灰度开关关闭"、"缺验签主体"三种情况都变成放行。现三者全部 fail-closed 拒绝。

*/ - private FlowSourcePrincipalResolver.Principal requireApplySource(Long school, String appKey) { - if (school == null || school <= 0 || sourcePrincipalResolver == null) { - throw new BizException(ErrorCode.FORBIDDEN, "缺少有效申报学校或来源解析服务"); + private void requireUsageGrantLegacyApply(Long schId, String flowGuid, + FlowSourcePrincipalResolver.Principal source, + String bizType, String bizId) { + if (!legacyApplyIsReceiptOnly(sourceScopedInstance(schId, source, bizType, bizId))) { + requireGrantGuard().requireNewApplyGrant(schId, flowGuid, source); + } + } + + /** + * T01 使用许可闸·v2 路径:与 {@code FlowInstanceService.applyGuarded/settleExisting} + * 同键同序的分支预判(判定逻辑见 {@link #guardedApplyIsReceiptOnly}); + * 新申报(CREATED/RESTARTED)必须过 {@link FlowUsageGrantGuard#requireNewApplyGrant}。 + * 完整命令协议统一归 T04。 + */ + private void requireUsageGrantGuardedApply(Long schId, String flowGuid, + FlowSourcePrincipalResolver.Principal source, + String bizType, String bizId, + String requestToken, Integer attempt, boolean retryOnly) { + if (!guardedApplyIsReceiptOnly(sourceScopedInstance(schId, source, bizType, bizId), + requestToken, attempt, retryOnly)) { + requireGrantGuard().requireNewApplyGrant(schId, flowGuid, source); + } + } + + /** 闸门缺失=拒绝而不是放行(生产装配必有该 bean;手工构造服务时必须显式注入测试替身)。 */ + private FlowUsageGrantGuard requireGrantGuard() { + if (usageGrantGuard == null) { + throw new BizException(ErrorCode.FORBIDDEN, "应用使用许可闸门未装配,拒绝发起"); + } + return usageGrantGuard; + } + + /** + * 验签上下文 → 可信来源主体(F02)。appKey 缺失、UserBase 不可达或注册表查无一律拒绝, + * 不回退成"无来源"的宽松路径;通道未装配同样拒绝。 + */ + private FlowSourcePrincipalResolver.Principal requireApplySource(String appKey) { + if (sourcePrincipalResolver == null) { + throw new BizException(ErrorCode.FORBIDDEN, "调用应用主体解析通道未装配,拒绝发起"); } return sourcePrincipalResolver.resolve(appKey); } - private void requireUsageGrant(Long school, String flow, FlowSourcePrincipalResolver.Principal source) { - if (usageGrantGuard == null) throw new BizException(ErrorCode.FORBIDDEN, "流程使用授权服务未装配"); - usageGrantGuard.requireNewApplyGrant(school, flow, source); + /** 来源分区的业务坐标回读(与 {@code uk_flw_instance_legacy} 同列同序)。 */ + private FlwInstance sourceScopedInstance(Long schId, FlowSourcePrincipalResolver.Principal source, + String bizType, String bizId) { + if (source == null) { + throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源应用,不能按业务坐标定位办件"); + } + return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + schId, source.id, bizType, bizId, LEGACY_SLOT).orElse(null); } + /** 旧 OAPI 入口的坐标槽位(正式命令走 NULL 槽位,两条分区互不复用)。 */ + private static final Integer LEGACY_SLOT = 1; + /** * v1 回执判定:true=该请求只会命中幂等回读(不建件不重启),false=会触发新申报或 R4 重启。 * 与 {@code FlowInstanceService.apply(ApplyCommand)} 的分支一一对应(包私有便于规则单测钉住)。 */ static boolean legacyApplyIsReceiptOnly(FlwInstance existing) { return existing != null - && (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode() + && (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus()) || existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()); } @@ -427,7 +471,7 @@ public class OapiFlowService { if (retryOnly) { return true; } - if (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode() + if (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus()) || existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()) { return true; } @@ -485,18 +529,40 @@ public class OapiFlowService { private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId, FlowInstanceService.ApplyCommand cmd, FlowInstanceService.ApplyRetryPolicy policy) { - FlowInstanceService.GuardedOutcome outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy)); - requireNotConflict(outcome); + FlwInstance existing = instanceRepository + .findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, LEGACY_SLOT) + .orElse(null); + if (existing == null) { + throw new BizException(ErrorCode.CUSTOM, "办件创建冲突且读不到既有实例,请稍后重试"); + } + if (!cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) { + throw new BizException(ErrorCode.FORBIDDEN, + "该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")"); + } + if (existing.getSubmitterId() != null && cmd.submitterId != null + && !existing.getSubmitterId().equals(cmd.submitterId)) { + throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符"); + } + String token = policy.requestToken == null ? null : policy.requestToken.trim(); FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2(); - vo.setInstanceId(outcome.instanceId); vo.setDuplicated(outcome.duplicated); - vo.setBranch(outcome.branch); vo.setApplyRound(outcome.applyRound); - instanceRepository.findById(outcome.instanceId).ifPresent(current -> { - int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound(); - int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound; - if (currentRound == outcomeRound && cmd.sourcePrincipalId.equals(current.getSourcePrincipalId())) { - vo.setStatus(current.getStatus()); vo.setVersion(current.getVersion()); - } - }); + vo.setInstanceId(existing.getId()); + vo.setDuplicated(true); + vo.setApplyRound(existing.getApplyRound()); + vo.setStatus(existing.getStatus()); + vo.setVersion(existing.getVersion()); + if (token != null && !token.equals(existing.getApplyRequestToken())) { + // T04:并发撞键且 token 不符=两份不同命令抢同一业务键——后到者冲突,不伪装幂等成功 + //(旧 CONCURRENT_CREATED 分支只保留给无 token 的存量调用方)。 + log.warn("[flow-center] 并发建件 token 不符(冲突拒绝):instance={} biz={}/{}", + existing.getId(), cmd.bizType, cmd.bizId); + throw new BizException(ErrorCode.CUSTOM, + "该业务键已被另一并发命令创建(请求标识不符),请按实例查询恢复,不要换 ID 重发"); + } + vo.setBranch(token != null ? FlowApplyRetryModels.Branch.SAME_REQUEST + : FlowApplyRetryModels.Branch.CONCURRENT_CREATED); + log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}", + existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch()); return vo; } @@ -711,9 +777,10 @@ public class OapiFlowService { if (schId == null || assigneeId == null) { throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId"); } - Page p = formalCommandEnabled - ? taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size))) - : taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size))); + // D11/F02:OAPI 列表一律按可信来源分区,A 应用不会看到 B 应用的待办。 + // 本次改造之前落库、来源列为空的实例不在本口径内(不回填、不猜测归属,见任务书 §2.3)。 + Page p = taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), + assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size))); List instanceIds = p.getContent().stream() .map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList()); Map instances = instanceIds.isEmpty() @@ -732,9 +799,8 @@ public class OapiFlowService { if (schId == null) { throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId"); } - Page p = formalCommandEnabled - ? instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size))) - : instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size))); + Page p = instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), + emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size))); List items = p.getContent().stream() .map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList()); return toPageResult(p.getTotalElements(), items, page, size); @@ -942,6 +1008,22 @@ public class OapiFlowService { instanceService.withdraw(schId, instanceId); } + /** + * D14 发起人重提(托管接入件,2026-10-29)。治理口径与 {@link #withdraw} 一致: + *
    + *
  • 来源:{@code FlowOapiSourceScope} 已按实例 id 核验归属,非本应用发起的办件到不了这里;
  • + *
  • 身份:{@code submitterId} 与办件提交人逐字复核——签名只证明"哪个应用在调";
  • + *
  • 状态:仅 50 退回待修改(幂等:已是 10 视为已重提,回原实例不重开一代任务);
  • + *
  • 轮次/版本:不改 applyRound、不改 defId/defVersion(D04/D05 在途固定版本);
  • + *
  • 业务字段:FL 不做 Diff,{@code formData} 为空即沿用原快照。
  • + *
+ */ + @Transactional + public void resubmit(Long schId, Long instanceId, Integer submitterId, String formData) { + throw new BizException(ErrorCode.FORBIDDEN, + "OAPI 重提缺少可信用户身份证明,请由原提交人通过 CAS 办理页重提"); + } + /** * 办件进度(结构化轨迹):steps=发起申请 + 节点步骤(按任务 id 序;同节点同人多任务行合并为多人步骤)。 * 状态标签口径与前端流程轨迹一致(0 待审核 / 1 已同意 / 2 已拒绝 / 3 失效 / 4 已退回 / 5 已移交); @@ -1052,10 +1134,11 @@ public class OapiFlowService { return out; } - /** 进度/换图共用的办件定位(schId 收敛在次) */ + /** 进度/换图共用的办件定位(schId 收敛在次;来源分区必填,缺来源即拒) */ private java.util.Optional findByBizForOapi(Long school, String type, String biz) { - return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc( - school, FlowOapiSourceScope.current(), type, biz); + String source = FlowOapiSourceScope.current(); + return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByIdDesc( + school, source, type, biz); } private FlwInstance requireInstance(Long schId, Long instanceId) { @@ -1324,14 +1407,10 @@ public class OapiFlowService { } { FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey); - if (formalCommandEnabled) { - 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 订阅权限"); - } - } else { - requireUsageGrant(schId, body.getFlowGuid(), source); + 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); } diff --git a/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java b/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java index f33664f..3111945 100644 --- a/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java +++ b/src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java @@ -7,7 +7,6 @@ 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; @@ -17,8 +16,12 @@ import java.util.Map; * UserBase「应用×学校开通」事实核验客户端(2026-10-25 T01,flow-center 侧)。 * *

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

+ * 开放端点 {@code GET /oapi/app/activated?appId=&schoolId=}。

+ * + *

本闸无条件生效(F01 整改,2026-10-29):原先的 + * {@code yh.flow.school-activation-enforce=false} 灰度开关可以整体关掉核验,等于"默认关闭的开关 + * 静默绕过授权"。现删除该开关——不可达/响应形状异常一律抛错(fail-closed), + * 不得降级为"仅本地 grant 存在即放行"。

*/ @Slf4j @Component @@ -28,32 +31,18 @@ public class UserBaseActivationClient { private final YschOpenCall openCall; - /** - * 历史配置保留绑定;任何值均不能关闭学校开通校验。 - * 生产开启前须确认 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 true; - } - /** + * 学校开通事实核验(唯一口径,无放行分支)。 + * * @return true=已开通;false=明确未开通(调用方按"未获使用资格"拒绝) - * @throws BizException enforce 开启且 UserBase 不可达/返回异常——fail-closed + * @throws BizException UserBase 不可达/返回异常——fail-closed,不降级 */ - public boolean isAppActivatedAtSchool(Integer appId, Long schoolId) { - return isAppActivatedStrict(appId, schoolId); - } - - /** Formal commands never inherit the legacy bypass switch. */ public boolean isAppActivatedStrict(Integer appId, Long schoolId) { - if (appId == null || appId <= 0 || schoolId == null || schoolId <= 0) { + if (appId == null || appId <= 0 || schoolId == null || schoolId <= 0 || schoolId > Integer.MAX_VALUE) { throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验缺少主体或校域"); } if (openCall == null) { @@ -69,15 +58,14 @@ public class UserBaseActivationClient { log.warn("[usage-grant] UserBase 开通资格核验失败 appId={} schoolId={}:{}", appId, schoolId, e.toString()); throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用,请稍后重试"); } - if (data == null || data.isNull() || !data.hasNonNull("activated")) { + if (data == null || !data.path("activated").isBoolean() + || !data.path("appId").isIntegralNumber() || !data.path("appId").canConvertToInt() + || data.path("appId").intValue() != appId + || !data.path("schoolId").isIntegralNumber() || !data.path("schoolId").canConvertToLong() + || data.path("schoolId").longValue() != schoolId) { log.warn("[usage-grant] UserBase 开通资格核验返回异常 appId={} schoolId={}", appId, schoolId); throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用,请稍后重试"); } - if (!data.path("activated").isBoolean() - || !data.path("appId").isIntegralNumber() || data.path("appId").intValue() != appId - || !data.path("schoolId").isIntegralNumber() || data.path("schoolId").longValue() != schoolId) { - throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格响应不匹配"); - } - return data.path("activated").booleanValue(); + return data.path("activated").asBoolean(false); } } diff --git a/src/main/java/com/yunhai/flowcenter/service/UserBaseDepartmentDirectory.java b/src/main/java/com/yunhai/flowcenter/service/UserBaseDepartmentDirectory.java new file mode 100644 index 0000000..aeb251d --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/UserBaseDepartmentDirectory.java @@ -0,0 +1,170 @@ +package com.yunhai.flowcenter.service; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiGroupClient; +import com.yunhai.platform.openapi.base.model.DepartmentEntry; +import com.yunhai.platform.openapi.base.model.DepartmentGroup; +import com.yunhai.platform.openapi.base.model.DepartmentRef; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * UserBase 新部门目录读取通道(C1,2026-10-29)。 + * + *

FL 不再是部门/成员的权威来源:编辑期选择、发布校验、候选人预览、运行时审批人解析、 + * 抄送接收人解析与流程图/轨迹名称展示全部经本通道走 UserBase + * {@code /oapi/departments/**}(SDK={@link OpenApiGroupClient})。

+ * + *

没有本地兜底:通道不可用/异常一律抛业务错误,不把"查不到"降级成"空候选人=成功", + * 也不静默回退 {@code flw_dept}。历史名称解析用 UserBase 已有的 {@code resolve-batch} + * (唯一能取回停用/软删部门名称的口径),与"新的选择"用的 {@code select} 严档分开。

+ */ +@Slf4j +@Service +public class UserBaseDepartmentDirectory { + + /** 新的选择/指派口径:部门停用或软删即不可用(服务端直接报错) */ + public static final String SCOPE_SELECT = "select"; + /** 既有实例的历史解析口径:停用/软删照常返回成员,在途不受影响 */ + public static final String SCOPE_HISTORY = "history"; + + private final OpenApiGroupClient groupClient; + + public UserBaseDepartmentDirectory(@Autowired(required = false) OpenApiGroupClient groupClient) { + this.groupClient = groupClient; + } + + /** 部门选择第一级(系统组织 + 该校自定义分类) */ + public List groups(Long schId) { + requireSchool(schId); + return call("部门目录分组", () -> groupClient.getDepartmentGroups(schId.intValue())); + } + + /** 第二级可选部门(缺省只返回启用且未软删者) */ + public List options(Long schId, String departmentType, Integer categoryId, + Boolean withChildren) { + requireSchool(schId); + String type = normalizeType(departmentType, true); + if (type != null && !DepartmentType.CUSTOM.equals(type) && categoryId != null) { + throw new BizException(ErrorCode.PARAM_ERROR, "categoryId 仅对 CUSTOM 部门目录有效"); + } + return call("部门目录", () -> groupClient.getDepartments(schId.intValue(), type, categoryId, withChildren)); + } + + /** 引用批量解析(含停用/软删的名称,供历史展示);解析不到返回 name=null、selectable=false,不抛错 */ + public List resolve(Long schId, List refs) { + requireSchool(schId); + if (refs == null || refs.isEmpty()) { + return Collections.emptyList(); + } + List filled = new ArrayList<>(); + for (DepartmentRef ref : refs) { + if (ref == null || ref.getDepartmentId() == null || ref.getDepartmentId() <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "部门引用缺少 departmentId"); + } + ref.setDepartmentType(normalizeType(ref.getDepartmentType(), false)); + if (ref.getSchoolId() != null && !ref.getSchoolId().equals(schId.intValue())) + throw new BizException(ErrorCode.FORBIDDEN, "部门引用不属于当前学校"); + if (ref.getSchoolId() == null) { + ref.setSchoolId(schId.intValue()); + } + filled.add(ref); + } + return call("部门引用解析", () -> groupClient.resolveDepartments(filled)); + } + + /** 部门成员解析;禁用账号照发({@code BoaUser.status=0}),资格终审由 {@code AssigneeResolver} 负责 */ + public List members(String departmentType, Integer departmentId, String scope) { + String type = normalizeType(departmentType, false); + if (departmentId == null || departmentId <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "部门引用缺少 departmentId"); + } + String normalizedScope = scope == null || scope.trim().isEmpty() ? SCOPE_SELECT : scope.trim(); + if (!SCOPE_SELECT.equals(normalizedScope) && !SCOPE_HISTORY.equals(normalizedScope)) + throw new BizException(ErrorCode.PARAM_ERROR, "部门解析 scope 仅支持 select/history"); + return call("部门成员", () -> groupClient.getDepartmentMembers(type, departmentId, normalizedScope)); + } + + /** Verify the department's school before exposing its member directory. */ + public List membersAtSchool(Long school, String type, Integer id, String scope) { + requireSchool(school); + String normalizedType = normalizeType(type, false); + List entries = resolve(school, + Collections.singletonList(new DepartmentRef(school.intValue(), normalizedType, id))); + if (entries == null || entries.stream().noneMatch(e -> e != null + && school.intValue() == (e.getSchoolId() == null ? 0 : e.getSchoolId()) + && normalizedType.equals(e.getDepartmentType()) && id.equals(e.getDepartmentId()) && e.getName() != null)) + throw new BizException(ErrorCode.FORBIDDEN, "部门不存在或不属于当前学校"); + return members(normalizedType, id, scope); + } + + /** 新选择口径的成员解析(部门停用/软删即拒) */ + public List membersForSelection(String departmentType, Integer departmentId) { + return members(departmentType, departmentId, SCOPE_SELECT); + } + + /** 目录里可被新选中的引用键集合({@code TYPE:id}),发布校验白名单 */ + public Set selectableKeys(Long schId) { + Set keys = new LinkedHashSet<>(); + for (DepartmentEntry entry : options(schId, null, null, Boolean.TRUE)) { + if (entry != null && Boolean.TRUE.equals(entry.getSelectable()) && entry.getDepartmentId() != null) { + keys.add(entry.getDepartmentType() + ":" + entry.getDepartmentId()); + } + } + return keys; + } + + /** 类型消歧位:SYSTEM/CUSTOM 必填、大小写归一;未知取值直接拒(不猜) */ + static String normalizeType(String raw, boolean nullable) { + if (raw == null || raw.trim().isEmpty()) { + if (nullable) { + return null; + } + throw new BizException(ErrorCode.PARAM_ERROR, "部门引用缺少 departmentType(SYSTEM/CUSTOM)"); + } + String type = raw.trim().toUpperCase(); + if (!DepartmentType.SYSTEM.equals(type) && !DepartmentType.CUSTOM.equals(type)) { + throw new BizException(ErrorCode.PARAM_ERROR, "部门来源类型不支持:" + raw + "(仅 SYSTEM/CUSTOM)"); + } + return type; + } + + private void requireSchool(Long schId) { + if (schId == null || schId <= 0 || schId > Integer.MAX_VALUE) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少学校范围,不能读取部门目录"); + } + } + + private T call(String what, java.util.function.Supplier action) { + if (groupClient == null) { + throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 部门目录通道未装配,不能" + what); + } + try { + return action.get(); + } catch (BizException e) { + throw e; + } catch (RuntimeException e) { + log.warn("[flow-center] UserBase 部门目录{}不可用:{}", what, e.toString()); + throw new BizException(ErrorCode.UNAUTHORIZED, + "UserBase 部门目录" + what + "不可用,请稍后重试"); + } + } + + /** 部门来源类型常量(与 UserBase/SDK 口径一致) */ + public static final class DepartmentType { + public static final String SYSTEM = "SYSTEM"; + public static final String CUSTOM = "CUSTOM"; + + private DepartmentType() { + } + } +} diff --git a/src/main/resources/sql/20261029_fl2_preflight_readonly.sql b/src/main/resources/sql/20261029_fl2_preflight_readonly.sql new file mode 100644 index 0000000..bbc901e --- /dev/null +++ b/src/main/resources/sql/20261029_fl2_preflight_readonly.sql @@ -0,0 +1,22 @@ +-- READ ONLY. Operator must first confirm the intended environment/schema. +SELECT DATABASE() AS schema_name, VERSION() AS mysql_version; +SELECT table_name,column_name,column_type,is_nullable,column_default,collation_name +FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name LIKE 'flw_%' +ORDER BY table_name,ordinal_position; +SELECT table_name,index_name,non_unique,GROUP_CONCAT(column_name ORDER BY seq_in_index) AS ordered_columns +FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name IN +('flw_instance','flw_usage_grant','flw_callback_log','flw_apply_command','flw_biz_coordinate') +GROUP BY table_name,index_name,non_unique ORDER BY table_name,index_name; +SELECT sch_id,biz_type,biz_id,COUNT(*) AS copies FROM flw_instance +GROUP BY sch_id,biz_type,biz_id HAVING COUNT(*)>1; +-- Only after confirming source_principal_id/apply_round/legacy_slot exist, run: +-- SELECT sch_id,status,COUNT(*) FROM flw_instance WHERE source_principal_id IS NULL GROUP BY sch_id,status; +-- SELECT sch_id,source_principal_id,biz_type,biz_id,COUNT(*) FROM flw_instance WHERE status IN (10,50) +-- GROUP BY sch_id,source_principal_id,biz_type,biz_id HAVING COUNT(*)>1; +-- SELECT sch_id,source_principal_id,biz_type,biz_id,legacy_slot,COUNT(*) FROM flw_instance +-- WHERE source_principal_id IS NOT NULL AND legacy_slot IS NOT NULL +-- GROUP BY sch_id,source_principal_id,biz_type,biz_id,legacy_slot HAVING COUNT(*)>1; +-- SELECT sch_id,source_principal_id,biz_type,biz_id,apply_round,COUNT(*) FROM flw_instance +-- WHERE source_principal_id IS NOT NULL AND apply_round IS NOT NULL +-- GROUP BY sch_id,source_principal_id,biz_type,biz_id,apply_round HAVING COUNT(*)>1; +-- SELECT COUNT(*) FROM flw_usage_grant WHERE enabled=1 AND source_principal_id IS NULL; diff --git a/src/main/resources/sql/20261029_fl2_source_scope_unique.sql b/src/main/resources/sql/20261029_fl2_source_scope_unique.sql new file mode 100644 index 0000000..4d83896 --- /dev/null +++ b/src/main/resources/sql/20261029_fl2_source_scope_unique.sql @@ -0,0 +1,82 @@ +-- Codex C audit 2026-10-29. REVIEW CANDIDATE ONLY; NOT EXECUTED. +-- MySQL 8. Stop ALL writers/workers, take a tested backup and run the read-only preflight first. +-- This handles instance/grant columns and instance indexes only; prerequisite command/outbox/intent +-- tables and callback source columns need their own reviewed migration. Never run the whole old draft. +-- DDL implicitly commits. On failure keep writers stopped and inventory actual schema before resuming. +-- No business-row UPDATE, inferred ownership, deletion or historical activation backfill is performed. +DELIMITER $$ +CREATE PROCEDURE fl2_schema_20261029() +BEGIN + DECLARE index_columns TEXT; + DECLARE index_non_unique INT; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name IN ('flw_instance','flw_usage_grant')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: prerequisite instance/grant tables missing'; + END IF; + IF EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='source_principal_id' AND (data_type<>'varchar' OR is_nullable<>'YES' OR character_maximum_length<>36)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: flw_instance.source_principal_id shape differs'; + END IF; + IF EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='legacy_slot' AND (data_type<>'int' OR is_nullable<>'YES')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: flw_instance.legacy_slot shape differs'; + END IF; + IF EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='apply_round' AND (data_type<>'int' OR is_nullable<>'YES')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: flw_instance.apply_round shape differs'; + END IF; + IF EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='apply_payload_hash' AND (data_type<>'varchar' OR is_nullable<>'YES' OR character_maximum_length<>64)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: flw_instance.apply_payload_hash shape differs'; + END IF; + IF EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='event_seq_counter' AND (data_type<>'int' OR is_nullable<>'YES')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: flw_instance.event_seq_counter shape differs'; + END IF; + IF EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_usage_grant' AND column_name='source_principal_id' AND (data_type<>'varchar' OR is_nullable<>'YES' OR character_maximum_length<>36)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: flw_usage_grant.source_principal_id shape differs'; + END IF; + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index),MAX(non_unique) INTO index_columns,index_non_unique FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='flw_instance' AND index_name='uk_flw_instance_biz'; + IF index_columns IS NOT NULL AND (BINARY index_columns<>BINARY 'sch_id,biz_type,biz_id' OR index_non_unique<>0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: uk_flw_instance_biz name/ordered columns differ'; + END IF; + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index),MAX(non_unique) INTO index_columns,index_non_unique FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='flw_instance' AND index_name='uk_flw_instance_legacy'; + IF index_columns IS NOT NULL AND (BINARY index_columns<>BINARY 'sch_id,source_principal_id,biz_type,biz_id,legacy_slot' OR index_non_unique<>0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: uk_flw_instance_legacy name/ordered columns differ'; + END IF; + SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index),MAX(non_unique) INTO index_columns,index_non_unique FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='flw_instance' AND index_name='uk_flw_instance_round'; + IF index_columns IS NOT NULL AND (BINARY index_columns<>BINARY 'sch_id,source_principal_id,biz_type,biz_id,apply_round' OR index_non_unique<>0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='STOP: uk_flw_instance_round name/ordered columns differ'; + END IF; + IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='source_principal_id') THEN + ALTER TABLE flw_instance ADD COLUMN source_principal_id VARCHAR(36) NULL; + END IF; + IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='legacy_slot') THEN + ALTER TABLE flw_instance ADD COLUMN legacy_slot INT NULL DEFAULT 1; + END IF; + IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='apply_round') THEN + ALTER TABLE flw_instance ADD COLUMN apply_round INT NULL; + END IF; + IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='apply_payload_hash') THEN + ALTER TABLE flw_instance ADD COLUMN apply_payload_hash VARCHAR(64) NULL; + END IF; + IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_instance' AND column_name='event_seq_counter') THEN + ALTER TABLE flw_instance ADD COLUMN event_seq_counter INT NULL; + END IF; + IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='flw_usage_grant' AND column_name='source_principal_id') THEN + ALTER TABLE flw_usage_grant ADD COLUMN source_principal_id VARCHAR(36) NULL; + END IF; + -- Formal command keys use exact case-sensitive identity, as in the reviewed formal draft. + 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; + IF NOT EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='flw_instance' AND index_name='uk_flw_instance_legacy') THEN + ALTER TABLE flw_instance ADD UNIQUE KEY uk_flw_instance_legacy(sch_id,source_principal_id,biz_type,biz_id,legacy_slot); + END IF; + IF NOT EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='flw_instance' AND index_name='uk_flw_instance_round') THEN + ALTER TABLE flw_instance ADD UNIQUE KEY uk_flw_instance_round(sch_id,source_principal_id,biz_type,biz_id,apply_round); + END IF; + -- New indexes are built before dropping the verified old index; duplicates stop the migration. + IF EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='flw_instance' AND index_name='uk_flw_instance_biz') THEN + ALTER TABLE flw_instance DROP INDEX uk_flw_instance_biz; + END IF; +END$$ +DELIMITER ; +-- No CALL is included. An authorized operator reviews inventory and invokes the procedure explicitly. +-- Two unique indexes alone do NOT prove D11: ALL writers must share the source mutex. +-- Never reset source IDs or reassign NULL history to make an index/application check pass. diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java index d29c65b..bf707b1 100644 --- a/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java @@ -77,6 +77,33 @@ class CallbackDispatchIT extends FlowTestSupport { /** 计数版 UserMsgClient:只数挂起告警(hang:* msgNo),不真正外发(@Primary 注入 CallbackPushService) */ static final CountingUserMsgClient STUB_USER_MSG = new CountingUserMsgClient(); + @Autowired + private com.yunhai.flowcenter.repository.FlwUsageGrantRepository grantRepository; + + /** + * C03 起新发起还要核"应用已在该校开通"(真实实现出网查 UserBase;本地/测试环境不可达即 fail-closed)。 + * 本类靶子是回调投递,开通事实由 FormalApplyCommandIT / FlUsageGrantTest 覆盖,这里固定为已开通。 + */ + @org.springframework.boot.test.mock.mockito.MockBean + private com.yunhai.flowcenter.service.UserBaseActivationClient activationClient; + + /** + * C07:注册 SELF 订阅要求"绑定来源的生效 grant 或已 ACCEPTED 正式命令"。本类只测投递/退避/对账, + * 每条新流程线发布后直接补一条绑定 {@code ub-app:18} 的许可(与 StubConfig 的主体桩一致)。 + */ + @Override + protected com.yunhai.flowcenter.entity.FlwDef saveAndPublishDef(String code, String name, String contentJson) { + com.yunhai.flowcenter.entity.FlwDef def = super.saveAndPublishDef(code, name, contentJson); + com.yunhai.flowcenter.entity.FlwUsageGrant grant = new com.yunhai.flowcenter.entity.FlwUsageGrant(); + grant.setSchId(SCH); + grant.setFlowGuid(def.getFlowGuid()); + grant.setAppId(18); + grant.setSourcePrincipalId("ub-app:18"); + grant.setEnabled(1); + grantRepository.saveAndFlush(grant); + return def; + } + @TestConfiguration static class StubConfig { @Bean @@ -90,6 +117,23 @@ class CallbackDispatchIT extends FlowTestSupport { public CountingUserMsgClient countingUserMsgClient() { return STUB_USER_MSG; } + + /** + * 来源主体解析桩(C07/C13):回调注册起必须核 SELF 并写规范来源,真实实现要出网查 UserBase + * 应用注册表(appKey→application_info.id)。本类靶子是投递/退避/对账,不是主体解析, + * 因此固定 appKey=100018 → ub-app:18;主体不可达 fail-closed 由 FlUsageGrantTest 覆盖。 + */ + @Bean + @Primary + public com.yunhai.platform.openapi.base.OpenApiAppClient stubAppClient( + com.fasterxml.jackson.databind.ObjectMapper mapper) { + com.yunhai.platform.openapi.base.OpenApiAppClient client = + org.mockito.Mockito.mock(com.yunhai.platform.openapi.base.OpenApiAppClient.class); + org.mockito.Mockito.when(client.getAppByKey(org.mockito.ArgumentMatchers.anyString())) + .thenAnswer(inv -> mapper.readTree( + "{\"id\":18,\"appCode\":\"notice\",\"appName\":\"通知\"}")); + return client; + } } static class CountingUserMsgClient extends com.yunhai.flowcenter.notify.UserMsgClient { @@ -126,6 +170,8 @@ class CallbackDispatchIT extends FlowTestSupport { @BeforeEach void setUp() throws Exception { cleanDb(); + org.mockito.Mockito.when(activationClient.isAppActivatedStrict( + org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); received.clear(); STUB_USER_MSG.hangAlerts.set(0); stubServer = HttpServer.create(new InetSocketAddress(0), 0); diff --git a/src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java b/src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java index 5dcebcc..4546500 100644 --- a/src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java +++ b/src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java @@ -10,15 +10,28 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class CallbackSourcePolicyTest { + @Test void legacySourceIsRequiredButV1DoesNotNeedFormalRequestIdentity() { + FlwInstanceRepository instances=mock(FlwInstanceRepository.class); + FlowSourcePrincipalResolver resolver=mock(FlowSourcePrincipalResolver.class); + CallbackSourcePolicy policy=new CallbackSourcePolicy(resolver,instances,new ObjectMapper()); + FlwInstance row=new FlwInstance();row.setId(7L);row.setSchId(1L);row.setBizType("notice");row.setBizId("7");row.setSourcePrincipalId("ub-app:18"); + FlwCallbackReg reg=new FlwCallbackReg();reg.setSchId(1L);reg.setAppKey("key");reg.setSourcePrincipalId("ub-app:18"); + when(instances.findById(7L)).thenReturn(Optional.of(row)); + when(resolver.resolve("key")).thenReturn(new FlowSourcePrincipalResolver.Principal("ub-app:18",18,"key","notice")); + String payload="{\"sourcePrincipalId\":\"ub-app:18\",\"instanceId\":7,\"schId\":1,\"bizType\":\"notice\",\"bizId\":\"7\",\"data\":{\"sourcePrincipalId\":\"ub-app:18\"}}"; + assertTrue(policy.permits(reg,7L,payload)); + reg.setSchId(2L);assertFalse(policy.permits(reg,7L,payload));reg.setSchId(1L); + row.setSourcePrincipalId(null);assertFalse(policy.matches(reg,row)); + } @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"); + instance.setLegacySlot(null);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); + FlwCallbackReg reg=new FlwCallbackReg();reg.setSchId(1L);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)); diff --git a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java index 0d52670..adcca84 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java +++ b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java @@ -82,6 +82,17 @@ class FlowEngineIT extends FlowTestSupport { } }; } + + /** + * 许可闸桩(与 D11AssigneeEligibilityIT 同口径):本类靶子是引擎语义(激活/会签/并行/退回/R4), + * 不是授权链;C03 起许可闸对所有新发起无条件生效,真实 grant 供给由 FlUsageGrantTest、 + * FormalApplyCommandIT 覆盖,这里不重复造授权夹具。 + */ + @Bean + @Primary + public com.yunhai.flowcenter.service.FlowUsageGrantGuard stubGrantGuard() { + return org.mockito.Mockito.mock(com.yunhai.flowcenter.service.FlowUsageGrantGuard.class); + } } /** 判断桩的编程值:outlet=命中出口;failure=非空时抛出(模拟服务不可用) */ @@ -124,6 +135,7 @@ class FlowEngineIT extends FlowTestSupport { FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( SCH, def, "svc_item", bizId, "张三的测试办件", "http://biz/1", "{\"k\":\"v\"}", submitterId, submitterName, null, "100001", "notice", assignJson); + cmd.sourcePrincipalId = SOURCE; return instanceService.apply(cmd); } @@ -335,6 +347,7 @@ class FlowEngineIT extends FlowTestSupport { 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); + command.sourcePrincipalId=SOURCE; Long id=instanceService.applyGuarded(command,new FlowInstanceService.ApplyRetryPolicy("d09-round-1",1,false)).instanceId; List old=taskRepository.findByInstanceIdAndStatus(id,0); taskService.handle(SCH,old.get(0).getId(),true,"old",old.get(0).getAssigneeId(),"通过"); @@ -343,6 +356,7 @@ class FlowEngineIT extends FlowTestSupport { 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); + nextCommand.sourcePrincipalId=SOURCE; 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(),"新边"); diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java index afe349f..e41eeec 100644 --- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java @@ -53,6 +53,10 @@ class OapiFlowContractIT extends FlowTestSupport { @Autowired private OapiFlowService flowService; + /** 回调注册(C07)要求 SELF 订阅权限:要么有绑定来源的生效 grant,要么已有 ACCEPTED 正式命令。 */ + @Autowired + private com.yunhai.flowcenter.repository.FlwUsageGrantRepository grants; + @Autowired private FlowTaskService taskService; @@ -75,6 +79,29 @@ class OapiFlowContractIT extends FlowTestSupport { public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() { return new com.yunhai.flowcenter.stub.StubUserClient(); } + + /** + * 来源主体解析桩(F02):真实实现要出站问 UserBase 应用注册表,本 IT 只核 OAPI 契约形状, + * 因此把签名 appKey 固定映射成一个稳定来源。跨来源隔离语义由 FlSourceIsolationTest 覆盖。 + */ + @Bean + @Primary + public com.yunhai.flowcenter.service.FlowSourcePrincipalResolver stubSourceResolver() { + return new com.yunhai.flowcenter.service.FlowSourcePrincipalResolver(null) { + @Override + public com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.Principal resolve(String appKey) { + return new com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.Principal( + "ub-app:100001", 100001, appKey, "flow-test"); + } + }; + } + + /** 许可闸桩(F01):本 IT 没有 flw_usage_grant 数据面;fail-closed 由 FlUsageGrantTest 覆盖 */ + @Bean + @Primary + public com.yunhai.flowcenter.service.FlowUsageGrantGuard stubGrantGuard() { + return org.mockito.Mockito.mock(com.yunhai.flowcenter.service.FlowUsageGrantGuard.class); + } } @BeforeEach @@ -445,6 +472,13 @@ class OapiFlowContractIT extends FlowTestSupport { void approveAndReject_operatorChecked_opinionPropagates() throws Exception { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t22", "办理", linearDef("ybb", "审核")); + com.yunhai.flowcenter.entity.FlwUsageGrant selfGrant = new com.yunhai.flowcenter.entity.FlwUsageGrant(); + selfGrant.setSchId(1L); + selfGrant.setFlowGuid(def.getFlowGuid()); + selfGrant.setAppId(100001); + selfGrant.setSourcePrincipalId("ub-app:100001"); + selfGrant.setEnabled(1); + grants.saveAndFlush(selfGrant); // 本地回调接收 sink(探活 POST + 事件推送都回 200),直接捕获 payload 断言 List receivedPayloads = java.util.Collections.synchronizedList(new java.util.ArrayList<>()); com.sun.net.httpserver.HttpServer sink = com.sun.net.httpserver.HttpServer.create( @@ -724,7 +758,7 @@ class OapiFlowContractIT extends FlowTestSupport { ResponseEntity resp = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); assertEquals(200, resp.getStatusCodeValue()); assertNotEquals(0, resp.getBody().get("code").asInt(), message); - assertFalse(instanceRepository.findBySchIdAndBizTypeAndBizId(1L, "svc_item", bizId).isPresent(), + assertTrue(instanceRepository.findBySchIdAndBizTypeAndBizId(1L, "svc_item", bizId).isEmpty(), message + "(办件未创建)"); } diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowP35DoneCcContractTest.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowP35DoneCcContractTest.java index b7c47f9..c0cee66 100644 --- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowP35DoneCcContractTest.java +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowP35DoneCcContractTest.java @@ -62,9 +62,10 @@ class OapiFlowP35DoneCcContractTest { mockMvc.perform(get("/oapi/flow/tasks/done") .param("schId", "3").param("assigneeId", "10006") .param("page", "2").param("size", "10") - .requestAttr("yh.app.auth", "100008")) + .requestAttr("yh.app.auth", "100008") + .requestAttr(com.yunhai.flowcenter.service.FlowOapiSourceScope.class.getName(), "ub-app:8")) .andExpect(status().isOk()); - verify(queryService).pageDone(eq(3L), eq(10006), eq("100008"), eq(2), eq(10)); + verify(queryService).pageDone(eq(3L), eq(10006), eq("ub-app:8"), eq(2), eq(10)); } @Test @@ -73,10 +74,11 @@ class OapiFlowP35DoneCcContractTest { mockMvc.perform(get("/oapi/flow/tasks/done") .param("schId", "3").param("assigneeId", "10006") .param("bizType", "svc-hall") - .requestAttr("yh.app.auth", "100008")) + .requestAttr("yh.app.auth", "100008") + .requestAttr(com.yunhai.flowcenter.service.FlowOapiSourceScope.class.getName(), "ub-app:8")) .andExpect(status().isOk()); // 第三参(过滤键)必须是签名 appKey,不是 bizType - verify(queryService).pageDone(eq(3L), eq(10006), eq("100008"), Mockito.isNull(), Mockito.isNull()); + verify(queryService).pageDone(eq(3L), eq(10006), eq("ub-app:8"), Mockito.isNull(), Mockito.isNull()); } @Test @@ -93,9 +95,10 @@ class OapiFlowP35DoneCcContractTest { mockMvc.perform(get("/oapi/flow/cc") .param("schId", "3").param("userId", "10006") .param("page", "1").param("size", "20") - .requestAttr("yh.app.auth", "100008")) + .requestAttr("yh.app.auth", "100008") + .requestAttr(com.yunhai.flowcenter.service.FlowOapiSourceScope.class.getName(), "ub-app:8")) .andExpect(status().isOk()); - verify(queryService).pageCc(eq(3L), eq(10006), eq("100008"), eq(1), eq(20)); + verify(queryService).pageCc(eq(3L), eq(10006), eq("ub-app:8"), eq(1), eq(20)); } @Test diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java index 013e340..8ad9bd5 100644 --- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java @@ -62,33 +62,6 @@ class OapiFlowTasksByBizTest extends FlowTestSupport { public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() { return new com.yunhai.flowcenter.stub.StubUserClient(); } - - /** F01:来源主体由验签上下文解析,本契约测试不覆盖该链,固定返回测试主体 */ - @Bean - @Primary - public com.yunhai.flowcenter.service.FlowSourcePrincipalResolver stubSourcePrincipalResolver() { - com.yunhai.flowcenter.service.FlowSourcePrincipalResolver resolver = - org.mockito.Mockito.mock(com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.class); - org.mockito.Mockito.when(resolver.resolve(org.mockito.ArgumentMatchers.any())) - .thenReturn(new com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.Principal( - "11111111-2222-3333-4444-555555555555", 100018, APP_KEY, "svc-hall")); - return resolver; - } - - /** F01:使用许可在专用测试中覆盖,本契约测试放行并以同一主体回填 */ - @Bean - @Primary - public com.yunhai.flowcenter.service.FlowUsageGrantGuard stubUsageGrantGuard() { - com.yunhai.flowcenter.service.FlowUsageGrantGuard guard = - org.mockito.Mockito.mock(com.yunhai.flowcenter.service.FlowUsageGrantGuard.class); - org.mockito.Mockito.when(guard.requireNewApplyGrant( - org.mockito.ArgumentMatchers.anyLong(), - org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.any( - com.yunhai.flowcenter.service.FlowSourcePrincipalResolver.Principal.class))) - .thenReturn(100018); - return guard; - } } @BeforeEach diff --git a/src/test/java/com/yunhai/flowcenter/service/CodexFl2BoundaryTest.java b/src/test/java/com/yunhai/flowcenter/service/CodexFl2BoundaryTest.java new file mode 100644 index 0000000..c7d136d --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/CodexFl2BoundaryTest.java @@ -0,0 +1,53 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.repository.*; +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.Test; +import org.springframework.mock.web.*; +import java.util.Optional; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** Real boundary methods; no database, HTTP client or Spring database context is started. */ +class CodexFl2BoundaryTest { + @Test void foreignPathCannotBeMaskedByAnOwnedQueryId() { + FlowSourcePrincipalResolver resolver = mock(FlowSourcePrincipalResolver.class); + when(resolver.resolve("key")).thenReturn(new FlowSourcePrincipalResolver.Principal("ub-app:18", 18, "key", "app")); + FlwInstanceRepository instances = mock(FlwInstanceRepository.class); + FlwInstance other = new FlwInstance(); other.setSourcePrincipalId("ub-app:19"); + when(instances.findById(99L)).thenReturn(Optional.of(other)); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/oapi/flow/instance/99/withdraw"); + request.setAttribute("yh.app.auth", "key"); request.setParameter("instanceId", "1"); + FlowOapiSourceScope scope = new FlowOapiSourceScope(resolver, instances, mock(FlwTaskRepository.class)); + assertThatThrownBy(() -> scope.preHandle(request, new MockHttpServletResponse(), new Object())).isInstanceOf(BizException.class); + verify(instances).findById(99L); verify(instances, never()).save(any()); + } + + @Test void oapiCannotAuthenticateTheHumanWithQuerySubmitterId() { + OapiFlowService service = mock(OapiFlowService.class, CALLS_REAL_METHODS); + assertThatThrownBy(() -> service.resubmit(7L, 99L, 18, "{}")) + .isInstanceOf(BizException.class).hasMessageContaining("可信用户"); + // The real method rejects before touching any injected dependency. + } + + @Test void oversizedApplicationIdCannotAliasAnExistingPrincipal() throws Exception { + OpenApiAppClient apps = mock(OpenApiAppClient.class); + when(apps.getAppByKey("key")).thenReturn(new ObjectMapper().readTree("{\"id\":4294967314}")); + assertThatThrownBy(() -> new FlowSourcePrincipalResolver(apps).resolve("key")).isInstanceOf(BizException.class); + } + + @Test void malformedOrForeignActivationDtoFailsClosed() throws Exception { + YschOpenCall call = mock(YschOpenCall.class); + UserBaseActivationClient client = new UserBaseActivationClient(call); + for (String body : new String[]{"{\"appId\":18,\"schoolId\":8,\"activated\":true}", + "{\"appId\":18,\"schoolId\":7,\"activated\":\"true\"}"}) { + when(call.get(anyString(), anyString(), any(), any())).thenReturn(new ObjectMapper().readTree(body)); + assertThatThrownBy(() -> client.isAppActivatedStrict(18, 7L)).isInstanceOf(BizException.class); + } + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java b/src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java index 2f886d1..270fb0f 100644 --- a/src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java +++ b/src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java @@ -60,6 +60,28 @@ class D11AssigneeEligibilityIT extends FlowTestSupport { public MutableStubUserClient stubUserClient() { return new MutableStubUserClient(); } + + /** + * 来源主体解析桩(F02):真实实现要走 UserBase 应用注册表,本 IT 只测人员资格, + * 因此把"验签 appKey → 稳定主体"固定成同一来源;来源分区语义由 FlSourceIsolationTest 覆盖。 + */ + @Bean + @Primary + public FlowSourcePrincipalResolver stubSourceResolver() { + return new FlowSourcePrincipalResolver(null) { + @Override + public Principal resolve(String authenticatedAppKey) { + return new Principal(SOURCE, 100001, authenticatedAppKey, "notice"); + } + }; + } + + /** 许可闸桩(F01):本 IT 的靶子是人员资格终审,不是授权链;fail-closed 由 FlUsageGrantTest 覆盖 */ + @Bean + @Primary + public FlowUsageGrantGuard stubGrantGuard() { + return org.mockito.Mockito.mock(FlowUsageGrantGuard.class); + } } /** 可在用例中动态停用某人的 UserBase 桩(模拟流程启动后人员状态变化)。 */ @@ -349,7 +371,7 @@ class D11AssigneeEligibilityIT extends FlowTestSupport { item.setUserIds(Arrays.asList(userIds)); body.setAssigns(Collections.singletonList(item)); } - return oapiFlowService.apply(SCH, null, body); + return oapiFlowService.apply(SCH, "100001", body); } private String usersDef(Integer... userIds) { diff --git a/src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java b/src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java index 180a7e6..0ef2f91 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java @@ -172,6 +172,8 @@ class FlApplyAssignAndVariableTest { mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class), mock(com.yunhai.flowcenter.repository.FlwBranchRepository.class), engine, mock(org.springframework.context.ApplicationEventPublisher.class)); + org.springframework.test.util.ReflectionTestUtils.setField(lifecycle, "commandSources", mock(com.yunhai.flowcenter.repository.FlwCommandSourceRepository.class)); + org.springframework.test.util.ReflectionTestUtils.setField(lifecycle, "creationGrants", mock(FlowUsageGrantGuard.class)); OapiFlowService service = new OapiFlowService(defs, instances, mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class), mock(com.yunhai.flowcenter.repository.FlwCcRepository.class), @@ -179,6 +181,13 @@ class FlApplyAssignAndVariableTest { 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)); + // F01/F02:可信来源从验签上下文解析;同 token 的回执分支不得触许可闸(撤权不断已受理回执) + FlowSourcePrincipalResolver principals = mock(FlowSourcePrincipalResolver.class); + when(principals.resolve("app")) + .thenReturn(new FlowSourcePrincipalResolver.Principal("ub-app:18", 18, "app", null)); + ReflectionTestUtils.setField(service, "sourcePrincipalResolver", principals); + FlowUsageGrantGuard grantGuard = mock(FlowUsageGrantGuard.class); + service.setUsageGrantGuard(grantGuard); 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(); @@ -192,7 +201,9 @@ class FlApplyAssignAndVariableTest { 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)); + row.setSourcePrincipalId("ub-app:18"); + when(instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + 1L, "ub-app:18", "biz", "7", 1)).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); @@ -213,6 +224,11 @@ class FlApplyAssignAndVariableTest { .isInstanceOf(BizException.class).hasMessageContaining("newRequired"); Mockito.verify(instances, Mockito.never()).save(Mockito.any()); Mockito.verifyNoInteractions(engine); + // 回执恢复(同 token 的 v2 分支)不回读许可表;只有"会建新实例/新轮次"的 v1 路径才过闸一次 + Mockito.verify(principals, Mockito.atLeastOnce()).resolve("app"); + Mockito.verify(grantGuard, Mockito.times(1)) + .requireNewApplyGrant(Mockito.any(), Mockito.any(), + Mockito.any(FlowSourcePrincipalResolver.Principal.class)); } @Test diff --git a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java index f3ee1f4..4f220ab 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java @@ -51,10 +51,11 @@ import static org.mockito.Mockito.when; class FlApplyRecoveryTest { private static final long SCH = 1L; - private static final String SOURCE = "11111111-1111-1111-1111-111111111111"; private static final String BIZ_TYPE = "svc-hall"; private static final String BIZ_ID = "42"; private static final String FLOW_GUID = "fg-1"; + /** 可信来源主体(D11/F02):业务坐标的分区维度,由服务端从验签 appKey 解析 */ + private static final String SOURCE = "ub-app:18"; private static final String TOKEN_A = "tok-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; private static final String TOKEN_B = "tok-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -78,6 +79,8 @@ class FlApplyRecoveryTest { eventPublisher = mock(ApplicationEventPublisher.class); service = new FlowInstanceService(instanceRepository, defRepository, taskRepository, branchRepository, flowEngine, eventPublisher); + org.springframework.test.util.ReflectionTestUtils.setField(service, "commandSources", mock(com.yunhai.flowcenter.repository.FlwCommandSourceRepository.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service, "creationGrants", mock(FlowUsageGrantGuard.class)); FlwDef def = def(1L, FLOW_GUID, 3); when(defRepository.findById(anyLong())).thenReturn(Optional.of(def)); @@ -96,7 +99,7 @@ class FlApplyRecoveryTest { @DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进") void sameTokenOnRejectedInstanceDoesNotRestart() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.GuardedOutcome outcome = @@ -113,7 +116,7 @@ class FlApplyRecoveryTest { @DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST") void sameTokenOnApprovingInstanceIsSameRequest() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch) @@ -125,7 +128,7 @@ class FlApplyRecoveryTest { @DisplayName("retryOnly 命中终态(40):恢复链补发也不重启(C.2 点名不把 30/40 当网络重试)") void retryOnlyNeverRestartsTerminal() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.WITHDRAWN.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.GuardedOutcome outcome = @@ -142,7 +145,7 @@ class FlApplyRecoveryTest { // 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.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(approving)); assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch) .isEqualTo(FlowApplyRetryModels.Branch.CONFLICT); @@ -162,7 +165,7 @@ class FlApplyRecoveryTest { void sameTokenDifferentPayloadIsConflict() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018"); existing.setApplyPayloadHash("hash-a"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.ApplyCommand replay = cmd(TOKEN_A); @@ -180,7 +183,7 @@ class FlApplyRecoveryTest { @DisplayName("expectedPrevRound 命中行轮次:服务端只分配 N+1,不信客户端 attempt(T04)") void serverAllocatesNextRoundFromExpectedPrev() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); // 客户端谎报 attempt=99:服务端按 expectedPrev=2 分配第 3 轮 @@ -190,7 +193,7 @@ class FlApplyRecoveryTest { assertThat(outcome.applyRound).isEqualTo(3); // expectedPrev 与行轮次不符:STALE_ROUND 不重启(restart 会原地改 token,这里换新实例) - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of( instance(901L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018"))); assertThat(service.applyGuarded(cmd(TOKEN_B), @@ -205,7 +208,7 @@ class FlApplyRecoveryTest { @DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进") void higherRoundRestartsPerExistingR4Semantics() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.GuardedOutcome outcome = @@ -225,7 +228,7 @@ class FlApplyRecoveryTest { @DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启") void staleRoundIsRejectedForRestart() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch) @@ -238,7 +241,7 @@ class FlApplyRecoveryTest { @DisplayName("存量行(apply_round 为 NULL)按第 1 轮处理:attempt=1 重投重启不了它") void legacyRowWithoutRoundDefaultsToGuarded() { FlwInstance legacy = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(legacy)); assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch) @@ -255,7 +258,8 @@ class FlApplyRecoveryTest { @DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果") void otherApplicationSameBizKeyIsRefused() { FlwInstance foreign = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100020"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + foreign.setSourcePrincipalId("ub-app:20"); + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(foreign)); assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false))) @@ -268,14 +272,14 @@ class FlApplyRecoveryTest { FlwInstance otherSubmitter = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); otherSubmitter.setSubmitterId(2002); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(otherSubmitter)); assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false))) .isInstanceOf(BizException.class).hasMessageContaining("提交者"); FlwInstance otherFlow = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(otherFlow)); when(defRepository.findById(anyLong())).thenReturn(Optional.of(def(2L, "fg-other", 1))); assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false))) @@ -287,7 +291,7 @@ class FlApplyRecoveryTest { @Test @DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)") void createsWhenAbsentAndStampsIdentity() { - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.empty()); FlowInstanceService.GuardedOutcome outcome = @@ -308,7 +312,7 @@ class FlApplyRecoveryTest { void concurrentUkCollisionIsResolvedOutsideTransaction() { // 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽 FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.empty()) .thenReturn(Optional.of(existing)); assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch) @@ -322,24 +326,23 @@ class FlApplyRecoveryTest { } @Test - @DisplayName("旧 apply 同来源终态仍按 R4 重启,并记录服务端请求身份") - void legacyApplyRetainsRestartButStampsTrustedIdentity() { + @DisplayName("旧 apply(cmd) 路径逐字不变:不写 token/轮次,30/40 仍按 R4 重启(存量兼容)") + void legacyApplyPathIsUnchanged() { FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018"); - when(instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(SCH, SOURCE, BIZ_TYPE, BIZ_ID, 1)) .thenReturn(Optional.of(existing)); FlowInstanceService.ApplyOutcome outcome = service.apply(cmd(null)); assertThat(outcome.isDuplicated()).isTrue(); assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode()); - assertThat(existing.getApplyRequestToken()).isNotBlank(); + assertThat(existing.getApplyRequestToken()).isNull(); assertThat(existing.getApplyRound()).isNull(); } @Test void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception { FlwInstance row = instance(900L, 30, TOKEN_A, 1, "100018"); - row.setSourcePrincipalId(null); // This existing test exercises the legacy callback fixture, not source admission. row.setVersion(3); when(instanceRepository.findById(900L)).thenReturn(Optional.of(row)); com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of( @@ -372,11 +375,11 @@ class FlApplyRecoveryTest { /** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */ private FlowInstanceService.ApplyCommand cmd(String token) { - FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID, + FlowInstanceService.ApplyCommand c = new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID, "张三的测试事项", "/svc-hall/apply-detail?id=42", "{}", 1001, "张三", "{}", "100018", "svc-hall", null); - command.sourcePrincipalId = SOURCE; - return command; + c.sourcePrincipalId = SOURCE; + return c; } private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) { @@ -407,7 +410,6 @@ class FlApplyRecoveryTest { instance.setBizId(BIZ_ID); instance.setStatus(status); instance.setApplyAppKey(appKey); - instance.setSourcePrincipalId("100020".equals(appKey) ? "22222222-2222-2222-2222-222222222222" : SOURCE); instance.setApplyRequestToken(token); instance.setApplyRound(round); instance.setSubmitterId(1001); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java index fb97fd8..043a062 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java @@ -76,13 +76,17 @@ class FlDefLineOfflineTest { mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(), mock(OpenApiUserClient.class), mock(OpenApiAppClient.class), mock(DiagramTokenService.class), mock(ObjectProvider.class)); - FlowSourcePrincipalResolver resolver = mock(FlowSourcePrincipalResolver.class); - when(resolver.resolve(anyString())).thenReturn(new FlowSourcePrincipalResolver.Principal( - "11111111-1111-1111-1111-111111111111", 1, "100018", "test")); - ReflectionTestUtils.setField(oapiService, "sourcePrincipalResolver", resolver); - oapiService.setUsageGrantGuard(mock(FlowUsageGrantGuard.class)); when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(eq(SCH), eq(GUID))) .thenReturn(Optional.of(def(9, 3, 0))); + // F01/F02:来源主体解析与使用许可闸在生产装配里必然存在;单测手工构造服务时必须显式注入 + // 测试替身,否则 apply 会因"闸门未装配/来源不可解析"被 fail-closed 拒(那是期望行为,不是靶子)。 + FlowSourcePrincipalResolver stubPrincipals = mock(FlowSourcePrincipalResolver.class); + when(stubPrincipals.resolve(org.mockito.ArgumentMatchers.anyString())) + .thenReturn(new FlowSourcePrincipalResolver.Principal( + "ub-app:18", 18, "100018", "flow-test")); + org.springframework.test.util.ReflectionTestUtils.setField(oapiService, "sourcePrincipalResolver", stubPrincipals); + oapiService.setUsageGrantGuard(mock(FlowUsageGrantGuard.class)); + } // ==================== 线级停发/恢复(管理面委托) ==================== diff --git a/src/test/java/com/yunhai/flowcenter/service/FlDepartmentSubjectTest.java b/src/test/java/com/yunhai/flowcenter/service/FlDepartmentSubjectTest.java new file mode 100644 index 0000000..a5e3ab8 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlDepartmentSubjectTest.java @@ -0,0 +1,423 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.AssigneeRef; +import com.yunhai.flowcenter.engine.AssigneeResolver; +import com.yunhai.flowcenter.engine.DefValidator; +import com.yunhai.flowcenter.engine.FlowDefModel; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.OpenApiGroupClient; +import com.yunhai.platform.openapi.base.OpenApiUserClient; +import com.yunhai.platform.openapi.base.model.DepartmentEntry; +import com.yunhai.platform.openapi.base.model.DepartmentRef; +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.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +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.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; + +/** + * UserBase 部门权威化与审批主体规则(工作包 C,2026-10-29)。 + * + *

测试替身边界:{@link OpenApiGroupClient}/{@link OpenApiUserClient} 按 UserBase + * {@code /oapi/departments/**} 的真实契约打桩(响应形状=SDK 的 DepartmentEntry/BoaUser), + * 这不是跨服务联调——真实 UserBase 可达性、SYSTEM/CUSTOM 数据齐备性由后续 WorkBuddy 验证。

+ * + *

钉住的红线:

+ *
    + *
  • 部门是审批授权主体,不是"前端筛出来的一堆人";解析出的候选仍逐人过 D11 资格终审;
  • + *
  • 一个用户可属多个部门 → 多部门引用按用户去重,只发一条待办;
  • + *
  • 目录不可用一律抛错,不静默退回 flw_dept,也不把空候选当成功;
  • + *
  • 展示口径(显示人员/显示部门)与审批主体分开配置并独立生效。
  • + *
+ */ +@DisplayName("UserBase 部门主体解析 + 展示规则(C1/C2/C3)") +class FlDepartmentSubjectTest { + @Test void foreignSchoolReferenceCannotExposeMembers() { + assertThatThrownBy(() -> directory.resolve(SCH, + Arrays.asList(new DepartmentRef((int) SCH + 1, SYSTEM, 42)))) + .isInstanceOf(BizException.class).hasMessageContaining("学校"); + DepartmentEntry foreign = entry(SYSTEM, 42, true, false); + foreign.setSchoolId((int) SCH + 1); foreign.setName("foreign"); + when(groupClient.resolveDepartments(any())).thenReturn(Arrays.asList(foreign)); + assertThatThrownBy(() -> directory.membersAtSchool(SCH, SYSTEM, 42, "select")) + .isInstanceOf(BizException.class).hasMessageContaining("学校"); + org.mockito.Mockito.verify(groupClient, org.mockito.Mockito.never()).getDepartmentMembers(any(), any(), any()); + } + + private static final long SCH = 5L; + private static final String SYSTEM = "SYSTEM"; + private static final String CUSTOM = "CUSTOM"; + + private OpenApiGroupClient groupClient; + private OpenApiUserClient userClient; + private UserBaseDepartmentDirectory directory; + private AssigneeResolver resolver; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + groupClient = mock(OpenApiGroupClient.class); + userClient = mock(OpenApiUserClient.class); + directory = new UserBaseDepartmentDirectory(groupClient); + when(groupClient.resolveDepartments(any())).thenAnswer(call -> { + java.util.List refs = call.getArgument(0); + if (refs == null) return java.util.Collections.emptyList(); + java.util.List rows = new java.util.ArrayList<>(); + for (DepartmentRef ref : refs) { + DepartmentEntry e = entry(ref.getDepartmentType(), ref.getDepartmentId(), true, false); + e.setSchoolId(ref.getSchoolId()); e.setName("department"); rows.add(e); + } + return rows; + }); + resolver = new AssigneeResolver(mock(FlowDeptAdminService.class), userClient); + resolver.setDepartmentDirectory(directory); + givenEligibleUsers(101, 102, 103, 104); + } + + // ==================== C1 权威目录接入 ==================== + + @Test + @DisplayName("DEPT 主体从 UserBase 目录解析候选人(scope=select 严档,不读 flw_dept_member)") + void departmentSubjectResolvesFromUserBase() { + when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")) + .thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师"))); + + List refs = resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42))); + + assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101, 102); + verify(groupClient).getDepartmentMembers(SYSTEM, 42, "select"); + } + + @Test + @DisplayName("同一用户归属多个被选部门:只产出一条待办(多组归属不是模型禁忌)") + void overlappingMembershipDeduplicatesUser() { + when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")) + .thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师"))); + when(groupClient.getDepartmentMembers(CUSTOM, 7, "select")) + .thenReturn(Arrays.asList(user(102, "李老师"), user(103, "王老师"))); + + FlowDefModel.Assignee assignee = new FlowDefModel.Assignee(); + assignee.type = FlowDefModel.ASSIGNEE_DEPT; + assignee.deptRefs = new ArrayList<>(Arrays.asList(deptRef(SYSTEM, 42), deptRef(CUSTOM, 7))); + + List refs = resolver.resolve(SCH, approvalNode(assignee)); + + assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101, 102, 103); + } + + @Test + @DisplayName("目录不可用:拒绝并说明原因,绝不退回 FL 本地科室(双权威 fallback 已禁)") + void directoryOutageFailsClosed() { + when(groupClient.getDepartmentMembers(anyString(), anyInt(), anyString())) + .thenThrow(new RuntimeException("userbase unreachable")); + FlowDefModel.Node node = approvalNode(deptAssignee(SYSTEM, 42)); + + assertThatThrownBy(() -> resolver.resolve(SCH, node)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不可用"); + // 未装配目录通道同样拒绝,而不是回读本地表 + AssigneeResolver unwired = new AssigneeResolver(mock(FlowDeptAdminService.class), userClient); + assertThatThrownBy(() -> unwired.resolve(SCH, node)) + .isInstanceOf(BizException.class) + .hasMessageContaining("未装配"); + } + + @Test + @DisplayName("部门解析出 0 人:按「无可用办理人」拒绝,不把空候选当成功") + void emptyDepartmentIsRejected() { + when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")).thenReturn(new ArrayList<>()); + + assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("无可用办理人"); + } + + @Test + @DisplayName("部门里全是禁用/跨校账号:整体拒绝并点名原因(存在≠有资格,D11)") + void ineligibleMembersAreFilteredAndEmptyIsRejected() { + when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")) + .thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师"))); + when(userClient.getUserById("101")).thenReturn(disabled(101)); + when(userClient.getUserById("102")).thenReturn(otherSchool(102)); + + assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42)))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不具备办理资格"); + } + + @Test + @DisplayName("部分成员不合格:剔除不合格者继续,不因个别人卡死整条流程") + void partiallyIneligibleKeepsEligibleOnes() { + when(groupClient.getDepartmentMembers(SYSTEM, 42, "select")) + .thenReturn(Arrays.asList(user(101, "张老师"), user(102, "李老师"))); + when(userClient.getUserById("102")).thenReturn(disabled(102)); + + List refs = resolver.resolve(SCH, approvalNode(deptAssignee(SYSTEM, 42))); + + assertThat(refs).extracting(AssigneeRef::getUserId).containsExactly(101); + } + + @Test + @DisplayName("部门引用缺类型消歧位:拒绝(同值 id 在 SYSTEM/CUSTOM 不等价)") + void untypedReferenceRejected() { + FlowDefModel.Assignee assignee = new FlowDefModel.Assignee(); + assignee.type = FlowDefModel.ASSIGNEE_DEPT; + FlowDefModel.DeptRef bad = new FlowDefModel.DeptRef(); + bad.departmentId = 42; + assignee.deptRefs = new ArrayList<>(Arrays.asList(bad)); + + assertThatThrownBy(() -> resolver.resolve(SCH, approvalNode(assignee))) + .isInstanceOf(BizException.class); + + assertThatThrownBy(() -> directory.members("OTHER", 42, "select")) + .isInstanceOf(BizException.class) + .hasMessageContaining("SYSTEM/CUSTOM"); + } + + @Test + @DisplayName("历史口径与选择口径分开:resolve-batch 取停用部门名称,select 不返回停用项") + void historyAndSelectionScopesAreSeparate() { + when(groupClient.getDepartmentMembers(CUSTOM, 9, "history")) + .thenReturn(Arrays.asList(user(103, "王老师"))); + assertThat(directory.members(CUSTOM, 9, "history")) + .extracting(BoaUser::getId).containsExactly(103); + + DepartmentEntry deleted = new DepartmentEntry(); + deleted.setDepartmentType(CUSTOM); + deleted.setDepartmentId(9); + deleted.setName("已撤销的课题组"); + deleted.setSelectable(false); + deleted.setDeleted(true); + deleted.setSchoolId((int) SCH); + when(groupClient.resolveDepartments(any())).thenReturn(Arrays.asList(deleted)); + + List resolved = directory.resolve(SCH, + Arrays.asList(new DepartmentRef(null, CUSTOM, 9))); + + assertThat(resolved).hasSize(1); + assertThat(resolved.get(0).getName()).isEqualTo("已撤销的课题组"); + assertThat(resolved.get(0).getSelectable()).isFalse(); + // 学校范围由服务端会话派生并补进引用,不接受前端传别校 id + assertThat(resolved.get(0).getSchoolId()).isEqualTo((int) SCH); + ArgumentCaptor> sent = ArgumentCaptor.forClass(List.class); + verify(groupClient).resolveDepartments(sent.capture()); + assertThat(sent.getValue().get(0).getSchoolId()).as("校域由服务端会话派生并补进引用").isEqualTo((int) SCH); + } + + @Test + @DisplayName("发布校验白名单来自目录 selectable 项,停用部门不可被新选中") + void selectableKeysOnlyCatalogSelectable() { + DepartmentEntry live = entry(SYSTEM, 42, true, false); + DepartmentEntry stopped = entry(SYSTEM, 43, false, false); + DepartmentEntry softDeleted = entry(CUSTOM, 9, true, true); + when(groupClient.getDepartments(eq((int) SCH), any(), any(), any())) + .thenReturn(Arrays.asList(live, stopped, softDeleted)); + + assertThat(directory.selectableKeys(SCH)).containsExactly("SYSTEM:42"); + } + + @Test + @DisplayName("空引用批量解析本地短路,不出站也不报错") + void emptyResolveBatchShortCircuits() { + assertThat(directory.resolve(SCH, new ArrayList<>())).isEmpty(); + verify(groupClient, never()).resolveDepartments(any()); + } + + // ==================== C3 展示规则 ==================== + + @Test + @DisplayName("显示部门:任务节点名取独立展示名;未配展示字段=沿用节点名(历史定义零变化)") + void displayLabelDrivesTaskNodeName() throws Exception { + FlowDefModel model = FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}"); + FlowDefModel.Node legacy = approvalNode(deptAssignee(SYSTEM, 42)); + assertThat(legacy.displayName()).isEqualTo("二级审批"); + + legacy.displayMode = FlowDefModel.DISPLAY_MODE_DEPT; + legacy.displayLabel = "校长室"; + assertThat(legacy.displayName()).isEqualTo("校长室"); + + // 按人员展示时不受 displayLabel 影响(只展示本节点自己配置/解析到的人) + legacy.displayMode = FlowDefModel.DISPLAY_MODE_USER; + assertThat(legacy.displayName()).isEqualTo("二级审批"); + assertThat(model).isNotNull(); + } + + @Test + @DisplayName("发布校验:显示部门必须填展示名;未知 displayMode 取值拒绝") + void publishRejectsMissingDisplayLabel() { + FlowDefModel model = twoLevelModel(deptAssignee(SYSTEM, 42)); + model.nodes.get(1).displayMode = FlowDefModel.DISPLAY_MODE_DEPT; + + assertThatThrownBy(() -> DefValidator.validate(model, null, null, + new java.util.HashSet<>(Arrays.asList("SYSTEM:42")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("展示名称"); + + model.nodes.get(1).displayMode = "BOTH"; + model.nodes.get(1).displayLabel = "校长室"; + assertThatThrownBy(() -> DefValidator.validate(model, null, null, + new java.util.HashSet<>(Arrays.asList("SYSTEM:42")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("displayMode"); + } + + @Test + @DisplayName("发布校验:部门不在本校可选目录内(跨校/停用/已删)即拦;引用齐备则放行") + void publishChecksDepartmentAgainstDirectoryWhitelist() { + FlowDefModel model = twoLevelModel(deptAssignee(SYSTEM, 42)); + + DefValidator.validate(model, null, null, new java.util.HashSet<>(Arrays.asList("SYSTEM:42"))); + + assertThatThrownBy(() -> DefValidator.validate(model, null, null, + new java.util.HashSet<>(Arrays.asList("SYSTEM:99")))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不在本校可选部门目录内"); + } + + @Test + @DisplayName("会签/或签对部门主体与人员主体同样可配(D07/D08 主体定候选,策略定完成条件)") + void countersignConfigurableForBothSubjects() { + Set whitelist = new java.util.HashSet<>(Arrays.asList("SYSTEM:42")); + // 部门主体 + 或签:合法(会签/或签不是只对部门生效) + FlowDefModel deptModel = twoLevelModel(deptAssignee(SYSTEM, 42)); + deptModel.nodes.get(2).countersign = FlowDefModel.COUNTERSIGN_ANY; + DefValidator.validate(deptModel, null, null, whitelist); + assertThat(deptModel.nodes.get(2).countersign).isEqualTo(FlowDefModel.COUNTERSIGN_ANY); + + // 人员主体 + 或签:同样合法 + FlowDefModel usersModel = twoLevelModel(usersAssignee(101, 102)); + usersModel.nodes.get(2).countersign = FlowDefModel.COUNTERSIGN_ANY; + DefValidator.validate(usersModel, null, null, whitelist); + + // 非法策略值:两类主体都拦 + deptModel.nodes.get(2).countersign = "MOST"; + assertThatThrownBy(() -> DefValidator.validate(deptModel, null, null, whitelist)) + .isInstanceOf(BizException.class) + .hasMessageContaining("countersign"); + } + + // ==================== fixtures ==================== + + private void givenEligibleUsers(int... ids) { + for (int id : ids) { + when(userClient.getUserById(String.valueOf(id))).thenReturn(sameSchool(id, "教师" + id)); + } + } + + private FlowDefModel.Node approvalNode(FlowDefModel.Assignee assignee) { + FlowDefModel.Node node = new FlowDefModel.Node(); + node.id = "n2"; + node.type = FlowDefModel.TYPE_APPROVAL; + node.name = "二级审批"; + node.assignee = assignee; + return node; + } + + private FlowDefModel twoLevelModel(FlowDefModel.Assignee assignee) { + FlowDefModel model = FlowDefModel.parse(mapper, "{\"start\":\"start\",\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"一级\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[101]}}," + + "{\"id\":\"end\",\"type\":\"END\"}],\"edges\":[" + + "{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}"); + FlowDefModel.Node deptNode = new FlowDefModel.Node(); + deptNode.id = "n2"; + deptNode.type = FlowDefModel.TYPE_APPROVAL; + deptNode.name = "二级审批"; + deptNode.assignee = assignee; + model.nodes.add(2, deptNode); + model.edges.add(edge("n1", "n2")); + model.edges.add(edge("n2", "end")); + // 一级→end 的原边由 n1→n2 取代 + model.edges.removeIf(e -> "n1".equals(e.from) && "end".equals(e.to)); + return model; + } + + private FlowDefModel.Edge edge(String from, String to) { + FlowDefModel.Edge e = new FlowDefModel.Edge(); + e.from = from; + e.to = to; + return e; + } + + private FlowDefModel.Assignee deptAssignee(String type, int id) { + FlowDefModel.Assignee assignee = new FlowDefModel.Assignee(); + assignee.type = FlowDefModel.ASSIGNEE_DEPT; + assignee.deptRefs = new ArrayList<>(Arrays.asList(deptRef(type, id))); + return assignee; + } + + private FlowDefModel.Assignee usersAssignee(int... ids) { + FlowDefModel.Assignee assignee = new FlowDefModel.Assignee(); + assignee.type = FlowDefModel.ASSIGNEE_USERS; + List list = new ArrayList<>(); + for (int id : ids) { + list.add(id); + } + assignee.userIds = list; + return assignee; + } + + private FlowDefModel.DeptRef deptRef(String type, int id) { + FlowDefModel.DeptRef ref = new FlowDefModel.DeptRef(); + ref.departmentType = type; + ref.departmentId = id; + return ref; + } + + private DepartmentEntry entry(String type, int id, boolean enabled, boolean deleted) { + DepartmentEntry e = new DepartmentEntry(); + e.setSchoolId((int) SCH); + e.setDepartmentType(type); + e.setDepartmentId(id); + e.setName(type + "-" + id); + e.setEnabled(enabled); + e.setDeleted(deleted); + e.setSelectable(enabled && !deleted); + return e; + } + + private BoaUser user(int id, String name) { + return sameSchool(id, name); + } + + private BoaUser sameSchool(int id, String name) { + BoaUser u = new BoaUser(); + u.setId(id); + u.setName(name); + u.setStatus(1); + com.yunhai.platform.utils.model.BoaGroup school = new com.yunhai.platform.utils.model.BoaGroup(); + school.setId((int) SCH); + u.setSchool(school); + return u; + } + + private BoaUser disabled(int id) { + BoaUser u = sameSchool(id, "停用账号"); + u.setStatus(0); + return u; + } + + private BoaUser otherSchool(int id) { + BoaUser u = sameSchool(id, "他校账号"); + u.getSchool().setId((int) SCH + 1); + return u; + } + +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlFlowCodeTest.java b/src/test/java/com/yunhai/flowcenter/service/FlFlowCodeTest.java new file mode 100644 index 0000000..4d9acd2 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlFlowCodeTest.java @@ -0,0 +1,225 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowEngine; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwAppRepository; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +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.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 流程编码自动生成与归属应用可选(A3,2026-10-29)。 + * + *

D02:定义归属、使用许可、实例来源是三个概念。此前 {@code save} 硬拦"归属应用必填" + * (2026-10-12 拍板)与"code/name 必填",把内部编码变成管理员的手填项。现:

+ *
    + *
  • 新建不传 code → 后端生成稳定唯一码;传合法 code(旧客户端)→ 原样保留,不破坏既有调用方;
  • + *
  • 更新/升版本不传 code → 继承既有编码,编码不随名称或编辑变化;
  • + *
  • appKey 缺省合法;显式传入仍须在本校已开启应用白名单内。
  • + *
+ */ +@DisplayName("流程编码后端生成 + 归属应用可选(A3/D02)") +class FlFlowCodeTest { + + private static final long SCH = 3L; + private static final String EXISTING_CODE = "flow-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + private FlwDefRepository defRepository; + private FlwAppRepository appRepository; + private FlowDefAdminService service; + + @BeforeEach + void setUp() { + defRepository = mock(FlwDefRepository.class); + appRepository = mock(FlwAppRepository.class); + service = new FlowDefAdminService(defRepository, + mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class), + mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class), + appRepository, + mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class), + mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), + mock(FlowEngine.class), + new ObjectMapper(), + mock(com.yunhai.flowcenter.service.FormCenterSchemaClient.class)); + when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(appRepository.isEnabledApp(any(), any())).thenReturn(true); + } + + // ==================== code 自动生成 ==================== + + @Test + @DisplayName("新建不传 code:后端生成 flow- 前缀 + 32 位 hex,且与 flowGuid 是两个概念") + void newFlowGetsGeneratedCode() { + FlwDef saved = service.save(SCH, null, null, "加班审批", contentJson(), null, null, null); + + assertThat(saved.getCode()).startsWith("flow-"); + assertThat(saved.getCode()).matches("flow-[0-9a-f]{32}"); + assertThat(saved.getFlowGuid()).isNotEmpty(); + assertThat(saved.getCode()).isNotEqualTo(saved.getFlowGuid()); + assertThat(saved.getVersion()).isEqualTo(1); + } + + @Test + @DisplayName("批量新建:编码互不相同(唯一性不依赖名称,也不依赖数据库唯一键)") + void generatedCodesAreDistinct() { + Set seen = new HashSet<>(); + for (int i = 0; i < 200; i++) { + FlwDef saved = service.save(SCH, null, "", "同名流程", contentJson(), null, null, null); + assertThat(seen.add(saved.getCode())).as("第 " + i + " 次生成撞码:" + saved.getCode()).isTrue(); + } + assertThat(seen).hasSize(200); + } + + @Test + @DisplayName("旧客户端显式提交合法 code:原样保留(不因新 UI 不展示而破坏既有调用方)") + void explicitCodeStillAccepted() { + FlwDef saved = service.save(SCH, null, "leave-annual", "年假", contentJson(), null, null, null); + + assertThat(saved.getCode()).isEqualTo("leave-annual"); + } + + @Test + @DisplayName("改名不重置编码:草稿直改后 code 与 flowGuid 都不变") + void renameDoesNotRegenerateCode() { + FlwDef existing = draft(80L, "flow-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "旧名称"); + when(defRepository.findById(80L)).thenReturn(Optional.of(existing)); + + FlwDef saved = service.save(SCH, 80L, null, "新名称", contentJson(), null, null, null); + + assertThat(saved.getCode()).isEqualTo("flow-eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); + assertThat(saved.getName()).isEqualTo("新名称"); + } + + @Test + @DisplayName("升版本继承编码:新版本行与源版本同一 code,flowGuid 不变、版本 +1") + void versionBumpInheritsCode() { + FlwDef published = published(81L, "flow-ffffffffffffffffffffffffffffffff", "已发布线"); + when(defRepository.findById(81L)).thenReturn(Optional.of(published)); + when(defRepository.maxVersionByGuid(SCH, published.getFlowGuid())).thenReturn(4); + + FlwDef saved = service.save(SCH, 81L, null, "已发布线", contentJson(), null, null, null); + + assertThat(saved.getCode()).isEqualTo("flow-ffffffffffffffffffffffffffffffff"); + assertThat(saved.getFlowGuid()).isEqualTo(published.getFlowGuid()); + assertThat(saved.getVersion()).isEqualTo(5); + assertThat(saved.getStatus()).isEqualTo(FlwEnums.DefStatus.DRAFT.getCode()); + } + + @Test + @DisplayName("显式提交不同编码:仍按「流程编码不可修改」拒绝(编码稳定,不随编辑漂移)") + void codeChangeOnExistingLineRejected() { + FlwDef existing = draft(82L, EXISTING_CODE, "草稿"); + when(defRepository.findById(82L)).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.save(SCH, 82L, "completely-other-code", "草稿", + contentJson(), null, null, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("编码不可修改"); + } + + @Test + @DisplayName("升版本走 baseGuid:不传 code 时继承源线编码") + void baseGuidBranchInheritsCode() { + FlwDef base = published(83L, "flow-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "基线"); + when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, base.getFlowGuid())) + .thenReturn(Optional.of(base)); + when(defRepository.maxVersionByGuid(SCH, base.getFlowGuid())).thenReturn(7); + + FlwDef saved = service.save(SCH, null, null, "基线", contentJson(), base.getFlowGuid(), null, null); + + assertThat(saved.getCode()).isEqualTo("flow-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + assertThat(saved.getVersion()).isEqualTo(8); + } + + // ==================== appKey 可选 ==================== + + @Test + @DisplayName("归属应用留空可以保存:定义不绑定唯一消费应用(D02)") + void nullAppKeyIsLegal() { + FlwDef saved = service.save(SCH, null, null, "无归属流程", contentJson(), null, null, null); + + assertThat(saved.getAppKey()).isNull(); + } + + @Test + @DisplayName("空白 appKey 归一为 null,不落「看起来有值实际无效」的字符串") + void blankAppKeyNormalizedToNull() { + FlwDef saved = service.save(SCH, null, null, "无归属流程", contentJson(), null, " ", null); + + assertThat(saved.getAppKey()).isNull(); + } + + @Test + @DisplayName("显式归属应用仍须在本校已开启白名单内(可选≠任意)") + void explicitAppKeyStillWhitelisted() { + when(appRepository.isEnabledApp(SCH, "not-enabled")).thenReturn(false); + + assertThatThrownBy(() -> service.save(SCH, null, null, "流程", contentJson(), null, "not-enabled", null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("归属应用未开启或不存在"); + } + + @Test + @DisplayName("名称仍是必填项:只放开 code/appKey,不放开身份性字段") + void nameStillRequired() { + assertThatThrownBy(() -> service.save(SCH, null, null, " ", contentJson(), null, null, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("名称"); + } + + @Test + @DisplayName("生成规则:flow- 前缀 + 规范 UUID hex,长度落在 code 列 64 之内") + void generatedCodeFitsColumn() { + for (int i = 0; i < 50; i++) { + String code = FlowDefAdminService.generateFlowCode(); + assertThat(code).hasSizeLessThanOrEqualTo(64).matches("flow-[0-9a-f]{32}"); + // 去前缀后必须还能按 UUID 解析:碰撞域就是 UUID 的 122 位随机空间 + UUID.fromString(code.substring("flow-".length()).replaceFirst( + "(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)", + "$1-$2-$3-$4-$5")); + } + } + + // ==================== fixtures ==================== + + private String contentJson() { + return "{\"nodes\":[{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"n1\",\"type\":\"END\",\"name\":\"结束\"}],\"edges\":[{\"from\":\"start\",\"to\":\"n1\"}]}"; + } + + private FlwDef draft(Long id, String code, String name) { + return row(id, code, name, FlwEnums.DefStatus.DRAFT.getCode(), 1); + } + + private FlwDef published(Long id, String code, String name) { + return row(id, code, name, FlwEnums.DefStatus.PUBLISHED.getCode(), 4); + } + + private FlwDef row(Long id, String code, String name, int status, int version) { + FlwDef def = new FlwDef(); + def.setId(id); + def.setSchId(SCH); + def.setFlowGuid("guid-" + id); + def.setCode(code); + def.setName(name); + def.setVersion(version); + def.setStatus(status); + def.setContentJson(contentJson()); + return def; + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java index 1ebf951..0078cd5 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java @@ -35,6 +35,8 @@ class FlNewInstancePathTest { FlowEngine engine = new FlowEngine(mapper, tasks, branches, mock(FlwCcRepository.class), assignees, mock(ConditionRouteService.class), publisher); FlowInstanceService apply = new FlowInstanceService(instances, defs, tasks, branches, engine, publisher); + org.springframework.test.util.ReflectionTestUtils.setField(apply, "commandSources", mock(com.yunhai.flowcenter.repository.FlwCommandSourceRepository.class)); + org.springframework.test.util.ReflectionTestUtils.setField(apply, "creationGrants", mock(FlowUsageGrantGuard.class)); FlowTaskService handle = new FlowTaskService(mapper, tasks, instances, branches, defs, assignees, engine, publisher); FlowAdvanceService advance = new FlowAdvanceService(instances, defs, tasks, engine, intents); @@ -81,7 +83,8 @@ class FlNewInstancePathTest { FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand( 1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant", null, "100001", "notice", null); - command.sourcePrincipalId = "11111111-1111-1111-1111-111111111111"; + // D11/F02:业务坐标必须带可信来源主体(由服务端从验签 appKey 解析,测试里显式给定) + command.sourcePrincipalId = "ub-app:100001"; FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command, new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false)); assertThat(outcome.duplicated).isFalse(); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlReturnToSubmitterTest.java b/src/test/java/com/yunhai/flowcenter/service/FlReturnToSubmitterTest.java new file mode 100644 index 0000000..e213fcb --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlReturnToSubmitterTest.java @@ -0,0 +1,377 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.AssigneeResolver; +import com.yunhai.flowcenter.engine.FlowDefModel; +import com.yunhai.flowcenter.engine.FlowEngine; +import com.yunhai.flowcenter.engine.FlowEvent; +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.repository.FlwBranchRepository; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.ArrayList; +import java.util.List; +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.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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * D14「退回发起人修改后重提」(工作包 B,2026-10-29)。 + * + *

钉住的四条语义(决策原文:同一实例、同一申报轮次、历史记录保留、从第一级重新审批):

+ *
    + *
  1. 退回后办件进入 {@code 50 退回待修改}——非终态,且与"拒绝终止/撤回/终态后重报"互不冒充;
  2. + *
  3. 重提沿用同一 {@code instanceId} 与同一 {@code applyRound},不建新实例、不自增轮次;
  4. + *
  5. 历史任务/意见/留痕行一律保留,重提只追加新一代任务;
  6. + *
  7. 身份与归属由服务端复核:非发起人不能代提,重复点击/网络重试不产生第二代任务。
  8. + *
+ * + *

mock 边界:仓储、引擎协作、事件发布器。没有真实事务/数据库锁/提交后回调, + * 并发只验证到"状态判定 + 引擎调用次数"这一层,真实事务由后续跨服务联调补证。

+ */ +@DisplayName("D14 退回发起人修改 + 同实例同轮次重提") +class FlReturnToSubmitterTest { + + private static final long SCH = 9L; + private static final Long INSTANCE_ID = 700L; + private static final Long TASK_ID = 900L; + private static final int SUBMITTER = 1001; + private static final int APPROVER = 2002; + private static final String NODE = "n2"; + + private final ObjectMapper mapper = new ObjectMapper(); + + private FlwTaskRepository taskRepository; + private FlwInstanceRepository instanceRepository; + private FlwBranchRepository branchRepository; + private FlwDefRepository defRepository; + private AssigneeResolver assigneeResolver; + private FlowEngine flowEngine; + private ApplicationEventPublisher eventPublisher; + private FlowTaskService taskService; + private FlowInstanceService instanceService; + + private FlwInstance instance; + private FlwDef def; + + @BeforeEach + void setUp() { + taskRepository = mock(FlwTaskRepository.class); + instanceRepository = mock(FlwInstanceRepository.class); + branchRepository = mock(FlwBranchRepository.class); + defRepository = mock(FlwDefRepository.class); + assigneeResolver = mock(AssigneeResolver.class); + flowEngine = mock(FlowEngine.class); + eventPublisher = mock(ApplicationEventPublisher.class); + taskService = new FlowTaskService(mapper, taskRepository, instanceRepository, branchRepository, + defRepository, assigneeResolver, flowEngine, eventPublisher); + instanceService = new FlowInstanceService(instanceRepository, defRepository, taskRepository, + branchRepository, flowEngine, eventPublisher); + + instance = approvingInstance(); + def = levelDef(); + when(instanceRepository.findById(INSTANCE_ID)).thenReturn(Optional.of(instance)); + when(instanceRepository.findFormalPreviousForUpdate(INSTANCE_ID)).thenReturn(Optional.of(instance)); + when(instanceRepository.saveAndFlush(any())).thenAnswer(inv -> inv.getArgument(0)); + when(instanceRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(pendingTask())); + when(taskRepository.claimAndFinish(eq(TASK_ID), any(), any(), anyString(), any())).thenReturn(1); + when(defRepository.findById(60L)).thenReturn(Optional.of(def)); + when(flowEngine.parseModel(any())).thenReturn(model()); + } + + // ==================== 审批人:退回发起人修改 ==================== + + @Test + @DisplayName("退回发起人:抢占到 RETURNED、整条在途代次失效、办件转 50 且留出原因") + void returnToSubmitterParksInstanceAndInvalidatesCohort() { + taskService.returnToSubmitter(SCH, TASK_ID, "材料不齐,请补充附件", APPROVER); + + verify(taskRepository).claimAndFinish(eq(TASK_ID), eq(FlwEnums.TaskStatus.RETURNED.getCode()), + any(), org.mockito.ArgumentMatchers.contains("退回发起人修改"), any()); + verify(taskRepository).invalidatePendingByInstance(INSTANCE_ID); + verify(branchRepository).invalidateRunningByInstance(INSTANCE_ID); + assertThat(instance.getStatus()).isEqualTo(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()); + assertThat(instance.getCurrentNodes()).isEmpty(); + assertThat(instance.getFinishTime()).as("50 不是终态,不得写办结时间").isNull(); + } + + @Test + @DisplayName("退回发起人发的是既有 RETURNED 事件,用 returnTo=SUBMITTER 表达去向(不新增事件码)") + void returnToSubmitterReusesReturnedEventCode() { + taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", APPROVER); + + ArgumentCaptor event = ArgumentCaptor.forClass(FlowEvent.class); + verify(eventPublisher).publishEvent(event.capture()); + FlowEvent published = event.getValue(); + assertThat(published.getType()).isEqualTo(FlowEvent.Type.RETURNED); + assertThat(published.getData().get("returnTo")).isEqualTo("SUBMITTER"); + assertThat(published.getData().get("targetNodeId")) + .isEqualTo(FlwEnums.TaskStatus.NODE_RETURN_SUBMITTER); + assertThat(published.getData().get("instanceStatus")) + .isEqualTo(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()); + } + + @Test + @DisplayName("退回发起人必须填原因:缺失即拒,且不动任务也不动办件") + void returnToSubmitterRequiresOpinion() { + assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, " ", APPROVER)) + .isInstanceOf(BizException.class) + .hasMessageContaining("退回原因"); + verify(taskRepository, never()).claimAndFinish(anyLong(), any(), any(), any(), any()); + assertThat(instance.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode()); + } + + @Test + @DisplayName("非本人办理权 / 非待办任务:一律拒绝(沿用既有抢占纪律)") + void returnToSubmitterRejectsForeignOrHandledTask() { + assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", 9999)) + .isInstanceOf(BizException.class); + + FlwTask handled = pendingTask(); + handled.setStatus(FlwEnums.TaskStatus.APPROVED.getCode()); + when(taskRepository.findById(TASK_ID)).thenReturn(Optional.of(handled)); + assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", APPROVER)) + .isInstanceOf(BizException.class); + } + + @Test + @DisplayName("办件已终态:不能再退回发起人") + void returnToSubmitterRejectsTerminalInstance() { + instance.setStatus(FlwEnums.InstanceStatus.REJECTED.getCode()); + + assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", APPROVER)) + .isInstanceOf(BizException.class) + .hasMessageContaining("审批中"); + } + + @Test + @DisplayName("重复点击/并发第二次抢不到办理权:拒绝且不产生第二代失效动作") + void doubleReturnIsRejectedByClaim() { + when(taskRepository.claimAndFinish(eq(TASK_ID), any(), any(), anyString(), any())).thenReturn(0); + + assertThatThrownBy(() -> taskService.returnToSubmitter(SCH, TASK_ID, "补充材料", APPROVER)) + .isInstanceOf(BizException.class) + .hasMessageContaining("已被办理"); + verify(taskRepository, never()).invalidatePendingByInstance(anyLong()); + } + + // ==================== 发起人:重提 ==================== + + @Test + @DisplayName("重提=同实例同轮次:不新建、不自增 applyRound、从 START 重新推进、历史行保留") + void resubmitReusesSameInstanceAndRound() { + instance.setStatus(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()); + instance.setApplyRound(2); + instance.setDefId(60L); + List history = historyRows(); + when(taskRepository.findByInstanceIdOrderByIdAsc(INSTANCE_ID)).thenReturn(history); + + Long returnedId = instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null); + + assertThat(returnedId).isEqualTo(INSTANCE_ID); + assertThat(instance.getId()).isEqualTo(INSTANCE_ID); + assertThat(instance.getApplyRound()).as("D14 不增加申报轮次").isEqualTo(2); + assertThat(instance.getDefId()).as("在途实例仍固定发起时版本(D04/D05)").isEqualTo(60L); + assertThat(instance.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode()); + // 重新推进用的是实例钉住的那份定义,从 START 起=第一级审批节点重建一代任务 + verify(flowEngine, times(1)).startAdvance(instance, def); + // 历史任务行没有被删除或被改写 + assertThat(history).hasSize(3); + assertThat(history.get(0).getStatus()).isEqualTo(FlwEnums.TaskStatus.APPROVED.getCode()); + verify(taskRepository, never()).delete(any()); + // 追加一条"修改后重新提交"留痕行 + ArgumentCaptor trace = ArgumentCaptor.forClass(FlwTask.class); + verify(taskRepository, times(1)).save(trace.capture()); + assertThat(trace.getValue().getNodeId()).isEqualTo(FlwEnums.TaskStatus.NODE_RESUBMIT); + assertThat(trace.getValue().getStatus()).isEqualTo(FlwEnums.TaskStatus.SUBMITTED.getCode()); + } + + @Test + @DisplayName("非发起人不能代提(应用签名/管理员身份都不算)") + void resubmitRejectsNonSubmitter() { + instance.setStatus(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()); + + assertThatThrownBy(() -> instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, 5555, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("发起人本人"); + verify(flowEngine, never()).startAdvance(any(), any()); + } + + @Test + @DisplayName("重复提交/网络重试:已是审批中=回原实例幂等收口,不再产生第二代任务") + void duplicateResubmitIsIdempotentReceipt() { + instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); + FlwTask trace = new FlwTask(); + trace.setNodeId(FlwEnums.TaskStatus.NODE_RESUBMIT); trace.setNodeName("修改后重新提交"); + when(taskRepository.findFirstByInstanceIdAndStatusOrderByIdDesc(INSTANCE_ID, + FlwEnums.TaskStatus.SUBMITTED.getCode())).thenReturn(Optional.of(trace)); + + assertThat(instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null)) + .isEqualTo(INSTANCE_ID); + verify(flowEngine, never()).startAdvance(any(), any()); + verify(taskRepository, never()).save(any()); + } + + @Test void ordinaryApprovingInstanceIsNotAResubmitReceipt() { + instance.setStatus(10); + assertThatThrownBy(() -> instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null)) + .isInstanceOf(BizException.class).hasMessageContaining("退回待修改"); + verify(flowEngine, never()).startAdvance(any(), any()); + verify(taskRepository, never()).save(any()); + verify(instanceRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("终态件不能走 D14 重提:拒绝终止/撤回与退回待修改是不同动作") + void terminalInstanceCannotResubmit() { + instance.setStatus(FlwEnums.InstanceStatus.REJECTED.getCode()); + + assertThatThrownBy(() -> instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null)) + .isInstanceOf(BizException.class) + .hasMessageContaining("退回待修改"); + + instance.setStatus(FlwEnums.InstanceStatus.WITHDRAWN.getCode()); + assertThatThrownBy(() -> instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null)) + .isInstanceOf(BizException.class); + } + + @Test + @DisplayName("回传新的表单快照才覆盖,未传即沿用原快照(FL 不做业务字段 Diff)") + void resubmitKeepsFormDataWhenNotSupplied() { + instance.setStatus(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()); + instance.setFormDataJson("{\"days\":3}"); + + instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, null); + assertThat(instance.getFormDataJson()).isEqualTo("{\"days\":3}"); + + instance.setStatus(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()); + instanceService.resubmitAfterReturn(SCH, INSTANCE_ID, SUBMITTER, "{\"days\":5}"); + assertThat(instance.getFormDataJson()).isEqualTo("{\"days\":5}"); + } + + @Test + @DisplayName("退回发起人 ≠ 普通退回到审批节点:returnBack 仍只接受审批节点目标") + void normalReturnBackStillTargetsApprovalNodes() { + FlwDef withNodes = def; + when(flowEngine.parseModel(any())).thenReturn(FlowDefModel.parse(mapper, + "{\"nodes\":[{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"一级\"}," + + "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"二级\"}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"n1\",\"to\":\"n2\"},{\"from\":\"n2\",\"to\":\"end\"}]}")); + when(taskRepository.findByInstanceIdOrderByIdAsc(INSTANCE_ID)).thenReturn(historyRows()); + + taskService.returnBack(SCH, TASK_ID, "n1", "再看一下", APPROVER); + + assertThat(instance.getStatus()).as("普通退回后办件仍是审批中,不是 50") + .isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode()); + verify(flowEngine).returnTo(eq(instance), eq(withNodes), eq(NODE), eq("n1"), any()); + verify(flowEngine, never()).startAdvance(any(), any()); + } + + // ==================== fixtures ==================== + + private FlwInstance approvingInstance() { + FlwInstance row = new FlwInstance(); + row.setId(INSTANCE_ID); + row.setSchId(SCH); + row.setDefId(60L); + row.setDefCode("flow-d14"); + row.setDefVersion(1); + row.setBizType("d14"); + row.setBizId("1"); + row.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); + row.setCurrentNodes(NODE); + row.setSubmitterId(SUBMITTER); + row.setSubmitterName("张三"); + row.setApplyAppKey("100018"); + row.setSourcePrincipalId("ub-app:18"); + row.setLegacySlot(1); + row.setVersion(0); + return row; + } + + private FlwTask pendingTask() { + FlwTask task = new FlwTask(); + task.setId(TASK_ID); + task.setInstanceId(INSTANCE_ID); + task.setSchId(SCH); + task.setNodeId(NODE); + task.setNodeName("二级审批"); + task.setAssigneeId(APPROVER); + task.setAssigneeName("李四"); + task.setStatus(FlwEnums.TaskStatus.PENDING.getCode()); + return task; + } + + /** 退回前已存在的历史行:一级同意、二级退回人本人待办、外加一条更早的通过行 */ + private List historyRows() { + List rows = new ArrayList<>(); + rows.add(historyTask(1L, "n1", FlwEnums.TaskStatus.APPROVED.getCode())); + rows.add(historyTask(2L, "n1", FlwEnums.TaskStatus.APPROVED.getCode())); + rows.add(historyTask(TASK_ID, NODE, FlwEnums.TaskStatus.RETURNED.getCode())); + return rows; + } + + private FlwTask historyTask(Long id, String nodeId, int status) { + FlwTask task = new FlwTask(); + task.setId(id); + task.setInstanceId(INSTANCE_ID); + task.setSchId(SCH); + task.setNodeId(nodeId); + task.setNodeName("节点" + nodeId); + task.setAssigneeId(APPROVER); + task.setAssigneeName("李四"); + task.setStatus(status); + return task; + } + + private FlwDef levelDef() { + FlwDef def = new FlwDef(); + def.setId(60L); + def.setSchId(SCH); + def.setFlowGuid("fg-d14"); + def.setCode("flow-d14"); + def.setName("D14 测试线"); + def.setVersion(1); + def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); + def.setContentJson(modelJson()); + return def; + } + + private FlowDefModel model() { + return FlowDefModel.parse(mapper, modelJson()); + } + + private String modelJson() { + return "{\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"一级\"}," + + "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"二级\"}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"n2\"}," + + "{\"from\":\"n2\",\"to\":\"end\"}]}"; + } + +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlSourceAuthorizationTest.java b/src/test/java/com/yunhai/flowcenter/service/FlSourceAuthorizationTest.java deleted file mode 100644 index a3a6687..0000000 --- a/src/test/java/com/yunhai/flowcenter/service/FlSourceAuthorizationTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.yunhai.flowcenter.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.yunhai.flowcenter.engine.*; -import com.yunhai.flowcenter.entity.*; -import com.yunhai.flowcenter.model.*; -import com.yunhai.flowcenter.repository.*; -import com.yunhai.platform.apistd.BizException; -import com.yunhai.platform.openapi.base.*; -import com.yunhai.platform.openapi.core.YschOpenCall; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.test.util.ReflectionTestUtils; -import java.util.*; -import java.util.concurrent.atomic.AtomicLong; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; -import static org.mockito.ArgumentMatchers.*; - -/** Real OAPI, source resolver, authorization, activation client and instance service. - * Only repositories, outbound UserBase transport, people resolver and engine are mocks. - * No real DB/transaction/unique-index claim. */ -class FlSourceAuthorizationTest { - static final String A="11111111-1111-1111-1111-111111111111"; - static final String B="22222222-2222-2222-2222-222222222222"; - final ObjectMapper mapper=new ObjectMapper(); - FlwInstanceRepository instances; FlwDefRepository defs; FlwUsageGrantRepository grants; - FlwTaskRepository tasks; FlwBranchRepository branches; - OpenApiAppClient apps; YschOpenCall outbound; UserBaseActivationClient activation; - ApplicationEventPublisher publisher; FlowEngine engine; FlowUsageGrantGuard guard; - FlowInstanceService lifecycle; OapiFlowService api; FlwDef def; - Map rows; - @BeforeEach void setup() throws Exception { - instances=mock(FlwInstanceRepository.class);defs=mock(FlwDefRepository.class); - grants=mock(FlwUsageGrantRepository.class);tasks=mock(FlwTaskRepository.class);branches=mock(FlwBranchRepository.class); - apps=mock(OpenApiAppClient.class);outbound=mock(YschOpenCall.class); - activation=new UserBaseActivationClient(outbound); - // Even an explicitly false legacy value cannot disable either gate. - ReflectionTestUtils.setField(activation,"enforce",false); - guard=new FlowUsageGrantGuard(grants,activation);ReflectionTestUtils.setField(guard,"appClient",apps); - when(apps.getAppByKey(anyString())).thenAnswer(c -> mapper.readTree("{\"id\":"+("app-b".equals(c.getArgument(0))?2:1)+",\"sourcePrincipalId\":\""+("app-b".equals(c.getArgument(0))?B:A)+"\",\"appCode\":\"test\"}")); - when(outbound.get(anyString(),anyString(),anyMap(),any())).thenAnswer(c -> { - Map q=c.getArgument(2); - return mapper.readTree("{\"appId\":"+q.get("appId")+",\"schoolId\":"+q.get("schoolId")+",\"activated\":true}"); - }); - when(grants.findBySchIdAndFlowGuidAndAppId(eq(27L),eq("flow"),anyInt())).thenAnswer(c -> { - int id=c.getArgument(2);FlwUsageGrant g=new FlwUsageGrant();g.setAppId(id);g.setSchId(27L);g.setFlowGuid("flow");g.setEnabled(1);g.setSourcePrincipalId(id==1?A:B);return Optional.of(g); - }); - def=new FlwDef();def.setId(7L);def.setSchId(27L);def.setFlowGuid("flow");def.setCode("test");def.setVersion(1);def.setStatus(1);def.setContentJson("{\"nodes\":[],\"edges\":[]}"); - when(defs.findById(7L)).thenReturn(Optional.of(def)); - when(defs.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(27L,"flow",1)).thenReturn(Optional.of(def)); - engine=mock(FlowEngine.class);publisher=mock(ApplicationEventPublisher.class); - when(engine.parseModel(any())).thenReturn(FlowDefModel.parse(mapper,def.getContentJson())); - lifecycle=new FlowInstanceService(instances,defs,tasks,branches,engine,publisher); - api=new OapiFlowService(defs,instances,tasks,mock(FlwCcRepository.class),mock(FlwCallbackRegRepository.class),lifecycle,mock(FlowTaskService.class),engine,mock(AssigneeResolver.class),mapper,mock(OpenApiUserClient.class),apps,mock(DiagramTokenService.class),mock(ObjectProvider.class)); - api.setUsageGrantGuard(guard); - ReflectionTestUtils.setField(api,"sourcePrincipalResolver",new FlowSourcePrincipalResolver(apps)); - rows=new HashMap<>();AtomicLong seq=new AtomicLong(100); - when(instances.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(anyLong(),anyString(),anyString(),anyString(),eq(1))) - .thenAnswer(c -> Optional.ofNullable(rows.get(c.getArgument(1)))); - when(instances.saveAndFlush(any())).thenAnswer(c -> {FlwInstance i=c.getArgument(0);if(i.getId()==null)i.setId(seq.incrementAndGet());rows.put(i.getSourcePrincipalId(),i);return i;}); - when(instances.findById(anyLong())).thenAnswer(c -> rows.values().stream().filter(i -> i.getId().equals(c.getArgument(0))).findFirst()); - when(instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(anyLong(),anyString(),anyString(),anyString())) - .thenAnswer(c -> Optional.ofNullable(rows.get(c.getArgument(1)))); - } - OapiModels.ApplyBody body() { - OapiModels.ApplyBody r=new OapiModels.ApplyBody();r.setFlowGuid("flow");r.setBizType("type");r.setBizId("same-id");r.setSubmitterId(5);return r; - } - FlowApplyRetryModels.ApplyRequestV2 v2(String token) { - FlowApplyRetryModels.ApplyRequestV2 r=new FlowApplyRetryModels.ApplyRequestV2();r.setFlowGuid("flow");r.setBizType("type");r.setBizId("same-id");r.setSubmitterId(5);r.setRequestToken(token);r.setAttempt(1);return r; - } - void noWrites() {verify(instances,never()).saveAndFlush(any());verifyNoInteractions(tasks,branches,engine,publisher);} - @Test void bothGatesRequiredAndSameSourceIsIdempotent() { - long a=api.apply(27L,"app-a",body()).getInstanceId(); - assertThat(api.apply(27L,"app-a",body()).getInstanceId()).isEqualTo(a); - assertThat(rows).hasSize(1);verify(engine,times(1)).startAdvance(any(),any()); - assertThat(rows.get(A).getSourcePrincipalId()).isEqualTo(A); - } - @Test void distinctSourcesCreateIndependentInstancesAndNeverRestartOthers() { - long a=api.apply(27L,"app-a",body()).getInstanceId();rows.get(A).setStatus(30); - long b=api.apply(27L,"app-b",body()).getInstanceId(); - assertThat(b).isNotEqualTo(a);assertThat(rows.get(A).getStatus()).isEqualTo(30); - assertThat(rows).hasSize(2); - assertThat(api.apply(27L,"app-a",body()).getInstanceId()).isEqualTo(a); - assertThat(rows.get(A).getStatus()).isEqualTo(10); - assertThat(rows.get(B).getId()).isEqualTo(b); - verify(instances,never()).findBySchIdAndBizTypeAndBizId(anyLong(),anyString(),anyString()); - } - @Test void missingGrantRejectsWithoutAnySideEffects() { - when(grants.findBySchIdAndFlowGuidAndAppId(anyLong(),anyString(),anyInt())).thenReturn(Optional.empty()); - assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); - } - @Test void inactiveOrRevokedSchoolRejectsEvenWithGrant() throws Exception { - when(outbound.get(anyString(),anyString(),anyMap(),any())).thenReturn(mapper.readTree("{\"appId\":1,\"schoolId\":27,\"activated\":false}")); - assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); - } - @Test void unavailableAuthorityRejects() { - when(outbound.get(anyString(),anyString(),anyMap(),any())).thenThrow(new RuntimeException("offline")); - assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); - } - @Test void malformedAuthorityResponseRejects() throws Exception { - when(outbound.get(anyString(),anyString(),anyMap(),any())).thenReturn(mapper.readTree("{\"appId\":2,\"schoolId\":27,\"activated\":true}")); - assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); - } - @Test void missingSchoolSourceOrGuardRejects() { - assertThatThrownBy(() -> api.apply(null,"app-a",body())).isInstanceOf(BizException.class); - assertThatThrownBy(() -> api.apply(0L,"app-a",body())).isInstanceOf(BizException.class); - assertThatThrownBy(() -> api.apply(27L,null,body())).isInstanceOf(BizException.class); - api.setUsageGrantGuard(null); - assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); - } - @Test void sourceLookupUnavailableOrUnknownRejects() throws Exception { - when(apps.getAppByKey("app-a")).thenReturn(mapper.readTree("{\"id\":1}")); - assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class); - when(apps.getAppByKey("app-a")).thenThrow(new RuntimeException("offline")); - assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); - } - @Test void mismatchedGrantSourceRejects() { - FlwUsageGrant g=new FlwUsageGrant();g.setEnabled(1);g.setSourcePrincipalId(B); - when(grants.findBySchIdAndFlowGuidAndAppId(anyLong(),anyString(),anyInt())).thenReturn(Optional.of(g)); - assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); - } - @Test void v2ExactReceiptSurvivesRevocationButNewCommandDoesNot() { - long id=api.applyV2(27L,"app-a",v2("token-1")).getInstanceId(); - when(grants.findBySchIdAndFlowGuidAndAppId(anyLong(),anyString(),anyInt())).thenReturn(Optional.empty()); - assertThat(api.applyV2(27L,"app-a",v2("token-1")).getInstanceId()).isEqualTo(id); - assertThatThrownBy(() -> api.applyV2(27L,"app-a",v2("new-token"))).isInstanceOf(BizException.class); - assertThatThrownBy(() -> api.applyV2(27L,"app-b",v2("token-1"))).isInstanceOf(BizException.class); - } - @Test void v2IndependentSourcesAndConflictRetryUseScopedLookup() { - long a=api.applyV2(27L,"app-a",v2("a-token")).getInstanceId(); - long b=api.applyV2(27L,"app-b",v2("b-token")).getInstanceId();assertThat(a).isNotEqualTo(b); - verify(instances,never()).findBySchIdAndBizTypeAndBizId(anyLong(),anyString(),anyString()); - } - @Test void v1ConflictRecoveryCannotReturnOtherSourcesRow() { - long a=api.apply(27L,"app-a",body()).getInstanceId(); - doAnswer(new org.mockito.stubbing.Answer() {boolean first=true;public FlwInstance answer(org.mockito.invocation.InvocationOnMock c) { - if(first){first=false;throw new DataIntegrityViolationException("race");}FlwInstance i=c.getArgument(0);i.setId(999L);rows.put(i.getSourcePrincipalId(),i);return i; - }}).when(instances).saveAndFlush(any()); - assertThat(api.apply(27L,"app-b",body()).getInstanceId()).isNotEqualTo(a); - } - @Test void otherSchoolOrFlowCannotUseGrant() { - assertThatThrownBy(() -> api.apply(28L,"app-a",body())).isInstanceOf(BizException.class); - OapiModels.ApplyBody r=body();r.setFlowGuid("not-granted"); - assertThatThrownBy(() -> api.apply(27L,"app-a",r)).isInstanceOf(BizException.class);noWrites(); - } - @Test void v2ConflictRetryNeverRecoversAnotherSource() { - long a=api.applyV2(27L,"app-a",v2("a-token")).getInstanceId(); - doAnswer(new org.mockito.stubbing.Answer() {boolean first=true;public FlwInstance answer(org.mockito.invocation.InvocationOnMock c) { - if(first){first=false;throw new DataIntegrityViolationException("race");}FlwInstance i=c.getArgument(0);i.setId(999L);rows.put(i.getSourcePrincipalId(),i);return i; - }}).when(instances).saveAndFlush(any()); - FlowApplyRetryModels.ApplyResultV2 result=api.applyV2(27L,"app-b",v2("b-token")); - assertThat(result.getInstanceId()).isNotEqualTo(a); - assertThat(result.isDuplicated()).isFalse(); - } - @Test void businessReadsAndExplicitInstanceAccessAreSourceScopedEvenWithFormalModeOff() throws Exception { - long a=api.apply(27L,"app-a",body()).getInstanceId(); - long b=api.apply(27L,"app-b",body()).getInstanceId(); - FlowOapiSourceScope scope=new FlowOapiSourceScope(new FlowSourcePrincipalResolver(apps),instances,tasks); - org.springframework.mock.web.MockHttpServletRequest req=new org.springframework.mock.web.MockHttpServletRequest("GET","/oapi/flow/instance"); - req.setAttribute("yh.app.auth","app-b"); - scope.preHandle(req,new org.springframework.mock.web.MockHttpServletResponse(),new Object()); - org.springframework.web.context.request.RequestContextHolder.setRequestAttributes(new org.springframework.web.context.request.ServletRequestAttributes(req)); - try {assertThat(api.instanceByBiz(27L,"type","same-id").getInstanceId()).isEqualTo(b);} - finally {org.springframework.web.context.request.RequestContextHolder.resetRequestAttributes();} - req.setParameter("instanceId",String.valueOf(a)); - assertThatThrownBy(() -> scope.preHandle(req,new org.springframework.mock.web.MockHttpServletResponse(),new Object())).isInstanceOf(BizException.class); - } - @Test void v1UsesExistingCallbackIdentityFieldsWithoutChangingEnvelopeContract() { - api.apply(27L,"app-a",body()); - assertThat(rows.get(A).getApplyRequestToken()).isNotBlank(); - assertThat(rows.get(A).getApplyPayloadHash()).hasSize(64); - assertThat(rows.get(A).getApplyRound()).isEqualTo(1); - } - @Test void legacyWriterCannotDuplicateAnExistingFormalCoordinate() { - FlwInstance formal=new FlwInstance();formal.setId(99L);formal.setSourcePrincipalId(A);formal.setLegacySlot(null); - when(instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(27L,A,"type","same-id")) - .thenReturn(Optional.of(formal)); - assertThatThrownBy(() -> api.apply(27L,"app-a",body())).isInstanceOf(BizException.class);noWrites(); - } -} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlSourceIsolationTest.java b/src/test/java/com/yunhai/flowcenter/service/FlSourceIsolationTest.java new file mode 100644 index 0000000..eae2d76 --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlSourceIsolationTest.java @@ -0,0 +1,240 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowDefModel; +import com.yunhai.flowcenter.engine.FlowEngine; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.repository.FlwBranchRepository; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; + +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.anyLong; +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; + +/** + * 跨应用业务键隔离(A2/F02,2026-10-29)。 + * + *

D11 的业务对象身份=可信来源应用+学校+bizType+bizId。旧实现按裸坐标 + * {@code findBySchIdAndBizTypeAndBizId(schId,bizType,bizId)} 复用实例,于是应用 B 用与 + * 应用 A 相同的业务键会拿到 A 的 instanceId 并以 {@code duplicated=true} 当成功返回。

+ * + *

本类用真实 {@code FlowInstanceService}(mock 仅仓储/引擎/发布器)钉住三条事实:

+ *
    + *
  1. 同来源同业务键 → 同一个实例(合法幂等重投不被打断);
  2. + *
  3. 异来源同业务键 → 各自建独立实例,绝不回读对方 id;
  4. + *
  5. 缺可信来源 → 直接拒,不"猜归属"也不建归属不明的行。
  6. + *
+ */ +@DisplayName("业务坐标来源分区:同来源幂等、跨来源隔离(F02/D11)") +class FlSourceIsolationTest { + @Test void credentialRotationDoesNotChangeTheSourceIdentity() { + FlwInstance existing = ownedInstance(500L, SOURCE_A, FlwEnums.InstanceStatus.APPROVING.getCode()); + existing.setApplyAppKey("retired-key"); given(SOURCE_A, existing); + assertThat(service.apply(command(SOURCE_A)).getInstanceId()).isEqualTo(500L); + verify(instanceRepository, never()).saveAndFlush(any()); + verify(flowEngine, never()).startAdvance(any(), any()); + } + @Test void missingCreationGuardCannotCreateOrRestart() { + org.springframework.test.util.ReflectionTestUtils.setField(service, "creationGrants", null); + assertThatThrownBy(() -> service.apply(command(SOURCE_A))).isInstanceOf(BizException.class) + .hasMessageContaining("门禁"); + verify(instanceRepository, never()).saveAndFlush(any()); + verify(flowEngine, never()).startAdvance(any(), any()); + } + @Test void formalHistoryCannotBeBypassedThroughLegacySlot() { + when(instanceRepository.formalIdsForUpdate(SCH, SOURCE_A, BIZ_TYPE, BIZ_ID)) + .thenReturn(java.util.Collections.singletonList(700L)); + assertThatThrownBy(() -> service.apply(command(SOURCE_A))).isInstanceOf(BizException.class) + .hasMessageContaining("正式命令"); + assertThatThrownBy(() -> service.applyGuarded(command(SOURCE_A), + new FlowInstanceService.ApplyRetryPolicy("new", 1, false))).isInstanceOf(BizException.class); + verify(instanceRepository, never()).saveAndFlush(any()); + verify(flowEngine, never()).startAdvance(any(), any()); + } + + private static final long SCH = 7L; + private static final String BIZ_TYPE = "notice"; + private static final String BIZ_ID = "3301"; + private static final String FLOW_GUID = "fg-source"; + private static final String SOURCE_A = "ub-app:11"; + private static final String SOURCE_B = "ub-app:22"; + + private final ObjectMapper mapper = new ObjectMapper(); + + private FlwInstanceRepository instanceRepository; + private FlwDefRepository defRepository; + private FlowEngine flowEngine; + private ApplicationEventPublisher eventPublisher; + private FlowInstanceService service; + + @BeforeEach + void setUp() { + instanceRepository = mock(FlwInstanceRepository.class); + defRepository = mock(FlwDefRepository.class); + flowEngine = mock(FlowEngine.class); + eventPublisher = mock(ApplicationEventPublisher.class); + service = new FlowInstanceService(instanceRepository, defRepository, + mock(FlwTaskRepository.class), mock(FlwBranchRepository.class), flowEngine, eventPublisher); + org.springframework.test.util.ReflectionTestUtils.setField(service, "commandSources", mock(com.yunhai.flowcenter.repository.FlwCommandSourceRepository.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service, "creationGrants", mock(FlowUsageGrantGuard.class)); + when(defRepository.findById(anyLong())).thenReturn(Optional.of(publishedDef())); + when(flowEngine.parseModel(any())).thenReturn(FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}")); + when(instanceRepository.saveAndFlush(any())).thenAnswer(call -> { + FlwInstance row = call.getArgument(0); + row.setId(row.getId() == null ? 1000L + System.nanoTime() % 1000 : row.getId()); + return row; + }); + } + + @Test + @DisplayName("同来源、同业务键、在途:回读原实例(合法幂等重投仍生效)") + void sameSourceReturnsOriginalInstance() { + FlwInstance existing = ownedInstance(500L, SOURCE_A, FlwEnums.InstanceStatus.APPROVING.getCode()); + given(SOURCE_A, existing); + + FlowInstanceService.ApplyOutcome outcome = service.apply(command(SOURCE_A)); + + assertThat(outcome.getInstanceId()).isEqualTo(500L); + assertThat(outcome.isDuplicated()).isTrue(); + verify(instanceRepository, never()).saveAndFlush(any(FlwInstance.class)); + verify(flowEngine, never()).startAdvance(any(), any()); + } + + @Test + @DisplayName("异来源、同学校同业务键:本来源无行→建独立实例,不泄漏 A 的 instanceId") + void otherSourceBuildsItsOwnInstance() { + FlwInstance instanceOfA = ownedInstance(500L, SOURCE_A, FlwEnums.InstanceStatus.APPROVING.getCode()); + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + SCH, SOURCE_B, BIZ_TYPE, BIZ_ID, 1)).thenReturn(Optional.empty()); + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + SCH, SOURCE_A, BIZ_TYPE, BIZ_ID, 1)).thenReturn(Optional.of(instanceOfA)); + + FlowInstanceService.ApplyOutcome outcomeOfB = service.apply(command(SOURCE_B)); + + assertThat(outcomeOfB.getInstanceId()).isNotEqualTo(500L); + assertThat(outcomeOfB.isDuplicated()).isFalse(); + ArgumentCaptor created = ArgumentCaptor.forClass(FlwInstance.class); + verify(instanceRepository).saveAndFlush(created.capture()); + assertThat(created.getValue().getSourcePrincipalId()).isEqualTo(SOURCE_B); + assertThat(created.getValue().getLegacySlot()).isEqualTo(1); + // A 的实例一个字节都没被动过 + assertThat(instanceOfA.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode()); + verify(instanceRepository, never()).findById(500L); + } + + @Test + @DisplayName("缺可信来源:拒绝,既不建件也不按裸坐标回读他人实例") + void missingSourceFailsClosed() { + assertThatThrownBy(() -> service.apply(command(null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("来源"); + assertThatThrownBy(() -> service.apply(command(" "))) + .isInstanceOf(BizException.class); + verify(instanceRepository, never()).saveAndFlush(any(FlwInstance.class)); + verify(instanceRepository, never()) + .findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("v2 守卫路径同样按来源分区:缺来源即拒,异来源建独立实例") + void guardedApplyIsAlsoSourceScoped() { + assertThatThrownBy(() -> service.applyGuarded(command(null), + new FlowInstanceService.ApplyRetryPolicy("tok-1", 1, false))) + .isInstanceOf(BizException.class) + .hasMessageContaining("来源"); + + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + eq(SCH), eq(SOURCE_B), eq(BIZ_TYPE), eq(BIZ_ID), eq(1))).thenReturn(Optional.empty()); + + FlowInstanceService.GuardedOutcome outcome = service.applyGuarded(command(SOURCE_B), + new FlowInstanceService.ApplyRetryPolicy("tok-1", 1, false)); + + assertThat(outcome.duplicated).isFalse(); + assertThat(outcome.branch).isEqualTo("CREATED"); + ArgumentCaptor created = ArgumentCaptor.forClass(FlwInstance.class); + verify(instanceRepository).saveAndFlush(created.capture()); + assertThat(created.getValue().getSourcePrincipalId()).isEqualTo(SOURCE_B); + } + + @Test + @DisplayName("同来源终态件重报:走既有 R4 重启推进(复用同一行,不是新实例)") + void sameSourceTerminalRestartKeepsSameRow() { + FlwInstance terminal = ownedInstance(500L, SOURCE_A, FlwEnums.InstanceStatus.REJECTED.getCode()); + given(SOURCE_A, terminal); + + FlowInstanceService.ApplyOutcome outcome = service.apply(command(SOURCE_A)); + + assertThat(outcome.getInstanceId()).isEqualTo(500L); + assertThat(outcome.isDuplicated()).isTrue(); + assertThat(terminal.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode()); + ArgumentCaptor flushed = ArgumentCaptor.forClass(FlwInstance.class); + verify(instanceRepository).saveAndFlush(flushed.capture()); + assertThat(flushed.getValue()).as("复用同一行,不新建实例").isSameAs(terminal); + verify(flowEngine, times(1)).startAdvance(any(), any()); + } + + // ==================== fixtures ==================== + + private void given(String source, FlwInstance existing) { + when(instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot( + SCH, source, BIZ_TYPE, BIZ_ID, 1)).thenReturn(Optional.of(existing)); + } + + private FlowInstanceService.ApplyCommand command(String source) { + FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( + SCH, publishedDef(), BIZ_TYPE, BIZ_ID, "测试事项", "/notice/detail?id=3301", "{}", + 1001, "张三", "{}", "100022", "notice", null); + cmd.sourcePrincipalId = source; + return cmd; + } + + private FlwDef publishedDef() { + FlwDef def = new FlwDef(); + def.setId(60L); + def.setSchId(SCH); + def.setFlowGuid(FLOW_GUID); + def.setCode("flow-abc"); + def.setName("隔离测试线"); + def.setVersion(2); + def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); + def.setContentJson("{\"nodes\":[],\"edges\":[]}"); + return def; + } + + private FlwInstance ownedInstance(Long id, String source, int status) { + FlwInstance instance = new FlwInstance(); + instance.setId(id); + instance.setSchId(SCH); + instance.setDefId(60L); + instance.setDefCode("flow-abc"); + instance.setDefVersion(2); + instance.setBizType(BIZ_TYPE); + instance.setBizId(BIZ_ID); + instance.setStatus(status); + instance.setSourcePrincipalId(source); + instance.setLegacySlot(1); + instance.setSubmitterId(1001); + instance.setSubmitterName("张三"); + instance.setVersion(0); + return instance; + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java b/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java index f0bf0f5..635bac2 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java @@ -9,6 +9,7 @@ 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 com.yunhai.platform.utils.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -29,24 +30,27 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.assertj.core.api.ThrowableAssert.catchThrowableOfType; /** - * 应用使用许可闸与恢复感知判定的单测(2026-10-25 T01)。 + * 应用使用许可闸(T01 + F01 整改 2026-10-29)。 * - *

钉住的不变量:

- *
    - *
  • 新发起必须有生效许可:无行/已撤销/主体解析失败/未在学校开通/核验不可达——五路全拒;
  • - *
  • 已受理命令回执可恢复:同 token、retryOnly 探询、在途/通过幂等、陈旧轮次四类 - * 回执分支不触许可闸(P0-3 收口顺序:认证 → 回执 → 未命中才查资格);
  • - *
  • appKey 不是主体:appKey 可重置/复用,许可与审计挂 UserBase 稳定主键 appId。
  • - *
+ *

本轮钉住的核心变化:闸门不再有任何直通分支。原 + * {@code if (guard == null || !guard.isEnforce() || appKey == null) return;} 让"灰度开关关闭 / + * 闸门未装配 / 缺验签主体"三种情况静默放行,正是 Codex 终审 F01 指出的 P1。 + * 现在 {@code yh.flow.school-activation-enforce} 这个开关已从代码里删除,四类缺证一律拒绝。

+ * + *

同时保留 P0-3 的收口顺序:已受理命令的回执恢复不回读本表——撤权不能没收已经受理的 + * 申报回执(判定逻辑见 {@code OapiFlowService#legacyApplyIsReceiptOnly / guardedApplyIsReceiptOnly}, + * 由 {@code FlApplyAssignAndVariableTest} 与本类下方用例分别钉住)。

*/ -@DisplayName("应用使用许可闸 FlowUsageGrantGuard + apply 恢复感知判定(T01)") +@DisplayName("应用使用许可闸:fail-closed + 恢复感知(F01)") class FlUsageGrantTest { private static final long SCH = 27L; private static final String FLOW = "fg-1"; private static final String APP_KEY = "100018"; + private static final int APP_ID = 5001; private FlwUsageGrantRepository grantRepository; private OpenApiAppClient appClient; @@ -63,28 +67,69 @@ class FlUsageGrantTest { 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 enforceOffCannotBypassAuthorization() { - ReflectionTestUtils.setField(activationClient, "enforce", false); + @DisplayName("appKey → UserBase 稳定主体:来源串挂在该主体上,不直接使用 appKey") + void resolvesStablePrincipal() { + when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(APP_ID)); - assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)).isInstanceOf(BizException.class); - verify(grantRepository, never()).findBySchIdAndFlowGuidAndAppId(anyLong(), anyString(), anyInt()); + FlowSourcePrincipalResolver.Principal source = + new FlowSourcePrincipalResolver(appClient).resolve(APP_KEY); + + assertThat(source.appId).isEqualTo(APP_ID); + assertThat(source.id).isEqualTo(FlowSourcePrincipalResolver.SOURCE_PREFIX + APP_ID); + } + + @Test + @DisplayName("缺验签 appKey:拒绝(不降级为「无来源」放行)") + void blankAppKeyRejected() { + FlowSourcePrincipalResolver resolver = new FlowSourcePrincipalResolver(appClient); + + assertThatThrownBy(() -> resolver.resolve(null)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> resolver.resolve(" ")).isInstanceOf(BizException.class); + } + + @Test + @DisplayName("UserBase 查无该 appKey:拒绝(凭据索引不构成主体)") + void unresolvablePrincipalRejected() { + when(appClient.getAppByKey(APP_KEY)).thenReturn(null); + + assertThatThrownBy(() -> new FlowSourcePrincipalResolver(appClient).resolve(APP_KEY)) + .isInstanceOf(BizException.class) + .hasMessageContaining("主体"); + } + + @Test + @DisplayName("UserBase 出站异常:拒绝而不是放行") + void principalLookupFailureRejected() { + when(appClient.getAppByKey(APP_KEY)).thenThrow(new RuntimeException("connection refused")); + + assertThatThrownBy(() -> new FlowSourcePrincipalResolver(appClient).resolve(APP_KEY)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不可用"); + } + + // ==================== 许可闸 ==================== + + @Test + @DisplayName("有生效许可 + 本校已开通:放行,不再出站重复解析主体") + void grantedAndActivatedPasses() { + givenGrant(grant(APP_ID, 1)); + stubActivation(APP_ID, true); + + guard.requireNewApplyGrant(SCH, FLOW, principal()); + + verify(grantRepository).findBySchIdAndFlowGuidAndAppId(SCH, FLOW, APP_ID); } @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)) + givenGrant(null); + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, principal())) .isInstanceOf(BizException.class) .hasMessageContaining("使用许可"); } @@ -92,71 +137,95 @@ class FlUsageGrantTest { @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))); + givenGrant(grant(APP_ID, 0)); - assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, principal())) .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 平台资源同口径):拒绝") + @DisplayName("持本地许可但未在该校开通:拒绝(grant 与学校开通是两重的独立事实)") 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); + givenGrant(grant(APP_ID, 1)); + stubActivation(APP_ID, false); - assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, principal())) .isInstanceOf(BizException.class) .hasMessageContaining("开通"); } @Test - @DisplayName("UserBase 资格核验不可达:fail-closed 拒绝(不降级为仅本地 grant 即放行)") + @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))); + givenGrant(grant(APP_ID, 1)); when(openCall.get(anyString(), anyString(), any(), any())) .thenThrow(new RuntimeException("connection refused")); - assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, principal())) .isInstanceOf(BizException.class) .hasMessageContaining("不可用"); } @Test - @DisplayName("appKey 解析不出稳定主体:拒绝(凭据索引不构成主体)") - void unresolvablePrincipalRejected() { - when(appClient.getAppByKey(APP_KEY)).thenReturn(null); + @DisplayName("开通核验返回异常形状:同样拒绝,不当成未开通也不当成已开通") + void activationMalformedResponseFailClosed() { + givenGrant(grant(APP_ID, 1)); + when(openCall.get(anyString(), anyString(), any(), any())) + .thenReturn(mapper.valueToTree(new HashMap())); - assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, APP_KEY)) - .isInstanceOf(BizException.class) - .hasMessageContaining("主体"); + BizException failure = catchThrowableOfType( + () -> guard.requireNewApplyGrant(SCH, FLOW, principal()), BizException.class); + + assertThat(failure).isNotNull(); + assertThat(failure.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); } - // ==================== apply 恢复感知判定(OapiFlowService 纯函数) ==================== + @Test + @DisplayName("缺学校/流程线/主体任一:拒绝(判定条件不齐不等于放行)") + void incompleteCoordinatesRejected() { + givenGrant(grant(APP_ID, 1)); + FlowSourcePrincipalResolver.Principal source = principal(); + + assertThatThrownBy(() -> guard.requireNewApplyGrant(null, FLOW, source)) + .isInstanceOf(BizException.class); + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, " ", source)) + .isInstanceOf(BizException.class); + assertThatThrownBy(() -> guard.requireNewApplyGrant(SCH, FLOW, null)) + .isInstanceOf(BizException.class); + } @Test - @DisplayName("v1 判定:在途/通过=回执放行;无实例/终态件=需许可") + @DisplayName("旧灰度开关已从代码中删除:没有任何方法能关掉开通核验") + void bypassSwitchIsGone() { + boolean switchStillExists = false; + for (java.lang.reflect.Method method : UserBaseActivationClient.class.getMethods()) { + if ("isEnforce".equals(method.getName()) || "isAppActivatedAtSchool".equals(method.getName())) { + switchStillExists = true; + } + } + assertThat(switchStillExists) + .as("enforce 开关方法必须已删除,否则默认关闭即可整体绕过授权(F01)") + .isFalse(); + boolean switchFieldStillDeclared = false; + for (java.lang.reflect.Field field : UserBaseActivationClient.class.getDeclaredFields()) { + if ("enforce".equals(field.getName())) { + switchFieldStillDeclared = true; + } + } + assertThat(switchFieldStillDeclared).as("enforce 字段本身必须已删除").isFalse(); + } + + // ==================== 恢复感知判定(OapiFlowService 纯函数) ==================== + + @Test + @DisplayName("v1 判定:在途(10)/退回待修改(50)/通过(20)=回执放行;无实例/终件=需许可") void legacyApplyReceiptDecision() { assertThat(OapiFlowService.legacyApplyIsReceiptOnly(null)).isFalse(); assertThat(OapiFlowService.legacyApplyIsReceiptOnly( instance(FlwEnums.InstanceStatus.APPROVING.getCode()))).isTrue(); + assertThat(OapiFlowService.legacyApplyIsReceiptOnly( + instance(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()))).isTrue(); assertThat(OapiFlowService.legacyApplyIsReceiptOnly( instance(FlwEnums.InstanceStatus.COMPLETED.getCode()))).isTrue(); assertThat(OapiFlowService.legacyApplyIsReceiptOnly( @@ -166,7 +235,7 @@ class FlUsageGrantTest { } @Test - @DisplayName("v2 判定:同 token/retryOnly/在途/陈旧轮次=回执;无实例或更高轮次=新申报") + @DisplayName("v2 判定:同 token/retryOnly/在途/退回待修改/陈旧轮次=回执;无实例或更高轮次=新申报") void guardedApplyReceiptDecision() { FlwInstance terminal = instance(FlwEnums.InstanceStatus.REJECTED.getCode()); terminal.setApplyRequestToken("tok-a"); @@ -177,6 +246,8 @@ class FlUsageGrantTest { 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( + instance(FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.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(); @@ -185,36 +256,48 @@ class FlUsageGrantTest { // ==================== 管理面:授予/撤销 ==================== @Test - @DisplayName("授予:appKey 当场解析成稳定主体落库;重申复用同一行") + @DisplayName("授予:appKey 当场解析成稳定主体、绑定来源串并落库;重申复用同一行") void grantResolvesPrincipalAndUpserts() { - // 本测主体解析与 upsert,不开资格闸(开通闸已由 Guard 用例钉住) - ReflectionTestUtils.setField(activationClient, "enforce", false); - FlowUsageGrantAdminService admin = - new FlowUsageGrantAdminService(grantRepository, activationClient); + 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)); - stubActivation(5001, true); + when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(APP_ID)); + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, APP_ID)).thenReturn(Optional.empty()); + when(grantRepository.save(any())).thenAnswer(call -> call.getArgument(0)); + stubActivation(APP_ID, true); FlwUsageGrant saved = admin.grant(SCH, FLOW, APP_KEY, "首次授权", "admin"); - assertThat(saved.getAppId()).isEqualTo(5001); + assertThat(saved.getAppId()).isEqualTo(APP_ID); assertThat(saved.getEnabled()).isEqualTo(1); assertThat(saved.getAppKeySnapshot()).isEqualTo(APP_KEY); + assertThat(saved.getSourcePrincipalId()) + .isEqualTo(FlowSourcePrincipalResolver.sourcePrincipalIdOf(APP_ID)); + } + + @Test + @DisplayName("授予:应用未在该校开通即拒(许可不发到未开通的应用上)") + void grantRequiresSchoolActivation() { + FlowUsageGrantAdminService admin = new FlowUsageGrantAdminService(grantRepository, activationClient); + ReflectionTestUtils.setField(admin, "appClient", appClient); + when(appClient.getAppByKey(APP_KEY)).thenReturn(registration(APP_ID)); + stubActivation(APP_ID, false); + + assertThatThrownBy(() -> admin.grant(SCH, FLOW, APP_KEY, null, "admin")) + .isInstanceOf(BizException.class) + .hasMessageContaining("开通"); + verify(grantRepository, never()).save(any()); } @Test @DisplayName("撤销:enabled=0 行保留(审计与幂等判据不删)") void revokeKeepsRow() { - FlowUsageGrantAdminService admin = - new FlowUsageGrantAdminService(grantRepository, activationClient); - FlwUsageGrant existing = grant(5001, 1); - when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, 5001)) + FlowUsageGrantAdminService admin = new FlowUsageGrantAdminService(grantRepository, activationClient); + FlwUsageGrant existing = grant(APP_ID, 1); + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, APP_ID)) .thenReturn(Optional.of(existing)); - when(grantRepository.save(any())).thenAnswer(c -> c.getArgument(0)); + when(grantRepository.save(any())).thenAnswer(call -> call.getArgument(0)); - FlwUsageGrant saved = admin.revoke(SCH, FLOW, 5001, "违规使用", "admin"); + FlwUsageGrant saved = admin.revoke(SCH, FLOW, APP_ID, "违规使用", "admin"); assertThat(saved.getEnabled()).isEqualTo(0); assertThat(saved.getId()).isNotNull(); @@ -222,10 +305,19 @@ class FlUsageGrantTest { // ==================== fixtures ==================== + private FlowSourcePrincipalResolver.Principal principal() { + return new FlowSourcePrincipalResolver.Principal( + FlowSourcePrincipalResolver.sourcePrincipalIdOf(APP_ID), APP_ID, APP_KEY, "flow-test"); + } + + private void givenGrant(FlwUsageGrant grant) { + when(grantRepository.findBySchIdAndFlowGuidAndAppId(SCH, FLOW, APP_ID)) + .thenReturn(Optional.ofNullable(grant)); + } + private JsonNode registration(int appId) { Map row = new HashMap<>(); row.put("id", appId); - row.put("sourcePrincipalId", "11111111-1111-1111-1111-111111111111"); row.put("appKey", APP_KEY); row.put("appName", "测试应用"); return mapper.valueToTree(row); @@ -246,9 +338,9 @@ class FlUsageGrantTest { g.setSchId(SCH); g.setFlowGuid(FLOW); g.setAppId(appId); + g.setSourcePrincipalId(FlowSourcePrincipalResolver.sourcePrincipalIdOf(appId)); g.setAppKeySnapshot(APP_KEY); g.setEnabled(enabled); - g.setSourcePrincipalId("11111111-1111-1111-1111-111111111111"); return g; } @@ -259,4 +351,5 @@ class FlUsageGrantTest { i.setStatus(status); return i; } + } diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java index eea6fc4..889ab03 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java @@ -149,10 +149,12 @@ class FlowDefAdminServiceSaveTest { } @Test - void 新建_appKey不传_必填被拦() { - BizException e = assertThrows(BizException.class, - () -> service.save(SCH, null, "work-leave", "请假", JSON, null, " ", null)); - assertTrue(e.getMessage().contains("归属应用必填")); + void 新建_appKey可选_不伪造归属() { + FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, null, " ", null); + assertNull(saved.getAppKey()); + assertEquals(SCH, saved.getSchId()); + assertEquals(1, saved.getVersion()); + verify(defRepository).save(saved); } @Test @@ -167,15 +169,15 @@ class FlowDefAdminServiceSaveTest { } @Test - void 升版本_源线appKey为空且不传_必填被拦() { + void 升版本_源线appKey为空且不传_保留独立流程线() { // 存量 NULL 线(D2 不兜底的 19 条):第一次升版本被强制补填——随编辑自然收敛 when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G")) .thenReturn(Optional.of(def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode()))); when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4); - BizException e = assertThrows(BizException.class, - () -> service.save(SCH, null, "work-leave", "请假", JSON, "G", null, null)); - assertTrue(e.getMessage().contains("归属应用必填")); + FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, "G", null, null); + assertNull(saved.getAppKey()); assertEquals("G", saved.getFlowGuid()); + assertEquals(5, saved.getVersion()); verify(defRepository).save(saved); } @Test @@ -189,13 +191,13 @@ class FlowDefAdminServiceSaveTest { } @Test - void 草稿直改_源行appKey为空且不传_必填被拦() { + void 草稿直改_源行appKey为空且不传_不制造归属() { FlwDef draft = def(9L, "G", "work-leave", 5, FlwEnums.DefStatus.DRAFT.getCode()); when(defRepository.findById(9L)).thenReturn(Optional.of(draft)); - BizException e = assertThrows(BizException.class, - () -> service.save(SCH, 9L, "work-leave", "请假", JSON, null, null, null)); - assertTrue(e.getMessage().contains("归属应用必填")); + FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", JSON, null, null, null); + assertSame(draft, saved); assertNull(saved.getAppKey()); + assertEquals(5, saved.getVersion()); verify(defRepository).save(draft); } @Test diff --git a/src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java b/src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java index 6f6c7e3..587a2c8 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java +++ b/src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java @@ -32,7 +32,7 @@ import static org.mockito.Mockito.*; @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"; + static final String A="ub-app:18", B="ub-app:19"; @Autowired FormalApplyCommandService service; @Autowired FormalPayloadCanonicalizer hashes; @Autowired FlwApplyCommandRepository commands; @@ -261,9 +261,9 @@ class FormalApplyCommandIT extends FlowTestSupport { } @Test void missingOrRecreatedSourceNeverInheritsHistoricalCommandsOrGrants() throws Exception { service.submit(1L,"100018",request("r")); - doReturn(mapper.readTree("{\"id\":18}")).when(apps).getAppByKey("100018"); + doReturn(mapper.readTree("{}")).when(apps).getAppByKey("100018"); assertThrows(RuntimeException.class,()->service.query(1L,"100018","r",null)); - doReturn(mapper.readTree("{\"id\":18,\"sourcePrincipalId\":\""+B+"\"}")).when(apps).getAppByKey("100018"); + doReturn(mapper.readTree("{\"id\":20}")).when(apps).getAppByKey("100018"); assertEquals("NOT_FOUND",service.query(1L,"100018","r",null).getState()); assertEquals("USAGE_DENIED",service.submit(1L,"100018",request("new")).getRejectCode()); } diff --git a/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java index 7416516..b0e2050 100644 --- a/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java @@ -42,14 +42,12 @@ class OapiFlowApplyDefVersionTest { private static final Long SCH_ID = 1L; private static final String FLOW_GUID = "g-assign"; - private static final String SOURCE_ID = "11111111-2222-3333-4444-555555555555"; private FlwDefRepository defRepository; private FlowInstanceService instanceService; private AssigneeResolver assigneeResolver; private OapiFlowService service; private FlwInstanceRepository instances; - private OpenApiAppClient appClient; @BeforeEach @SuppressWarnings("unchecked") @@ -58,29 +56,24 @@ class OapiFlowApplyDefVersionTest { instanceService = mock(FlowInstanceService.class); assigneeResolver = mock(AssigneeResolver.class); instances = mock(FlwInstanceRepository.class); - appClient = mock(OpenApiAppClient.class); - com.fasterxml.jackson.databind.node.ObjectNode registered = - new ObjectMapper().createObjectNode() - .put("id", 100009) - .put("appCode", "flow-center") - .put("sourcePrincipalId", SOURCE_ID); - when(appClient.getAppByKey(any())).thenReturn(registered); service = new OapiFlowService(defRepository, instances, mock(FlwTaskRepository.class), mock(FlwCcRepository.class), mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class), mock(FlowEngine.class), assigneeResolver, new ObjectMapper(), - mock(OpenApiUserClient.class), appClient, + mock(OpenApiUserClient.class), mock(OpenApiAppClient.class), mock(DiagramTokenService.class), mock(ObjectProvider.class)); - // F01:来源解析与使用许可在真实装配中由 Spring 注入,单测按同一契约显式装配 - org.springframework.test.util.ReflectionTestUtils.setField( - service, "sourcePrincipalResolver", new FlowSourcePrincipalResolver(appClient)); - FlowUsageGrantGuard guard = mock(FlowUsageGrantGuard.class); - when(guard.requireNewApplyGrant(anyLong(), any(), - any(FlowSourcePrincipalResolver.Principal.class))).thenReturn(100009); - service.setUsageGrantGuard(guard); // 本校校验在单测里关闭(@Value 字段未注入=默认 false),引擎出参给一个既有办件 when(instanceService.apply(any(FlowInstanceService.ApplyCommand.class))) .thenReturn(new FlowInstanceService.ApplyOutcome(500L, false)); + // F01/F02:来源主体解析与使用许可闸在生产装配里必然存在;单测手工构造服务时必须显式注入 + // 测试替身,否则 apply 会因"闸门未装配/来源不可解析"被 fail-closed 拒(那是期望行为,不是靶子)。 + FlowSourcePrincipalResolver stubPrincipals = mock(FlowSourcePrincipalResolver.class); + when(stubPrincipals.resolve(org.mockito.ArgumentMatchers.anyString())) + .thenReturn(new FlowSourcePrincipalResolver.Principal( + "ub-app:18", 18, "100018", "flow-test")); + org.springframework.test.util.ReflectionTestUtils.setField(service, "sourcePrincipalResolver", stubPrincipals); + service.setUsageGrantGuard(mock(FlowUsageGrantGuard.class)); + } private static FlwDef publishedDef(int version) { diff --git a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java index e71bcf6..61f1ba9 100644 --- a/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java +++ b/src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java @@ -33,6 +33,11 @@ import java.util.UUID; public abstract class FlowTestSupport { public static final long SCH = 1L; + /** + * 可信来源主体(D11/F02):业务坐标的分区维度。真实链路由服务端从验签 appKey 经 + * UserBase 应用注册表解析,测试里显式给定同一个值。 + */ + public static final String SOURCE = "ub-app:100001"; @Autowired protected FlwDefRepository defRepository;