feat(fl2): 落地部门目录 / 退回重提(D14) / 授权管理后端(基线 origin/main)
build / build-push (push) Failing after 55s

在 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:
chenxing
2026-10-30 10:15:12 +08:00
parent b369527fe1
commit d72128672f
52 changed files with 3208 additions and 859 deletions
+4 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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 配置");
return;
}
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
/**
* 审批主体校验(C2:部门与人员是两种主体,部门不能降级成"前端筛选出来的一堆人")。
* DEPT=UserBase 新部门目录的类型化引用(新配置唯一口径);DEPT_KEY=历史本地科室(只读兼容);
* USERS=固定人员。会签/或签由 {@code countersign} 独立决定,对 DEPT 与 USERS 同样有效。
*/
private static void validateSubject(FlowDefModel.Node n, FlowDefModel.Assignee a, String what,
Set<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;
}
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,9 +93,8 @@ public class FormalApplyCommandService {
coordinate.setBizType(request.getBizType()); coordinate.setBizId(request.getBizId());
coordinates.saveAndFlush(coordinate);
}
if (instances.findBySchIdAndBizTypeAndBizId(school, request.getBizType(), request.getBizId()).isPresent()
|| instances.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
school, source.id, request.getBizType(), request.getBizId(), 1).isPresent()) {
if (instances.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
school, source.id, request.getBizType(), request.getBizId(), 1).isPresent()) {
return reject(command, "LEGACY_COORDINATE_NOT_MIGRATED", "旧业务坐标尚未审核迁移");
}
if (coordinate.getActiveInstanceId() != null) return reject(command, "IN_FLIGHT", "同一业务坐标已有在途实例");
@@ -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,28 +381,76 @@ 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 重启。
* 与 {@code FlowInstanceService.apply(ApplyCommand)} 的分支一一对应(包私有便于规则单测钉住)。
*/
static boolean legacyApplyIsReceiptOnly(FlwInstance existing) {
return existing != null
&& (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
&& (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus())
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode());
}
@@ -427,7 +471,7 @@ public class OapiFlowService {
if (retryOnly) {
return true;
}
if (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
if (FlwEnums.InstanceStatus.inFlightCodes().contains(existing.getStatus())
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
return true;
}
@@ -485,18 +529,40 @@ public class OapiFlowService {
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
FlowInstanceService.ApplyCommand cmd,
FlowInstanceService.ApplyRetryPolicy policy) {
FlowInstanceService.GuardedOutcome outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy));
requireNotConflict(outcome);
FlwInstance existing = instanceRepository
.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, LEGACY_SLOT)
.orElse(null);
if (existing == null) {
throw new BizException(ErrorCode.CUSTOM, "办件创建冲突且读不到既有实例,请稍后重试");
}
if (!cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) {
throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
}
if (existing.getSubmitterId() != null && cmd.submitterId != null
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符");
}
String token = policy.requestToken == null ? null : policy.requestToken.trim();
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(outcome.instanceId); vo.setDuplicated(outcome.duplicated);
vo.setBranch(outcome.branch); vo.setApplyRound(outcome.applyRound);
instanceRepository.findById(outcome.instanceId).ifPresent(current -> {
int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound();
int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound;
if (currentRound == outcomeRound && cmd.sourcePrincipalId.equals(current.getSourcePrincipalId())) {
vo.setStatus(current.getStatus()); vo.setVersion(current.getVersion());
}
});
vo.setInstanceId(existing.getId());
vo.setDuplicated(true);
vo.setApplyRound(existing.getApplyRound());
vo.setStatus(existing.getStatus());
vo.setVersion(existing.getVersion());
if (token != null && !token.equals(existing.getApplyRequestToken())) {
// T04:并发撞键且 token 不符=两份不同命令抢同一业务键——后到者冲突,不伪装幂等成功
//(旧 CONCURRENT_CREATED 分支只保留给无 token 的存量调用方)。
log.warn("[flow-center] 并发建件 token 不符(冲突拒绝):instance={} biz={}/{}",
existing.getId(), cmd.bizType, cmd.bizId);
throw new BizException(ErrorCode.CUSTOM,
"该业务键已被另一并发命令创建(请求标识不符),请按实例查询恢复,不要换 ID 重发");
}
vo.setBranch(token != null ? FlowApplyRetryModels.Branch.SAME_REQUEST
: FlowApplyRetryModels.Branch.CONCURRENT_CREATED);
log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}",
existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch());
return vo;
}
@@ -711,9 +777,10 @@ public class OapiFlowService {
if (schId == null || assigneeId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId");
}
Page<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,14 +1407,10 @@ public class OapiFlowService {
}
{
FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey);
if (formalCommandEnabled) {
com.yunhai.flowcenter.entity.FlwUsageGrant grant = formalGrants.findBySchIdAndFlowGuidAndAppId(schId, body.getFlowGuid(), source.appId).orElse(null);
boolean permitted = grant != null && Integer.valueOf(1).equals(grant.getEnabled()) && source.id.equals(grant.getSourcePrincipalId());
if (!permitted && !formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(schId, source.id, body.getFlowGuid(), "ACCEPTED")) {
throw new BizException(ErrorCode.FORBIDDEN, "没有 SELF 订阅权限");
}
} else {
requireUsageGrant(schId, body.getFlowGuid(), source);
com.yunhai.flowcenter.entity.FlwUsageGrant grant = formalGrants.findBySchIdAndFlowGuidAndAppId(schId, body.getFlowGuid(), source.appId).orElse(null);
boolean permitted = grant != null && Integer.valueOf(1).equals(grant.getEnabled()) && source.id.equals(grant.getSourcePrincipalId());
if (!permitted && !formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(schId, source.id, body.getFlowGuid(), "ACCEPTED")) {
throw new BizException(ErrorCode.FORBIDDEN, "没有 SELF 订阅权限");
}
reg.setSourcePrincipalId(source.id);
}
@@ -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;