在 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 派生方法属另一实现线)
This commit is contained in:
@@ -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' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||
# 为真实 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" \
|
||||
|
||||
+1
-11
@@ -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 已完成验证。
|
||||
|
||||
+148
-170
@@ -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);
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -125,6 +125,21 @@ public class FlowAdminController {
|
||||
return usageGrantAdminService.listByFlow(adminGuard.requireAdmin(user, null), flowGuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本校全部授权行(应用使用许可管理页数据源,E6):校域取自管理员会话,
|
||||
* 不接受 body/query 指定学校,也不跨校拉取。
|
||||
*/
|
||||
@GetMapping("/usage-grant/schoolList")
|
||||
public List<com.yunhai.flowcenter.entity.FlwUsageGrant> usageGrantSchoolList(BoaUser user) {
|
||||
return usageGrantAdminService.listBySchool(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
/** 流程线 → 生效授权应用数(流程列表的授权摘要列;仅本校范围) */
|
||||
@GetMapping("/usage-grant/summary")
|
||||
public java.util.Map<String, Long> usageGrantSummary(BoaUser user) {
|
||||
return usageGrantAdminService.grantedAppCounts(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
/** 授予/重申使用授权(appKey 当场解析为 UserBase 稳定主体落库;enforce 开启时校学校开通) */
|
||||
@PostMapping("/usage-grant/grant")
|
||||
public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantGrant(
|
||||
|
||||
@@ -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 本地科室。
|
||||
*
|
||||
* <p>校域一律由 {@link FlowAdminGuard#requireAdmin} 从 CAS 会话派生,前端没有可越界的 schId 入口;
|
||||
* 全部只读,不接受写操作,也不回落本地 {@code flw_dept}。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/flow/admin/department")
|
||||
@RequiredArgsConstructor
|
||||
public class FlowDepartmentController {
|
||||
|
||||
private final FlowAdminGuard adminGuard;
|
||||
private final UserBaseDepartmentDirectory directory;
|
||||
|
||||
/** 第一级「部门组」:系统组织(SYSTEM)+ 该校自定义分类(CUSTOM) */
|
||||
@GetMapping("/groups")
|
||||
public List<DepartmentGroup> 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<DepartmentEntry> 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<DepartmentEntry> resolve(BoaUser user,
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "departmentId", required = false) Integer departmentId) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
List<DepartmentRef> 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<BoaUser> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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) {
|
||||
|
||||
@@ -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<String, Object> 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 可选过滤 */
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>三种主体口径:</p>
|
||||
* <ul>
|
||||
* <li>{@code DEPT}(<b>新配置唯一口径</b>):UserBase 新部门目录(SYSTEM/CUSTOM 类型化引用)
|
||||
* 解析出候选人员,再由节点策略决定一人办理/会签/或签;</li>
|
||||
* <li>{@code USERS}:定义直配固定人员;同样支持会签/或签;</li>
|
||||
* <li>{@code DEPT_KEY}(历史只读):FL 本地 {@code flw_dept}/{@code flw_dept_member}。
|
||||
* 仅为不破坏既有在途定义而保留读取,<b>新配置不再写入、也不是静默兜底</b>:
|
||||
* {@code DEPT} 解析失败照抛,不回退本地科室表。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>审批责任人统一走 D11 资格终审(存在+启用+校域);仅 NOTIFY 抄送接收人保留宽容口径
|
||||
* (解析不出人只记日志继续走——抄送是知会不是审批)。</p>
|
||||
*/
|
||||
@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<AssigneeRef> resolve(Long schId, FlowDefModel.Node node) {
|
||||
FlowDefModel.Assignee a = node.assignee;
|
||||
if (a == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少办理人配置:" + node.id);
|
||||
}
|
||||
List<AssigneeRef> refs = new ArrayList<>();
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) {
|
||||
// 部门是授权主体,候选人仍逐人过 D11 资格终审:不合格者剔除并留痕(部门里有停用账号
|
||||
// 不该卡死整条流程),但权威源不可达(UNAUTHORIZED)照抛,不伪装成"无人可用"。
|
||||
List<String> 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<String> 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:部门是审批授权主体,不是"前端筛选人员的临时集合")。
|
||||
*
|
||||
* <p>一个用户可属多个部门,多部门/多引用解析后<b>按用户去重</b>,避免同一人收到两条待办。
|
||||
* 目录不可用照抛(空候选不等于成功)。{@code tolerant=true}(抄送)时把"某部门查不到人"
|
||||
* 记日志跳过,但通道整体不可用仍然拒绝——不能把故障伪装成"没人需要知会"。</p>
|
||||
*/
|
||||
private List<AssigneeRef> 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<AssigneeRef> out = new ArrayList<>();
|
||||
Set<Integer> 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<BoaUser> 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<AssigneeRef> resolveDepartmentReceivers(Long schId, FlowDefModel.Node node,
|
||||
FlowDefModel.Assignee a) {
|
||||
List<AssigneeRef> 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<AssigneeRef> 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()));
|
||||
}
|
||||
|
||||
@@ -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<String> enabledDeptKeys, Set<String> enabledRouterKeys) {
|
||||
validate(model, enabledDeptKeys, enabledRouterKeys, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* C1 发布校验(2026-10-29):新增 UserBase 新部门目录白名单。
|
||||
*
|
||||
* @param selectableDeptRefs 本校<b>可被新选中</b>的部门引用键({@code TYPE:id})全集;
|
||||
* null=跳过(单测与不带目录上下文的调用面)。目录不可用时调用方应
|
||||
* 直接抛错阻断发布,而不是传 null 放行——见
|
||||
* {@code FlowDefAdminService#selectableDeptRefs}。
|
||||
*/
|
||||
public static void validate(FlowDefModel model, Set<String> enabledDeptKeys, Set<String> enabledRouterKeys,
|
||||
Set<String> selectableDeptRefs) {
|
||||
List<String> 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<String> enabledDeptKeys, List<String> problems) {
|
||||
private static void validateNotify(FlowDefModel.Node n, Set<String> enabledDeptKeys,
|
||||
Set<String> selectableDeptRefs, List<String> 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<String> enabledDeptKeys, List<String> problems) { FlowDefModel.Assignee a = n.assignee;
|
||||
if (a == null || a.type == null) {
|
||||
problems.add(nodeLabel(n) + " 缺少 assignee 配置");
|
||||
/**
|
||||
* 审批主体校验(C2:部门与人员是两种主体,部门不能降级成"前端筛选出来的一堆人")。
|
||||
* DEPT=UserBase 新部门目录的类型化引用(新配置唯一口径);DEPT_KEY=历史本地科室(只读兼容);
|
||||
* USERS=固定人员。会签/或签由 {@code countersign} 独立决定,对 DEPT 与 USERS 同样有效。
|
||||
*/
|
||||
private static void validateSubject(FlowDefModel.Node n, FlowDefModel.Assignee a, String what,
|
||||
Set<String> enabledDeptKeys, Set<String> selectableDeptRefs,
|
||||
List<String> problems) {
|
||||
if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) {
|
||||
if (a.deptRefs == null || a.deptRefs.isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " DEPT " + what + "缺少 deptRefs");
|
||||
return;
|
||||
}
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
Set<String> 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<String> enabledDeptKeys,
|
||||
Set<String> selectableDeptRefs, List<String> 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)) {
|
||||
|
||||
@@ -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 租户键决定(不由客户端提交)。
|
||||
* <b>新配置一律写本类型</b>;{@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 是<b>必带消歧位</b>——同值 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<Integer> userIds;
|
||||
/** {@link #type}=DEPT 时的部门引用集合(可多选;一个用户可属多个部门,解析后按人去重) */
|
||||
public List<DeptRef> deptRefs;
|
||||
}
|
||||
|
||||
/** 发起时指派策略:mode 必填(容错 null=MULTI);min/max 可空(缺省 1/候选全量数);required 仅子应用 UI 用 */
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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());
|
||||
|
||||
@@ -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 仅保留迁移前旧入口的一业务一行语义;正式模式关闭旧写入口。
|
||||
*
|
||||
* <p>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;
|
||||
|
||||
|
||||
@@ -8,9 +8,18 @@ public final class FlwEnums {
|
||||
private FlwEnums() {
|
||||
}
|
||||
|
||||
/** 办件状态:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 */
|
||||
/**
|
||||
* 办件状态:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 / 50 退回待修改。
|
||||
*
|
||||
* <p><b>50 是 D14「退回发起人修改后重提」的等待态</b>(2026-10-29):审批人把办件退回发起人后,
|
||||
* 实例仍在同一轮次、同一实例上等待发起人重提,<b>不是终态</b>——不能复用 20/30/40 表达,
|
||||
* 否则"退回待修改"会被 R4 重启、幂等回读与在途统计误判成新一轮申报。
|
||||
* 10 与 50 一起构成"在途"口径(见 {@link #inFlightCodes()})。</p>
|
||||
*/
|
||||
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<Integer> 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;
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long> {
|
||||
@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<Long> 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<FlwInstance> findFormalPreviousForUpdate(@Param("id") Long id);
|
||||
@@ -34,17 +38,22 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
|
||||
value = "update flw_instance set event_seq_counter = :v where id = :id", nativeQuery = true)
|
||||
int setEventSeqCounter(@Param("id") Long id, @Param("v") Integer v);
|
||||
|
||||
Optional<FlwInstance> findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(Long school, String source, String type, String biz);
|
||||
Optional<FlwInstance> 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<FlwInstance> pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable);
|
||||
|
||||
Optional<FlwInstance> 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<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
|
||||
/**
|
||||
* D11/F02 业务坐标主查询:<b>可信来源应用 + 学校 + 业务类型 + 业务 ID + 入口槽位</b>。
|
||||
* 与 {@code uk_flw_instance_legacy} 逐列同构——应用 A 的在途件不会因为撞上应用 B 的同名业务键
|
||||
* 而被 B 读到、复用或拿到 A 的 instanceId。
|
||||
*
|
||||
* <p>原 3 参 {@code findBySchIdAndBizTypeAndBizId}(只按裸坐标、且限定 {@code sourcePrincipalId is null})
|
||||
* 已删除:它既可能把他人办件回给本应用,也读不到改造后的新行。</p>
|
||||
*/
|
||||
Optional<FlwInstance> findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||
Long schId, String sourcePrincipalId, String bizType, String bizId, Integer legacySlot);
|
||||
|
||||
List<FlwInstance> findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status);
|
||||
|
||||
@@ -75,14 +84,14 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
|
||||
@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<FlwInstance> pageByBiz(@Param("schId") Long schId, @Param("bizType") String bizType,
|
||||
@Param("status") Integer status, Pageable pageable);
|
||||
/**
|
||||
* 跨来源审计读:同一裸业务坐标在本校的全部归属行(不参与幂等复用,只用于"这个业务键被谁占了"
|
||||
* 的核查与测试断言零状态)。幂等与归属判定一律走上面的来源分区方法。
|
||||
*/
|
||||
List<FlwInstance> 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<Object[]> countApprovingByDefIds(@Param("schId") Long schId, @Param("defIds") Collection<Long> defIds);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
|
||||
java.util.Optional<FlwTask> 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<FlwTask, Long> {
|
||||
Page<FlwTask> 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<FlwTask> pageTodoBiz(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
|
||||
@Param("bizType") String bizType, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 我的已办(一个办件一行,2026-09-18):同一办件被本人办理多次(多节点/退回重办/移交后
|
||||
|
||||
@@ -15,4 +15,6 @@ public interface FlwUsageGrantRepository extends JpaRepository<FlwUsageGrant, Lo
|
||||
List<FlwUsageGrant> findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(Long schId, String flowGuid);
|
||||
|
||||
List<FlwUsageGrant> findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(Long schId, Integer appId);
|
||||
|
||||
List<FlwUsageGrant> findBySchIdOrderByFlowGuidAscUpdateTimeDesc(Long schId);
|
||||
}
|
||||
|
||||
@@ -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<FlwDef> list(Long schId, Integer status, String appKey) {
|
||||
List<FlwDef> 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):缺省留空合法,显式传入时
|
||||
* 必须 ∈ 本校已开启应用白名单(继承值不重校)。它<b>不是</b>使用许可,也不控制定义的可见性——
|
||||
* 授权关系归 {@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<String> 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<String> 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<String> 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} 同族约定但
|
||||
* 语义不同——编码是<b>可重名的分类标签</b>(唯一键是 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-";
|
||||
|
||||
}
|
||||
|
||||
@@ -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;<b>非构造参数</b>——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;同样<b>非构造参数</b>,理由同上)。
|
||||
* 由服务端从验签 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):<b>同一实例、同一申报轮次</b>从第一级重新审批。
|
||||
*
|
||||
* <p>与"终态后重新申报"({@link #apply} 的 R4 重启,会刷新定义版本并留 重新提交 轨迹)
|
||||
* 和"申请人撤回"完全区分:本方法只接受 {@code 50 退回待修改} 的办件,不改 defId/defVersion
|
||||
* (D04/D05 在途实例固定发起时版本),不自增 applyRound,不删任何历史任务/意见/事件行。</p>
|
||||
*
|
||||
* <p>并发重提使用数据库行锁并刷新已加载实体;状态 10 还必须有最新 D14 提交留痕,
|
||||
* 才可作为回执恢复。普通审批中与旧 R4 留痕都不构成 D14 重提成功证明。</p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建件。<b>来源主体与槽位都是入参</b>:旧入口传(解析出的来源, {@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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务坐标的<b>来源分区</b>读(F02 收口):可信来源缺失即拒,命中行必然属于本来源。
|
||||
*
|
||||
* <p>D11 的业务对象身份=可信来源应用+学校+bizType+bizId,因此应用 A 已占用同名业务键时,
|
||||
* 应用 B 命中的是"本来源无行"→ 走新建,各自得到独立实例;B 既拿不到 A 的 instanceId,
|
||||
* 也不会被当成 duplicated 成功。同来源重投才回原实例。</p>
|
||||
*/
|
||||
private FlwInstance requireSourceScopedLookup(ApplyCommand cmd) {
|
||||
if (cmd.sourcePrincipalId == null || cmd.sourcePrincipalId.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源应用,不能按业务坐标定位或新建办件");
|
||||
}
|
||||
lockLegacyAdmission(cmd);
|
||||
Optional<FlwInstance> 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)) {
|
||||
|
||||
@@ -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 起无条件生效)。
|
||||
*
|
||||
* <p>来源主体只从<b>验签上下文</b>推导({@code yh.app.auth} → UserBase 应用注册主键),
|
||||
* 任何客户端自报的来源头/字段都不采信。原先本拦截器挂在
|
||||
* {@code flow-center.formal-command.enabled} 上,旧发起入口跑起来时来源维度整体缺位,
|
||||
* 等于"A 应用能用同一业务键拿到 B 应用的实例"——现取消该开关。</p>
|
||||
*
|
||||
* <p>本闸只做<b>身份与归属</b>:解析来源 + 按 id 访问时核验实例属于本来源。
|
||||
* "新发起的使用许可"是另一件事,归 {@link FlowUsageGrantGuard},只挂在创建新实例的路径上,
|
||||
* 不误加到只读查询/候选人预览。</p>
|
||||
*/
|
||||
@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, "仅允许访问本应用来源的流程事实"); }
|
||||
}
|
||||
|
||||
@@ -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 是验签凭据,不是主体)。
|
||||
*
|
||||
* <p>主体=UserBase {@code application_info} 主键 id(稳定整型主键,appKey 可重置/复用不影响它)。
|
||||
* 加 {@link #SOURCE_PREFIX} 前缀落库,与未来可能引入的 UUID 主体空间不撞。
|
||||
* 不可达、查无、缺主键一律拒绝,不回退 appKey/数字自增猜测。</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,55 @@ public class FlowTaskService {
|
||||
"无可退回的上游节点,请使用拒绝"));
|
||||
}
|
||||
|
||||
/**
|
||||
* D14 退回发起人修改(2026-10-29):与 {@link #returnBack}(退回到上游<b>审批节点</b>续办)是
|
||||
* 两个不同动作,绝不能混用。
|
||||
*
|
||||
* <p>语义:当前节点待办与<b>整条办件的在途代次</b>全部失效(含并行分支),办件转入
|
||||
* {@code 50 退回待修改}——仍不是终态、仍在同一实例同一申报轮次;历史任务/意见/事件行一律保留。
|
||||
* 发起人修改业务表单后走 {@code FlowInstanceService#resubmitAfterReturn} 重提,从第一级重新审批。</p>
|
||||
*
|
||||
* <p>幂等与防重:抢占用既有 {@code claimAndFinish}(UPDATE WHERE status=0 原子语义),
|
||||
* 双击/并发第二次拿不到办理权即拒。事件复用 {@code RETURNED}(不新增、不重命名既有事件码),
|
||||
* 以 {@code returnTo=SUBMITTER} 与 {@code instanceStatus=50} 表达真实去向。</p>
|
||||
*/
|
||||
@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<String, Object> 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 自然不放行,无需改推进逻辑。
|
||||
|
||||
@@ -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<FlwUsageGrant> listBySchool(Long schId) {
|
||||
return schId == null ? java.util.Collections.<FlwUsageGrant>emptyList()
|
||||
: grantRepository.findBySchIdOrderByFlowGuidAscUpdateTimeDesc(schId);
|
||||
}
|
||||
|
||||
/** 流程线 → 生效授权应用数(列表页授权摘要列;无授权的线不出键,前端按缺省 0 显示)。 */
|
||||
public Map<String, Long> grantedAppCounts(Long schId) {
|
||||
Map<String, Long> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) 许可。
|
||||
*
|
||||
* <p>三层独立事实,缺一不可(所有环境):</p>
|
||||
* <p>三层独立事实,缺一不可:</p>
|
||||
* <ol>
|
||||
* <li><b>稳定主体</b>:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用,
|
||||
* 不是主体;主体解析不可达=fail-closed 拒绝);</li>
|
||||
@@ -23,8 +21,12 @@ import org.springframework.stereotype.Service;
|
||||
* <li><b>UB 学校开通</b>:该应用在该校的平台开通事实(第二重证明,不因本地 grant 而豁免)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>不再有灰度开关</b>:原 {@code yh.flow.school-activation-enforce=false} 允许整体关掉本闸,
|
||||
* 属"默认关闭的开关静默绕过授权",已删除。装配缺失同样拒绝——见
|
||||
* {@code OapiFlowService#requireUsageGrantGuard}。</p>
|
||||
*
|
||||
* <p><b>撤权边界(P0-3)</b>:本闸只拦"新发起";已受理命令的回执恢复与在途实例不回读本表——
|
||||
* 恢复路径的"是否已受理"判定归命令协议(T04),调用方负责在恢复命中时跳过本闸。</p>
|
||||
* 恢复路径的"是否已受理"判定归命令协议,调用方负责在恢复命中时跳过本闸。</p>
|
||||
*/
|
||||
@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(供命令快照/审计使用)。
|
||||
* 新发起资格核验。主体由调用方从<b>验签上下文</b>解析({@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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -93,8 +93,7 @@ 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(
|
||||
if (instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||
school, source.id, request.getBizType(), request.getBizId(), 1).isPresent()) {
|
||||
return reject(command, "LEGACY_COORDINATE_NOT_MIGRATED", "旧业务坐标尚未审核迁移");
|
||||
}
|
||||
|
||||
@@ -145,8 +145,8 @@ public class OapiFlowService {
|
||||
FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
|
||||
}
|
||||
|
||||
private <T> T inTransaction(
|
||||
org.springframework.transaction.support.TransactionCallback<T> action) {
|
||||
private FlowInstanceService.GuardedOutcome inTransaction(
|
||||
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> 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,20 +381,68 @@ public class OapiFlowService {
|
||||
|
||||
/**
|
||||
* T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠
|
||||
* 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
|
||||
* 重启即新一轮申报。每次均强制通过独立的学校开通与流程使用授权校验。
|
||||
* 既有实例状态——在途(10/50)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
|
||||
* 重启即新一轮申报。
|
||||
*
|
||||
* <p><b>F01 整改(2026-10-29):本方法不再有直通分支。</b>旧实现
|
||||
* {@code if (guard == null || !guard.isEnforce() || appKey == null) return;} 让"闸门未装配"、
|
||||
* "灰度开关关闭"、"缺验签主体"三种情况都变成放行。现三者全部 fail-closed 拒绝。</p>
|
||||
*/
|
||||
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 重启。
|
||||
@@ -406,7 +450,7 @@ public class OapiFlowService {
|
||||
*/
|
||||
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);
|
||||
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());
|
||||
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(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<FlwTask> p = formalCommandEnabled
|
||||
? taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)))
|
||||
: taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)));
|
||||
// D11/F02:OAPI 列表一律按可信来源分区,A 应用不会看到 B 应用的待办。
|
||||
// 本次改造之前落库、来源列为空的实例不在本口径内(不回填、不猜测归属,见任务书 §2.3)。
|
||||
Page<FlwTask> p = taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(),
|
||||
assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)));
|
||||
List<Long> instanceIds = p.getContent().stream()
|
||||
.map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList());
|
||||
Map<Long, FlwInstance> instances = instanceIds.isEmpty()
|
||||
@@ -732,9 +799,8 @@ public class OapiFlowService {
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
|
||||
}
|
||||
Page<FlwInstance> p = formalCommandEnabled
|
||||
? instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)))
|
||||
: instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)));
|
||||
Page<FlwInstance> p = instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(),
|
||||
emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)));
|
||||
List<OapiModels.InstanceItemVo> items = p.getContent().stream()
|
||||
.map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList());
|
||||
return toPageResult(p.getTotalElements(), items, page, size);
|
||||
@@ -942,6 +1008,22 @@ public class OapiFlowService {
|
||||
instanceService.withdraw(schId, instanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* D14 发起人重提(托管接入件,2026-10-29)。治理口径与 {@link #withdraw} 一致:
|
||||
* <ul>
|
||||
* <li>来源:{@code FlowOapiSourceScope} 已按实例 id 核验归属,非本应用发起的办件到不了这里;</li>
|
||||
* <li>身份:{@code submitterId} 与办件提交人逐字复核——签名只证明"哪个应用在调";</li>
|
||||
* <li>状态:仅 50 退回待修改(幂等:已是 10 视为已重提,回原实例不重开一代任务);</li>
|
||||
* <li>轮次/版本:不改 applyRound、不改 defId/defVersion(D04/D05 在途固定版本);</li>
|
||||
* <li>业务字段:FL 不做 Diff,{@code formData} 为空即沿用原快照。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<FlwInstance> 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,15 +1407,11 @@ 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);
|
||||
}
|
||||
reg.setSourcePrincipalId(source.id);
|
||||
}
|
||||
reg.setCallbackUrl(body.getCallbackUrl());
|
||||
|
||||
@@ -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 侧)。
|
||||
*
|
||||
* <p>事实源=UserBase {@code sys_group_app_rel}(学校组单点开通,不沿祖先),
|
||||
* 开放端点 {@code GET /oapi/app/activated?appId=&schoolId=}。<b>enforce 开启时
|
||||
* 不可达/异常一律抛错(fail-closed)</b>——"UserBase 不可达"不得降级为"仅本地 grant 存在即放行"。</p>
|
||||
* 开放端点 {@code GET /oapi/app/activated?appId=&schoolId=}。</p>
|
||||
*
|
||||
* <p><b>本闸无条件生效(F01 整改,2026-10-29)</b>:原先的
|
||||
* {@code yh.flow.school-activation-enforce=false} 灰度开关可以整体关掉核验,等于"默认关闭的开关
|
||||
* 静默绕过授权"。现删除该开关——不可达/响应形状异常一律抛错(fail-closed),
|
||||
* 不得降级为"仅本地 grant 存在即放行"。</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>FL 不再是部门/成员的权威来源:编辑期选择、发布校验、候选人预览、运行时审批人解析、
|
||||
* 抄送接收人解析与流程图/轨迹名称展示全部经本通道走 UserBase
|
||||
* {@code /oapi/departments/**}(SDK={@link OpenApiGroupClient})。</p>
|
||||
*
|
||||
* <p><b>没有本地兜底</b>:通道不可用/异常一律抛业务错误,不把"查不到"降级成"空候选人=成功",
|
||||
* 也不静默回退 {@code flw_dept}。历史名称解析用 UserBase 已有的 {@code resolve-batch}
|
||||
* (唯一能取回停用/软删部门名称的口径),与"新的选择"用的 {@code select} 严档分开。</p>
|
||||
*/
|
||||
@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<DepartmentGroup> groups(Long schId) {
|
||||
requireSchool(schId);
|
||||
return call("部门目录分组", () -> groupClient.getDepartmentGroups(schId.intValue()));
|
||||
}
|
||||
|
||||
/** 第二级可选部门(缺省只返回启用且未软删者) */
|
||||
public List<DepartmentEntry> 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<DepartmentEntry> resolve(Long schId, List<DepartmentRef> refs) {
|
||||
requireSchool(schId);
|
||||
if (refs == null || refs.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<DepartmentRef> 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<BoaUser> 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<BoaUser> membersAtSchool(Long school, String type, Integer id, String scope) {
|
||||
requireSchool(school);
|
||||
String normalizedType = normalizeType(type, false);
|
||||
List<DepartmentEntry> 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<BoaUser> membersForSelection(String departmentType, Integer departmentId) {
|
||||
return members(departmentType, departmentId, SCOPE_SELECT);
|
||||
}
|
||||
|
||||
/** 目录里可被新选中的引用键集合({@code TYPE:id}),发布校验白名单 */
|
||||
public Set<String> selectableKeys(Long schId) {
|
||||
Set<String> 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> T call(String what, java.util.function.Supplier<T> 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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<FlwTask> 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(),"新边");
|
||||
|
||||
@@ -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<String> 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<JsonNode> 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 + "(办件未创建)");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<FlwInstance>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.<FlwInstance>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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
}
|
||||
|
||||
// ==================== 线级停发/恢复(管理面委托) ====================
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>测试替身边界:{@link OpenApiGroupClient}/{@link OpenApiUserClient} 按 UserBase
|
||||
* {@code /oapi/departments/**} 的真实契约打桩(响应形状=SDK 的 DepartmentEntry/BoaUser),
|
||||
* <b>这不是跨服务联调</b>——真实 UserBase 可达性、SYSTEM/CUSTOM 数据齐备性由后续 WorkBuddy 验证。</p>
|
||||
*
|
||||
* <p>钉住的红线:</p>
|
||||
* <ul>
|
||||
* <li>部门是<b>审批授权主体</b>,不是"前端筛出来的一堆人";解析出的候选仍逐人过 D11 资格终审;</li>
|
||||
* <li>一个用户可属多个部门 → 多部门引用按用户去重,只发一条待办;</li>
|
||||
* <li>目录不可用一律抛错,<b>不静默退回 flw_dept</b>,也不把空候选当成功;</li>
|
||||
* <li>展示口径(显示人员/显示部门)与审批主体分开配置并独立生效。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<DepartmentRef> refs = call.getArgument(0);
|
||||
if (refs == null) return java.util.Collections.emptyList();
|
||||
java.util.List<DepartmentEntry> 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<AssigneeRef> 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<AssigneeRef> 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<AssigneeRef> 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<DepartmentEntry> 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<List<DepartmentRef>> 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<String> 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<Integer> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>D02:定义归属、使用许可、实例来源是三个概念。此前 {@code save} 硬拦"归属应用必填"
|
||||
* (2026-10-12 拍板)与"code/name 必填",把内部编码变成管理员的手填项。现:</p>
|
||||
* <ul>
|
||||
* <li>新建不传 code → 后端生成稳定唯一码;传合法 code(旧客户端)→ 原样保留,不破坏既有调用方;</li>
|
||||
* <li>更新/升版本不传 code → 继承既有编码,编码不随名称或编辑变化;</li>
|
||||
* <li>appKey 缺省合法;显式传入仍须在本校已开启应用白名单内。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>钉住的四条语义(决策原文:同一实例、同一申报轮次、历史记录保留、从第一级重新审批):</p>
|
||||
* <ol>
|
||||
* <li>退回后办件进入 {@code 50 退回待修改}——非终态,且与"拒绝终止/撤回/终态后重报"互不冒充;</li>
|
||||
* <li>重提沿用同一 {@code instanceId} 与同一 {@code applyRound},不建新实例、不自增轮次;</li>
|
||||
* <li>历史任务/意见/留痕行一律保留,重提只<b>追加</b>新一代任务;</li>
|
||||
* <li>身份与归属由服务端复核:非发起人不能代提,重复点击/网络重试不产生第二代任务。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>mock 边界:仓储、引擎协作、事件发布器。没有真实事务/数据库锁/提交后回调,
|
||||
* 并发只验证到"状态判定 + 引擎调用次数"这一层,真实事务由后续跨服务联调补证。</p>
|
||||
*/
|
||||
@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<FlowEvent> 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<FlwTask> 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<FlwTask> 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<FlwTask> historyRows() {
|
||||
List<FlwTask> 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\"}]}";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String,FlwInstance> 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<String,String> 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<FlwInstance>() {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<FlwInstance>() {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();
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>D11 的业务对象身份=<b>可信来源应用</b>+学校+bizType+bizId。旧实现按裸坐标
|
||||
* {@code findBySchIdAndBizTypeAndBizId(schId,bizType,bizId)} 复用实例,于是应用 B 用与
|
||||
* 应用 A 相同的业务键会拿到 A 的 instanceId 并以 {@code duplicated=true} 当成功返回。</p>
|
||||
*
|
||||
* <p>本类用真实 {@code FlowInstanceService}(mock 仅仓储/引擎/发布器)钉住三条事实:</p>
|
||||
* <ol>
|
||||
* <li>同来源同业务键 → 同一个实例(合法幂等重投不被打断);</li>
|
||||
* <li>异来源同业务键 → 各自建独立实例,<b>绝不回读对方 id</b>;</li>
|
||||
* <li>缺可信来源 → 直接拒,不"猜归属"也不建归属不明的行。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@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<FlwInstance> 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<FlwInstance> 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<FlwInstance> 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;
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>钉住的不变量:</p>
|
||||
* <ul>
|
||||
* <li><b>新发起必须有生效许可</b>:无行/已撤销/主体解析失败/未在学校开通/核验不可达——五路全拒;</li>
|
||||
* <li><b>已受理命令回执可恢复</b>:同 token、retryOnly 探询、在途/通过幂等、陈旧轮次四类
|
||||
* 回执分支不触许可闸(P0-3 收口顺序:认证 → 回执 → 未命中才查资格);</li>
|
||||
* <li><b>appKey 不是主体</b>:appKey 可重置/复用,许可与审计挂 UserBase 稳定主键 appId。</li>
|
||||
* </ul>
|
||||
* <p>本轮钉住的核心变化:<b>闸门不再有任何直通分支</b>。原
|
||||
* {@code if (guard == null || !guard.isEnforce() || appKey == null) return;} 让"灰度开关关闭 /
|
||||
* 闸门未装配 / 缺验签主体"三种情况静默放行,正是 Codex 终审 F01 指出的 P1。
|
||||
* 现在 {@code yh.flow.school-activation-enforce} 这个开关已从代码里删除,四类缺证一律拒绝。</p>
|
||||
*
|
||||
* <p>同时保留 P0-3 的收口顺序:<b>已受理命令的回执恢复不回读本表</b>——撤权不能没收已经受理的
|
||||
* 申报回执(判定逻辑见 {@code OapiFlowService#legacyApplyIsReceiptOnly / guardedApplyIsReceiptOnly},
|
||||
* 由 {@code FlApplyAssignAndVariableTest} 与本类下方用例分别钉住)。</p>
|
||||
*/
|
||||
@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<String, Object>()));
|
||||
|
||||
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<String, Object> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user