Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11e077867b | ||
|
|
f7819b14e2 | ||
|
|
7eeaea55f8 | ||
|
|
a10f2e054c | ||
|
|
4b4d63e3ba | ||
|
|
60ff464226 | ||
|
|
61b0ae7b8a | ||
|
|
2f54f37ab3 | ||
|
|
97dec39d45 | ||
|
|
93ecc79839 | ||
|
|
52c27d88bb | ||
|
|
132919b029 | ||
|
|
8ceb2a4247 | ||
|
|
ad87005169 | ||
|
|
448c8d36f4 | ||
|
|
f4121b2c1e | ||
|
|
82ffe2f030 | ||
|
|
008aee3c88 | ||
|
|
81d9a075e8 | ||
|
|
775b2392fa | ||
|
|
a9aeca744d | ||
|
|
fd4e207ce0 | ||
|
|
c02bdc5eca | ||
|
|
62aff51fed | ||
|
|
4eefe078e3 | ||
|
|
de332d1c66 | ||
|
|
c896fbb90b | ||
|
|
b4856a480d | ||
|
|
d687ba7d56 | ||
|
|
0ecbf1740b | ||
|
|
6f68e1ebe7 | ||
|
|
6cd33abc67 | ||
|
|
f07ac6da93 | ||
|
|
cd046782d9 | ||
|
|
a06b610d9d | ||
|
|
4293c9628a | ||
|
|
d6b7a68ec3 | ||
|
|
92619508f0 | ||
|
|
a1f99fd7e0 | ||
|
|
7c92e953ec | ||
|
|
56a71f813f | ||
|
|
9bb7250c5e | ||
|
|
1efe4feb50 |
+72
-10
@@ -9,13 +9,17 @@ name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, codex/core-rc-20261023 ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cache_bust:
|
||||
description: '构建前清空 maven m2 缓存'
|
||||
required: false
|
||||
default: 'false'
|
||||
tests_only:
|
||||
description: '只验证测试和 JAR,不构建推送镜像或通知建档'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
@@ -25,7 +29,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl(alpine)
|
||||
run: apk add --no-cache git curl
|
||||
run: |
|
||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
apk add --no-cache git curl
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -33,6 +40,10 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
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 字符
|
||||
@@ -55,20 +66,70 @@ jobs:
|
||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f mavenbuild 2>/dev/null || true
|
||||
docker run -d --name mavenbuild \
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
trap 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true' EXIT
|
||||
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$BUILD_CONTAINER" \
|
||||
--network infra-infra-net \
|
||||
-v /opt/docker/projman-m2:/root/.m2 \
|
||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||
docker cp "${PWD}/." mavenbuild:/build/
|
||||
docker exec mavenbuild 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"
|
||||
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
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
|
||||
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 \
|
||||
-e MYSQL_DATABASE=flowcenter_ci -e MYSQL_USER=flow_ci \
|
||||
-e MYSQL_PASSWORD -e MYSQL_ROOT_PASSWORD mysql:8.0 --skip-log-bin >/dev/null
|
||||
docker cp "${PWD}/ci/flow-test-schema.sql" "$DB_CONTAINER":/docker-entrypoint-initdb.d/001-schema.sql
|
||||
docker start "$DB_CONTAINER" >/dev/null
|
||||
ready=0
|
||||
for n in $(seq 1 60); do
|
||||
if docker exec "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -h127.0.0.1 -u"$MYSQL_USER" "$MYSQL_DATABASE" -Nse "SELECT 1" >/dev/null 2>&1'; then ready=1; break; fi
|
||||
sleep 2
|
||||
done
|
||||
[ "$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" = 14 ] || { echo "Expected 14 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'
|
||||
mkdir -p "${PWD}/target"
|
||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f mavenbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
|
||||
- name: 构建并推送镜像
|
||||
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
@@ -79,6 +140,7 @@ jobs:
|
||||
|
||||
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
|
||||
- name: 通知 projman 建档(部署由运维手动触发)
|
||||
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
|
||||
run: |
|
||||
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# FL 指定门禁与独立 MySQL
|
||||
|
||||
`test-gate.sh` 是 Gitea 与本地等价 CI 共用的入口。无参运行 `test`;工作流使用
|
||||
`sh ci/test-gate.sh clean package`,真实测试未通过不会生成待发布 JAR,后续镜像步骤不会运行。
|
||||
所有指定类必须存在;Surefire 不允许零测试;Hibernate 固定 `ddl-auto=validate`。
|
||||
|
||||
工作流在 `infra-infra-net` 内新建 `${BUILD_CONTAINER}-db`,镜像 `mysql:8.0`,不映射宿主端口。
|
||||
在启动之前把 `flow-test-schema.sql` 复制进初始化目录;该文件只含 14 张表的结构,不含业务数据。
|
||||
镜像初始化阶段建立测试 schema,测试阶段只 validate。每个 job 的数据库容器名和随机凭据独立,
|
||||
EXIT trap 回收 Maven/MySQL 容器;不访问 160 业务库。
|
||||
|
||||
随机凭据仅经容器环境变量传递,不写仓库或日志。Maven 子容器必须获得:
|
||||
|
||||
- `CI_TEST_DB_URL`:MySQL JDBC URL;schema 必须为 `flowcenter_ci` 或 `flowcenter_ci_*`。
|
||||
- `CI_TEST_DB_USER`、`CI_TEST_DB_PASSWORD`:本次数据库的专用账号与密码。
|
||||
|
||||
脚本只允许 localhost、127.0.0.1 或 `mavenbuild-*-db` 主机,随后映射为 Spring datasource 环境变量。
|
||||
`OapiFlowTasksByBizTest` 在任何 cleanDb 前检查 JDBC 实际连接的 host/schema,防止 profile 覆盖错误。
|
||||
CI 场景必须实际连接 `flowcenter_ci*`,不能悄悄改用默认开发库;11 项继续为真 MySQL 测试。
|
||||
|
||||
本地等价运行:在独立测试库按此 schema 初始化后,通过进程环境设置上述三个变量,
|
||||
再执行 `sh ci/test-gate.sh clean package`。凭据不要写入命令历史或提交到仓库。
|
||||
|
||||
本次已在本机 MySQL 8.0.45 做正反等价验证:完整 schema 通过,空 schema 验证失败且不生成 JAR。
|
||||
三个 workflow 的手动入口新增 `tests_only=true`(默认),可以在 runner 上只验测试/JAR,不推镜像或通知建档。
|
||||
本机没有 Docker,Gitea runner 的容器启动、镜像拉取和网络连通未实跑;这些仍需在 runner 上验证,
|
||||
不能将等价 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;随后对 14 表正常 schema 执行正向门禁。
|
||||
空库检查的预期失败不属于 380 项正向用例。回收检查打印 `CI_CONTAINER_CLEANUP=PASS`,同时删除本次容器的匿名卷。
|
||||
本文件是运行约定;只有取得对应远端准确 SHA 的实际日志,才能宣称 runner 已完成验证。
|
||||
@@ -0,0 +1,330 @@
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!50503 SET NAMES utf8mb4 */;
|
||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*!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_app` (
|
||||
`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_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`app_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`enabled` int NOT NULL,
|
||||
`sch_id` bigint NOT NULL,
|
||||
`sort_no` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_app_sch_key` (`sch_id`,`app_key`),
|
||||
KEY `idx_flw_app_sch_enabled` (`sch_id`,`enabled`)
|
||||
) 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,
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
`dept_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`dept_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`sch_id` bigint NOT NULL,
|
||||
`status` int NOT NULL,
|
||||
`user_id` int NOT NULL,
|
||||
`user_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_map_sch_dept_user` (`sch_id`,`dept_key`,`user_id`),
|
||||
KEY `idx_flw_map_sch_dept` (`sch_id`,`dept_key`,`status`)
|
||||
) 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_attachment` (
|
||||
`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,
|
||||
`field_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`file_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`file_path` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`file_size` bigint DEFAULT NULL,
|
||||
`instance_id` bigint NOT NULL,
|
||||
`mime_type` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`sch_id` bigint NOT NULL,
|
||||
`web_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_flw_att_inst` (`instance_id`,`sch_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,
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
`branch_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`instance_id` bigint NOT NULL,
|
||||
`status` int NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_branch_inst_key` (`instance_id`,`branch_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_callback_log` (
|
||||
`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 DEFAULT NULL,
|
||||
`biz_type` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`event` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`event_id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`instance_id` bigint NOT NULL,
|
||||
`last_error` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`last_http_status` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`next_retry_time` datetime DEFAULT NULL,
|
||||
`payload_json` longtext COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`push_time` datetime DEFAULT NULL,
|
||||
`reg_id` bigint NOT NULL,
|
||||
`retry_count` int NOT NULL,
|
||||
`sch_id` bigint NOT NULL,
|
||||
`status` int NOT NULL,
|
||||
`resp_body` text COLLATE utf8mb4_general_ci,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_log_event` (`event_id`),
|
||||
KEY `idx_flw_log_scan` (`status`,`next_retry_time`),
|
||||
KEY `idx_flw_log_inst` (`instance_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!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_callback_reg` (
|
||||
`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_key` varchar(64) COLLATE utf8mb4_general_ci NOT 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,
|
||||
`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`)
|
||||
) 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_cc` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`instance_id` bigint NOT NULL COMMENT '归属办件',
|
||||
`sch_id` bigint NOT NULL COMMENT '租户键(冗余,列表免 join)',
|
||||
`node_id` varchar(64) NOT NULL COMMENT '定义节点 id',
|
||||
`node_name` varchar(128) DEFAULT NULL COMMENT '节点名快照',
|
||||
`user_id` int NOT NULL COMMENT '接收人 id',
|
||||
`user_name` varchar(128) DEFAULT NULL COMMENT '接收人姓名快照',
|
||||
`sent_time` datetime(3) NOT NULL COMMENT '抄送时间(节点激活时刻,毫秒)',
|
||||
`create_by` varchar(64) DEFAULT NULL,
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`update_by` varchar(64) DEFAULT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_flw_cc_user` (`user_id`,`sent_time`),
|
||||
KEY `idx_flw_cc_inst` (`instance_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='抄送留痕(NOTIFY 节点逐接收人一行)';
|
||||
/*!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_def` (
|
||||
`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,
|
||||
`code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`content_json` longtext COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`form_id` bigint DEFAULT NULL,
|
||||
`item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`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;
|
||||
/*!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_dept` (
|
||||
`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,
|
||||
`dept_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`sch_id` bigint NOT NULL,
|
||||
`source_group_id` int DEFAULT NULL,
|
||||
`status` int NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_dept_sch_srcgroup` (`sch_id`,`source_group_id`),
|
||||
KEY `idx_flw_dept_sch_status` (`sch_id`,`status`)
|
||||
) 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_dept_member` (
|
||||
`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,
|
||||
`dept_id` bigint NOT NULL,
|
||||
`status` int NOT NULL,
|
||||
`user_id` int NOT NULL,
|
||||
`user_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_dept_member` (`dept_id`,`user_id`),
|
||||
KEY `idx_flw_dept_member_dept` (`dept_id`,`status`)
|
||||
) 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_instance` (
|
||||
`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 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_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,
|
||||
`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,
|
||||
`sch_id` bigint NOT NULL,
|
||||
`status` int NOT NULL,
|
||||
`submit_time` datetime DEFAULT NULL,
|
||||
`submitter_id` int NOT NULL,
|
||||
`submitter_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`version` int NOT NULL,
|
||||
`apply_app_code` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`apply_app_key` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`assign_json` longtext COLLATE utf8mb4_general_ci,
|
||||
`apply_request_token` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`apply_round` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_instance_biz` (`sch_id`,`biz_type`,`biz_id`),
|
||||
KEY `idx_flw_inst_submitter` (`sch_id`,`submitter_id`,`status`),
|
||||
KEY `idx_flw_inst_status_time` (`sch_id`,`status`,`submit_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!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_route_log` (
|
||||
`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,
|
||||
`attempt` int NOT NULL,
|
||||
`decide_time` datetime(3) NOT 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,
|
||||
`outlet` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`raw_response` text COLLATE utf8mb4_general_ci,
|
||||
`router_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`sch_id` bigint NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_flw_route_log_inst` (`instance_id`,`node_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_router` (
|
||||
`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,
|
||||
`base_path` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`path` varchar(255) COLLATE utf8mb4_general_ci NOT 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_task` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`create_time` datetime DEFAULT NULL,
|
||||
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`update_time` datetime DEFAULT NULL,
|
||||
`arrive_time` datetime(3) NOT NULL COMMENT '到达时间(毫秒)',
|
||||
`assignee_id` int NOT NULL,
|
||||
`assignee_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`deadline` datetime DEFAULT NULL,
|
||||
`handle_time` datetime(3) DEFAULT NULL COMMENT '办理时间(毫秒)',
|
||||
`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,
|
||||
`opinion` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`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 */;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
# 真实 MySQL 测试必须使用本次任务的独立 schema,禁止默认连开发/生产库。
|
||||
: "${CI_TEST_DB_URL:?CI_TEST_DB_URL is required}"
|
||||
: "${CI_TEST_DB_USER:?CI_TEST_DB_USER is required}"
|
||||
: "${CI_TEST_DB_PASSWORD:?CI_TEST_DB_PASSWORD is required}"
|
||||
case "$CI_TEST_DB_URL" in jdbc:mysql://*) ;; *) echo 'Expected a MySQL test URL' >&2; exit 2 ;; esac
|
||||
address=${CI_TEST_DB_URL#jdbc:mysql://}
|
||||
host=${address%%/*}; host=${host%%:*}
|
||||
case "$host" in localhost|127.0.0.1|mavenbuild-*-db) ;; *) echo 'Database host is outside isolated CI allowlist' >&2; exit 2 ;; esac
|
||||
schema=${address#*/}; schema=${schema%%\?*}
|
||||
case "$schema" in flowcenter_ci|flowcenter_ci_*) ;; *) echo 'Database schema is outside isolated CI prefix' >&2; exit 2 ;; esac
|
||||
export SPRING_DATASOURCE_URL="$CI_TEST_DB_URL"
|
||||
export SPRING_DATASOURCE_USERNAME="$CI_TEST_DB_USER"
|
||||
export SPRING_DATASOURCE_PASSWORD="$CI_TEST_DB_PASSWORD"
|
||||
TESTS=FlApplyRecoveryTest,FlFormBindingTest,OapiFlowApplyDefVersionTest,CallbackPropertiesTest,FlowDefAdminServicePublishContractTest,FormCenterSchemaClientTest,OapiFlowTasksByBizTest,OapiFlowP35DoneCcContractTest
|
||||
# Surefire 的选择器部分匹配也可能成功;每个指定类必须随源码存在。
|
||||
oldIFS=$IFS
|
||||
IFS=,
|
||||
for test in $TESTS; do
|
||||
[ -n "$(find src/test/java -type f -name "$test.java" -print -quit)" ] || { echo "Missing required test class: $test" >&2; exit 2; }
|
||||
done
|
||||
IFS=$oldIFS
|
||||
if [ "$#" -eq 0 ]; then set -- test; fi
|
||||
exec mvn -B "$@" -Dtest="$TESTS" -DfailIfNoTests=true -Dsurefire.failIfNoSpecifiedTests=true -DforkCount=0 -Dspring.jpa.hibernate.ddl-auto=validate < /dev/null
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
: "${CI_TEST_DB_URL:?isolated empty MySQL schema URL is required}"
|
||||
case "$CI_TEST_DB_URL" in */flowcenter_ci_invalid\?*|*/flowcenter_ci_invalid_*\?*) ;; *) echo 'Negative guard requires its own invalid test schema' >&2; exit 2 ;; esac
|
||||
log=$(mktemp /tmp/flow-ci-negative.XXXXXX)
|
||||
trap 'rm -f "$log"' EXIT
|
||||
set +e
|
||||
sh ci/test-gate.sh clean package >"$log" 2>&1
|
||||
code=$?
|
||||
set -e
|
||||
cat "$log"
|
||||
echo "NEGATIVE_MVN_EXIT=$code"
|
||||
[ "$code" -ne 0 ] || { echo 'Empty schema unexpectedly passed' >&2; exit 1; }
|
||||
grep -q 'Schema-validation: missing table' "$log"
|
||||
grep -q 'Tests run: 92, Failures: 0, Errors: 11, Skipped: 0' "$log"
|
||||
[ -z "$(find target -maxdepth 1 -name '*.jar' -print -quit 2>/dev/null)" ] || { echo 'Failed gate produced a JAR' >&2; exit 1; }
|
||||
echo 'CI_EMPTY_SCHEMA_FAILURE_GUARD=PASS'
|
||||
@@ -0,0 +1,38 @@
|
||||
# 流程定义精确表单版本契约(2026-10-23,R2 / R1)
|
||||
|
||||
流程关联表单时必须同时绑定 `formUuid` 与正整数 `formVersion`。保存显式关联、发布、启用统一调用
|
||||
`GET /oapi/form/schema?schId=&uuid=&version=`,核对返回 UUID 和版本;不回落到当前版本。
|
||||
未关联表单的独立流程(Mode C)仍可使用;孤立版本号不是未关联流程,必须拒绝。
|
||||
|
||||
UUID-only 存量定义不能发布或启用,必须重新选择明确版本。当前开发阶段不做存量迁移。
|
||||
本轮不修改任何数据库;部署前核查 `def68` 的现有绑定即可。
|
||||
|
||||
FC 精确 schema 只返回 `publish_time != null` 的版本行。草稿不能作为发布契约。
|
||||
已发布历史版本仍可按坐标读取,不要求它是头表当前版本;停用/软删的历史读取与原授权边界保持。
|
||||
FC 将不存在、不可读、未发布版本统一为 `PARAM_ERROR(2)`,FL 不通过文案猜测资源存在性。
|
||||
|
||||
## 稳定错误码与恢复动作
|
||||
|
||||
API 信封 `code` 使用下列数值;`msg` 仅用于展示,消费方不得解析文案判断重试。
|
||||
|
||||
| code / 名称 | 含义 | 恢复动作 |
|
||||
|---|---|---|
|
||||
| 46101 `VERSION_REQUIRED` | 已指定 UUID,未指定版本 | 重新绑定;发布/启用均阻断,不调用 FC |
|
||||
| 46102 `INVALID_COORDINATE` | 半个坐标、版本非正整数/非整数/溢出 | 修正请求;不盲目重试 |
|
||||
| 46103 `SCHEMA_UNAVAILABLE` | 网络、空响应、服务端故障等拉取失败 | 可重试;客户端每次最多请求 2 次 |
|
||||
| 46104 `SCHEMA_CONTRACT_VIOLATION` | 返回 UUID/版本缺失、不合法或不匹配 | 阻断;检查契约或重新选择坐标,不盲目重试 |
|
||||
| 46105 `RESOURCE_UNAVAILABLE` | FC 业务码 2 或 HTTP 404,指定发布资源不存在或不可读 | 检查资源/发布版本/读授权,不盲目重试 |
|
||||
| 46106 `ACCESS_DENIED` | SDK 身份/授权业务码 3、4 | 修复身份/授权后重试,不自动重放 |
|
||||
|
||||
坐标核验失败、资源不可用与身份拒绝均不在客户端重试循环内重复请求。
|
||||
`SchemaUnavailableException` 与 `SchemaContractViolationException` 在公共边界分别翻译成错误码,
|
||||
保留 cause;不存在被公共包装吞掉后无法命中的分类 catch。
|
||||
启用只核验绑定,不覆盖此前发布的 `formContract` 快照;运行期继续使用流程版本里的原快照。
|
||||
|
||||
## 验证与边界
|
||||
|
||||
`FlFormBindingTest` 覆盖发布/启用缺版本、非法版本、孤立版本、四类失败码和不落库、启用保留快照。
|
||||
`FlowDefAdminServicePublishContractTest` 全部关联夹具带 v3,原表达式键查、类型桶查、域查红牌继续验证。
|
||||
`FormCenterSchemaClientTest` 覆盖不合法返回版本、UUID 不符、按业务码分类及重试次数。
|
||||
FC `OapiSchemaPublishedVersionTest` 验证真实服务层的草稿阻断和历史已发布版可读;其中仓储是桩,
|
||||
不是数据库或 160 验收。
|
||||
@@ -0,0 +1,118 @@
|
||||
# FL公共schema扩展兼容边界与下一批任务草案(TASK_4)
|
||||
|
||||
日期:2026-10-22。状态:文档检查完成;没有实施显隐/矩阵/量表,也没有放宽FC白名单或修改路由算法。伴随契约:[FC草案](../../../form-center/service/docs/schema-extension-draft.md)。本相对链接跨目录时应以工作区根目录的 `form-center/service/docs/schema-extension-draft.md` 为准。
|
||||
|
||||
## 1. HEAD与当前锚点
|
||||
|
||||
FL后端HEAD=`2f54f37ab34ca71410ec854f1adcc1da7c4dbe5f`;FC后端HEAD=`b2437a20742bfbfe38a564134904f3e0d7d931a3`;FC前端HEAD=`2e406a88b1d9e09b2ca54a8cf68a3726174f49c0`。均有任务实施后的未提交工作区内容,不能写成已合入。
|
||||
|
||||
FL / `src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java` / `FormCenterSchemaClient#toSnapshot(Map)`:下段已与当前HEAD的git show重新对照,当前工作区仍保留这段搬运规则:
|
||||
|
||||
```java
|
||||
field.put("key", f.path("key").asText(null));
|
||||
field.put("type", f.path("type").asText(null));
|
||||
JsonNode required = f.get("required");
|
||||
field.put("required", required == null ? Boolean.FALSE : required.asBoolean(false));
|
||||
```
|
||||
|
||||
同方法对数组options搬运value(缺value时回落label)为enum;顶层快照为formUuid/formVersion/audience/fields。它没有完整保留field.valueSchema、visible_when、pattern、min/max、precision或复合值声明;更没有执行这些规则。`type`字符串被带入不能证明引擎支持复杂值。
|
||||
|
||||
FL / `service/FlowDefAdminService#save/#publish/#enable`:当前工作区在保存绑定前核验确切版本,升版继承formUuid/formVersion,取消关联清空坐标;发布以 `fetchSchema(schId,uuid,version)` 核对返回坐标后嵌入contentJson.formContract。此管理行为属于Task1及本轮代码修复,**不能声称存在于上述HEAD**。已发布/停用版本的快照不能原地重写;修改关联走新草稿。旧两参fetchSchema仍是内部方法,不作为新管理链的无版本入口。
|
||||
|
||||
FC / `common/FormSchemaValidator#validateField`:
|
||||
|
||||
```java
|
||||
rejectUnknownProps(field, ALLOWED_PROPS, "字段属性(" + key + ")");
|
||||
```
|
||||
|
||||
FC / `common/FormValueValidator#validate`先处理required再分派类型;当前没有显隐计算。FC / `common/FormValueSchemaBuilder#withValueSchemas`只生成读侧机读描述;前端 `types/formSchema.ts` / `ALLOWED_FIELD_PROPS`及 `components/fields/index.ts#fieldWidget`限定当前控件。根未知属性未统一拒绝,不构成安全规则兼容机制。
|
||||
|
||||
## 2. 当前边界与未来兼容矩阵
|
||||
|
||||
| FC值/候选类型 | 当前schema/快照事实 | 未来FL路由要求(未实施) |
|
||||
|---|---|---|
|
||||
| number | JSON number;FC校验边界/precision;FL快照只保留type等少量字段 | 显式数值域及空值语义;不能从缺失边界推定任意数值合法 |
|
||||
| select/radio | 稳定value字符串;FL有enum | 用value比较,不能按label;value缺失不能静默用展示文案给新契约兜底 |
|
||||
| multiselect/checkbox | string[];快照有type及enum,但无完整array约束 | 禁止把整个数组当标量eq/gt操作数;若需contains等运算,必须另立版本化运算符及集合语义 |
|
||||
| upload/image | [{id,fileName,key}];机读形状未完整进入FL快照 | 禁止对象/数组转字符串或比较附件URL作为路由;文件访问权限仍在FC |
|
||||
| display metadata | 当前无通用跨端透传保证 | 获批后可在专用命名空间保留,不能执行或改变审批判据 |
|
||||
| visible_when | FC字段属性白名单拒绝,FL无执行器 | 必需规则不受支持则拒绝发布/发起;不得只在编辑器隐藏后继续按旧快照执行 |
|
||||
| matrix/scale | 未冻结/未放行 | 拒绝复合值标量路由,或只消费显式版本化投影;两种策略实施前定稿 |
|
||||
|
||||
当前表达式校验与运行仍按现有引擎行为;上表右列是下一批要求,不冒充已存在的“复杂值安全闸”。不得为了通过候选schema而先修改PHASE1_TYPES、ALLOWED_PROPS或表达式白名单。
|
||||
|
||||
## 3. 能力协商与投影候选
|
||||
|
||||
下一版formContract应明确schemaContractVersion、requiredCapabilities以及字段完整值形状;具体字段名尚为候选。unsupported required capability必须阻断发布,不能丢掉字段后继续成功。已发布流程继续使用其原快照,不被FC新能力升级暗中改变。
|
||||
|
||||
若选择投影,应明确:
|
||||
|
||||
- 稳定命名空间及命名冲突规则(候选如`projection.satisfaction_score`,当前表达式是否支持点号必须先核实,不能直接采用)。
|
||||
- 投影算法版本、输入表单版本、rowKey/枚举值域及顺序,禁止label参与算法。
|
||||
- 全部可见、部分空值、整题隐藏、不适用、非法复合结构的输出分别定义;hidden不能自动映射为0,null不能自动映射为空字符串。
|
||||
- 谁计算并校验投影、在哪个可信边界附带它、如何进入revision/hash;不能信任浏览器额外上报的分数。
|
||||
- 对象键顺序与数组顺序遵循已有规范化;如改变快照成员或哈希规则必须升协议,不原地改fc.canon.v1。
|
||||
|
||||
| 场景 | 候选输出/行为 |
|
||||
|---|---|
|
||||
| 已知row、有效值、题目可见 | 按已冻结投影算法产出标量/枚举 |
|
||||
| 题目隐藏 | 输出显式不适用标记或拒绝该路由;不得假装0分,具体方案待定 |
|
||||
| 必答row缺失 | FC拒绝答卷,不向FL构造“补零”结果 |
|
||||
| 重复row/未知row/非法枚举 | 拒绝复合值,错误可定位,不执行路由 |
|
||||
| 旧schema无该题/旧流程无该能力 | 保持旧快照行为;需要新能力则升流程版本 |
|
||||
|
||||
## 4. 下一批任务书草案(均未授权实施)
|
||||
|
||||
这些文件已分别属于Task1/2或历史功能,实施前必须重新分配写权限;不能在Task3分支顺手追加。以下为成对功能包,**不是可同时执行的互不相交白名单**。正式拆分时要核对真实路径、确定独占文件集和按序依赖。
|
||||
|
||||
### E1:公共扩展版本及静态规则校验
|
||||
|
||||
背景:先建立能力协商及拒绝边界,避免静默丢规则。
|
||||
|
||||
改动候选:FC `common/FormSchemaValidator#validateSchema/#validateField/#validateOptions`、`common/FormValueSchemaBuilder#withValueSchemas`;FC前端 `types/formSchema.ts`、设计器序列化路径;拟新增独立规则AST/能力清单类(名称待实施时确定)。必须同时处理根/group/field/options各层保留与拒绝规则,不能只放宽字段白名单。
|
||||
|
||||
行为:先校验契约版本与必需能力,再核对引用/类型/循环/资源预算;未知安全规则拒绝,允许的display metadata仅保留不执行。valueSchema仍由服务端派生。
|
||||
|
||||
依赖/约束:重新分配Task1 schema/目录契约相关文件;不改AppRegistry或部门模型,不引入eval。
|
||||
|
||||
验收计划:未知根/字段/选项、循环依赖、类型不符、超预算、保留metadata但不执行、旧schema保持原行为。此处仅列计划,本轮不写、不跑测试。
|
||||
|
||||
### E2:可见性规则的全写入通道与普通题型回归
|
||||
|
||||
背景:显隐不是纯前端效果,必须覆盖自填、匿名、hosted、本人/管理员编辑和导入。
|
||||
|
||||
改动候选:FC `service/AnswerService#doSubmit`、`service/AnswerReadService#editBySubmitter`、`service/AnswerAdminService#edit`、`service/AnswerImportService`、公共FormValueValidator;FC前端 `views/Fill.vue`、字段渲染/校验及编辑入口。具体编辑组件路径在正式任务前用rg重新定位,不虚构已存在文件名。
|
||||
|
||||
行为:取钉版schema,执行同一规则服务,先visible再required;隐藏值A/B策略必须先获定稿。导入预检和执行使用同一版本;本人/匿名身份边界和hosted本人边界保持;索引/outbox基于实际存储结果更新。
|
||||
|
||||
依赖/约束:E1完成且隐藏值策略定稿;Answer/投递/Fill属于Task2,需重新分配;OPEN-T19b原有代填不是此任务取消对象。
|
||||
|
||||
验收计划:number=0、false、select value、空数组、多选顺序、upload归属;切换上游使下游隐藏;旧答卷按原版编辑;匿名与hosted隔离;导入同规则、原子回滚与hash一致。
|
||||
|
||||
### E3:统计导出与历史解释
|
||||
|
||||
改动候选:FC `service/AnswerStatsService`、`AnswerExportService`、`AnswerIndexService`和已有统计/导出前端(路径须正式定位);矩阵渲染与行列编辑组件仅在题型获批后新增。
|
||||
|
||||
行为:区分N/E/V/A四类分母;逐答卷版本解释字段/规则,导出不适用与空值不同;历史读取不修改答卷。不跨版本同key暗合并,不把多选计次当人数。
|
||||
|
||||
依赖/约束:E2后才有稳定写入语义;不得用当前schema重解释历史数据。矩阵object/array结构、rowKey、算法上限先定稿。
|
||||
|
||||
验收计划:版本间增删题、隐藏/可见未答、V=0、多选计数、矩阵每行/整题分母、字典label变动、历史附件及业务扩展保留。
|
||||
|
||||
### E4:FC—FL成对快照、复杂值拒绝或投影
|
||||
|
||||
改动候选:FC `service/OapiFormService`/机读schema契约;FL `service/FormCenterSchemaClient#toSnapshot`、`FlowDefAdminService#publish`、`FlowInstanceService`输入校验及`engine/FlowDefModel`/相关表达式验证入口;FL前端定义编辑器及表达式提示。调用端与服务端在一个任务中交付,不硬拆端点和调用。
|
||||
|
||||
行为:先识别required capabilities,再保存完整契约快照;不支持的安全能力拒绝发布。complex值不走标量比较,或仅使用显式受信投影。新绑定保存/发布均核验确切formUuid/version;旧快照只读,改动走新流程版本。
|
||||
|
||||
依赖/约束:E1能力格式稳定,E2/E3值与统计口径稳定;Task1版本绑定、Task2流程发起文件需重新分配;不改部门节点策略,不把矩阵处理塞进回调恢复。
|
||||
|
||||
验收计划:array/object标量路由拒绝、投影边界与null、返回坐标不符、撤权、跨版本不可变快照、普通表单仍可发布/发起。资源失败必须有稳定码,不把“网络不可达”误当“不支持能力”。
|
||||
|
||||
## 5. 本轮交付检查
|
||||
|
||||
- 当前白名单、11类控件、根/group/field/options边界、FC验证与FL快照搬运已逐项对照源码。
|
||||
- 样例均明确标注候选;隐藏值A/B未被私自定稿,matrix/scale未被写成已实现。
|
||||
- 数值/单选/多选/附件及所有写入、编辑、导入、统计、导出的版本来源详见FC伴随草案。
|
||||
- L1状态:**文档检查完成,功能测试未执行**。按用户本轮指令只做编译/跳过测试打包;构建结果记录在根目录docs/tasks/evidence/refinement-20261022,不构成候选功能验收。
|
||||
- 后续具体测试与生产联调方案另出;本轮不连接真实库/MQ、不执行DDL、不启动对账、不做前端视觉验收。
|
||||
@@ -50,6 +50,17 @@
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- 外观偏好同源读端点 GET /api/appearance(三态 proxy/local/off,默认 proxy 经 YschOpenCall
|
||||
代理 UserHome /oapi/appearance,userhome 地址走平台 application-platform.yml 兜底)。
|
||||
平台件不进聚合包:本地同名控制器已删(2026-10-12 任务 E 收敛,starter 端点同 URL 同语义) -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-appearance-spring-boot-starter</artifactId>
|
||||
<!-- 显式版本(2026-10-17 过夜任务环境修复,与 NoticeService 同因):该件只由 BasePlatform
|
||||
的 feat/appearance-sdk 分支及其 BOM 托管,本次接入件工作基于 feat/flow-embed(BOM 无此条目),
|
||||
不写版本则依赖解析直接失败、本仓无法打包。appearance 分支合流后应删本行回落 BOM。 -->
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<!-- 平台 auth starter(0928 后快照)的 WeChatAuthAutoConfiguration 签名引用 StringRedisTemplate,
|
||||
data-redis 为 optional,接入前提=应用自带(见该 starter pom 注释);缺失时 bean 签名解析直接
|
||||
TypeNotPresentException,应用起不来 -->
|
||||
@@ -70,6 +81,14 @@
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 条件路由表达式引擎(2026-10-10 四议题):CONDITION 节点 judgeMode=EXPRESSION 时
|
||||
出边 condition 用 AviatorScript 求值(JDK8 兼容;5.4.3 含安全修复,沙箱见 ExpressionEvaluator) -->
|
||||
<dependency>
|
||||
<groupId>com.googlecode.aviator</groupId>
|
||||
<artifactId>aviator</artifactId>
|
||||
<version>5.4.3</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
@@ -120,6 +120,10 @@ public class CallbackDispatcher {
|
||||
if (event.getData() != null) {
|
||||
data.putAll(event.getData());
|
||||
}
|
||||
// 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。
|
||||
if (event.getRoundIdentity() != null) {
|
||||
data.putAll(event.getRoundIdentity());
|
||||
}
|
||||
payload.put("data", data);
|
||||
String payloadJson = objectMapper.writeValueAsString(payload);
|
||||
|
||||
|
||||
@@ -211,6 +211,105 @@ public class CallbackPushService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调探测(2026-10-11 管理端增强):以该注册的 secret 按现行信封签一条未知事件码 flow.ping,
|
||||
* 走真实推送管道同步一发——接收方按规范 §6.4 对未知事件码"记日志后 200",不破坏业务数据。
|
||||
* 探测是一次性行为:结果落 flw_callback_log(成功=1 / 失败=2,永不进重试队列),
|
||||
* 响应体摘要落 resp_body。返回 {success, httpStatus, elapsedMs, respSnippet, error}。
|
||||
*/
|
||||
public java.util.Map<String, Object> probe(Long schId, FlwCallbackReg reg) {
|
||||
String eventId = UUID.randomUUID().toString().replace("-", "");
|
||||
String body = probePayload(schId, reg, eventId);
|
||||
long start = System.currentTimeMillis();
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
Integer httpStatus = null;
|
||||
String respSnippet = null;
|
||||
String error = null;
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("X-Flw-EventId", eventId);
|
||||
headers.set("X-Flw-Timestamp", ts);
|
||||
headers.set("X-Flw-Nonce", nonce);
|
||||
headers.set("X-Flw-Signature", sign(reg.getSecret(), ts, nonce, body));
|
||||
RestTemplate template = CallbackAppSigner.signedTemplate(
|
||||
appIdentityProvider.getIfAvailable(), reg.getBasePath(), 5000, 10000);
|
||||
ResponseEntity<String> resp = template.exchange(reg.getCallbackUrl(), HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
httpStatus = resp.getStatusCodeValue();
|
||||
respSnippet = resp.getBody() == null ? null : truncate(resp.getBody());
|
||||
} catch (Exception e) {
|
||||
error = rootMessage(e);
|
||||
}
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
boolean ok = httpStatus != null && httpStatus >= 200 && httpStatus < 300;
|
||||
persistProbeLog(schId, reg, eventId, body, ok, httpStatus, respSnippet, error);
|
||||
log.info("[flow-center] 回调探测:reg={} url={} ok={} http={} elapsed={}ms",
|
||||
reg.getId(), reg.getCallbackUrl(), ok, httpStatus, elapsed);
|
||||
java.util.Map<String, Object> out = new java.util.HashMap<>();
|
||||
out.put("success", ok);
|
||||
out.put("httpStatus", httpStatus);
|
||||
out.put("elapsedMs", elapsed);
|
||||
out.put("respSnippet", respSnippet);
|
||||
out.put("error", error);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 探测信封:与 CallbackDispatcher 现行事件同构,event=flow.ping(未知码),instanceId=0 哨兵 */
|
||||
private String probePayload(Long schId, FlwCallbackReg reg, String eventId) {
|
||||
java.util.Map<String, Object> payload = new java.util.HashMap<>();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("event", "flow.ping");
|
||||
payload.put("schId", schId);
|
||||
payload.put("flowGuid", reg.getFlowGuid());
|
||||
payload.put("instanceId", 0L);
|
||||
payload.put("instanceVersion", 0);
|
||||
payload.put("bizType", "probe");
|
||||
payload.put("bizId", String.valueOf(reg.getId()));
|
||||
payload.put("bizTitle", "回调探测 flow.ping");
|
||||
payload.put("occurredTime", LocalDateTime.now().format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
payload.put("taskId", null);
|
||||
payload.put("operatorUserId", null);
|
||||
payload.put("operatorName", null);
|
||||
java.util.Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("probe", true);
|
||||
data.put("message", "flow-center 回调连通性探测(未知事件码,按规范 §6.4 记日志后 200 即可)");
|
||||
payload.put("data", data);
|
||||
try {
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(payload);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("探测信封序列化失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 探测结果落日志行(一次性行为:成功=1 / 失败=2,status≠PENDING 永不被重试扫描捞起) */
|
||||
private void persistProbeLog(Long schId, FlwCallbackReg reg, String eventId, String body,
|
||||
boolean ok, Integer httpStatus, String respSnippet, String error) {
|
||||
try {
|
||||
FlwCallbackLog row = new FlwCallbackLog();
|
||||
row.setSchId(schId);
|
||||
row.setRegId(reg.getId());
|
||||
row.setEventId(eventId);
|
||||
row.setEvent("flow.ping");
|
||||
row.setInstanceId(0L);
|
||||
row.setBizType("probe");
|
||||
row.setBizId(String.valueOf(reg.getId()));
|
||||
row.setPayloadJson(body);
|
||||
row.setRespBody(respSnippet);
|
||||
row.setStatus(ok ? FlwEnums.CallbackLogStatus.SUCCESS.getCode()
|
||||
: FlwEnums.CallbackLogStatus.DEAD.getCode());
|
||||
row.setRetryCount(0);
|
||||
row.setNextRetryTime(LocalDateTime.now());
|
||||
row.setLastHttpStatus(httpStatus == null ? null : String.valueOf(httpStatus));
|
||||
row.setLastError(error == null ? null : truncate(error));
|
||||
row.setPushTime(LocalDateTime.now());
|
||||
logRepository.save(row);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 探测日志落库失败:reg={} {}", reg.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static String sign(String secret, String timestamp, String nonce, String body) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 同步发布阶段冻结轮次身份;不修改引擎或 apply/v2 七支分支。 */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FlowEventIdentityListener {
|
||||
private final FlwInstanceRepository instances;
|
||||
|
||||
@EventListener
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public void capture(FlowEvent event) {
|
||||
instances.findById(event.getInstanceId()).ifPresent(instance ->
|
||||
event.captureRoundIdentity(instance.getApplyRound(), instance.getApplyRequestToken()));
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@ import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.service.FlowAdminGuard;
|
||||
import com.yunhai.flowcenter.service.FlowCallbackAdminService;
|
||||
import com.yunhai.flowcenter.service.FlowDefAdminService;
|
||||
import com.yunhai.flowcenter.service.FlowDiagramService;
|
||||
import com.yunhai.flowcenter.service.FlowMapAdminService;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BasePageResult;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -43,19 +46,67 @@ public class FlowAdminController {
|
||||
|
||||
private final FlowAdminGuard adminGuard;
|
||||
private final FlowDefAdminService defAdminService;
|
||||
|
||||
/**
|
||||
* 表单中心目录客户端(v3/TASK_1)。
|
||||
*
|
||||
* <p><b>字段注入而不是加进构造参数</b>:本类的构造器已被 {@code FlowAdminInstanceHungTest} 与
|
||||
* {@code FlowDiagramServiceTest} 逐位钉住,而那两份测试不在本任务的可改清单里——多一个入参
|
||||
* 会让整个测试树编译不过。</p>
|
||||
*/
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.yunhai.flowcenter.service.FormCenterCatalogClient formCenterCatalogClient;
|
||||
private final FlowMapAdminService mapAdminService;
|
||||
private final FlowCallbackAdminService callbackAdminService;
|
||||
private final com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlowDiagramService diagramService;
|
||||
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
|
||||
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
||||
private final com.yunhai.flowcenter.service.FlowAppAdminService appAdminService;
|
||||
private final com.yunhai.flowcenter.service.FlowDeptAdminService deptAdminService;
|
||||
private final com.yunhai.flowcenter.repository.FlwRouteLogRepository routeLogRepository;
|
||||
|
||||
// ==================== 流程定义 ====================
|
||||
|
||||
@GetMapping("/def/list")
|
||||
public List<FlwDef> defList(BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status) {
|
||||
return defAdminService.list(adminGuard.requireAdmin(user, null), status);
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@RequestParam(value = "appKey", required = false) String appKey) {
|
||||
return defAdminService.list(adminGuard.requireAdmin(user, null), status, appKey);
|
||||
}
|
||||
|
||||
/** 应用清单("按应用筛选"下拉):本校 flw_def 已标注 ∪ 已注册回调的应用 */
|
||||
@GetMapping("/def/appkeys")
|
||||
public List<String> defAppKeys(BoaUser user) {
|
||||
return defAdminService.distinctAppKeys(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
// ==================== 应用设置(2026-10-10 四议题) ====================
|
||||
|
||||
/**
|
||||
* 应用管理列表:本校"可对接流程"白名单(flw_app)。附带一次学校池静默同步
|
||||
* (UserBase list-by-school;不可达降级本地行)。开通过程在 UserBase 管理端做。
|
||||
*/
|
||||
@GetMapping("/app/list")
|
||||
public List<com.yunhai.flowcenter.entity.FlwApp> appList(BoaUser user) {
|
||||
return appAdminService.list(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
/** 开关某应用是否对接流程(停用后 defSave 归属应用校验拦截新引用) */
|
||||
@PostMapping("/app/enable")
|
||||
public void appEnable(BoaUser user,
|
||||
@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
appAdminService.enable(schId,
|
||||
body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id"))),
|
||||
Boolean.parseBoolean(String.valueOf(body.get("enabled"))));
|
||||
}
|
||||
|
||||
/** 应用辅助统计(2026-10-11 增强):appKey → 回调注册数(本校),应用设置页"对接深度"列 */
|
||||
@GetMapping("/app/stats")
|
||||
public Map<String, Long> appStats(BoaUser user) {
|
||||
return appAdminService.stats(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
@GetMapping("/def/get")
|
||||
@@ -63,14 +114,60 @@ public class FlowAdminController {
|
||||
return defAdminService.get(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
/** 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿) */
|
||||
/**
|
||||
* 表单中心目录代理(v3/TASK_1):编辑器"关联表单"选择器的数据源,浏览器只打本应用同源接口,
|
||||
* appKey/secret 不在前端出现。校域由 {@link FlowAdminGuard#requireAdmin} 从 CAS 会话派生,
|
||||
* 前端没有可越界的 schId 入口;中心侧只回"已发布 ∩ 本应用可读"的表单,
|
||||
* 没授权的表压根不出现(不是"看得见但选不了")。
|
||||
*/
|
||||
@GetMapping("/form-center/forms")
|
||||
public com.yunhai.flowcenter.service.FormCenterCatalogClient.CatalogPage formCenterForms(
|
||||
BoaUser user,
|
||||
@RequestParam(value = "appCode", required = false) String appCode,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
return formCenterCatalogClient.listForms(adminGuard.requireAdmin(user, null),
|
||||
appCode, keyword, page, pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;
|
||||
* changeLog=升版本改动说明,仅新版本行落库,>500 截断;
|
||||
* formUuid=关联表单定义 uuid(C1,2026-10-20):null=保持、空串=取消关联、发布后不可改改=升版;
|
||||
* <b>formVersion=v3 确切版本(与 formUuid 成对,单传一个即拒;取消关联时同批清空)</b>。
|
||||
*/
|
||||
@PostMapping("/def/save")
|
||||
public FlwDef defSave(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
Long id = body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id")));
|
||||
return defAdminService.save(schId, id,
|
||||
(String) body.get("code"), (String) body.get("name"),
|
||||
(String) body.get("contentJson"), (String) body.get("baseGuid"));
|
||||
(String) body.get("contentJson"), (String) body.get("baseGuid"),
|
||||
(String) body.get("appKey"), (String) body.get("changeLog"),
|
||||
(String) body.get("formUuid"),
|
||||
parseFormVersion(body.get("formVersion")));
|
||||
}
|
||||
|
||||
private static Integer parseFormVersion(Object value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return Integer.valueOf(String.valueOf(value));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "formVersion 必须是正整数", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据原地编辑(2026-10-10 议题2):只改 name/appKey 不升版本(version/flowGuid/status/contentJson
|
||||
* 结构均不动,仅同步 contentJson 顶层 name);已发布亦可。结构改动请走编辑器升版本。
|
||||
*/
|
||||
@PostMapping("/def/updateMeta")
|
||||
public FlwDef defUpdateMeta(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return defAdminService.updateMeta(schId,
|
||||
body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id"))),
|
||||
(String) body.get("name"), (String) body.get("appKey"));
|
||||
}
|
||||
|
||||
@PostMapping("/def/publish/{id}")
|
||||
@@ -94,6 +191,16 @@ public class FlowAdminController {
|
||||
return defAdminService.versions(adminGuard.requireAdmin(user, null), flowGuid);
|
||||
}
|
||||
|
||||
/** 在途办件数(议题二④):flowGuids 逗号分隔,去重后最多 100;返回 flowGuid → 审批中办件数(无在途也返 0 键) */
|
||||
@GetMapping("/def/inflight")
|
||||
public Map<String, Long> defInflight(BoaUser user, @RequestParam("flowGuids") String flowGuids) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
List<String> guids = java.util.Arrays.stream(flowGuids.split(","))
|
||||
.map(String::trim).filter(s -> !s.isEmpty()).distinct().limit(100)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
return defAdminService.inflightCounts(schId, guids);
|
||||
}
|
||||
|
||||
/** 流程定义示意图(方案 §3.1):requireAdmin 后按 defId 任意版本出图(草稿可预览) */
|
||||
@GetMapping("/def/diagram")
|
||||
public FlowModels.DiagramVo defDiagram(BoaUser user, @RequestParam("id") Long id,
|
||||
@@ -118,35 +225,100 @@ public class FlowAdminController {
|
||||
.body(imageService.renderPng(diagramService.build(def, null, mode)));
|
||||
}
|
||||
|
||||
// ==================== 办理人映射 ====================
|
||||
// ==================== 科室管理(2026-10-10 四议题·议题3,取代 /map/* 映射 CRUD) ====================
|
||||
|
||||
@GetMapping("/map/list")
|
||||
public List<FlwAssigneeMap> mapList(BoaUser user,
|
||||
@RequestParam(value = "deptKey", required = false) String deptKey) {
|
||||
return mapAdminService.listByDept(adminGuard.requireAdmin(user, null), deptKey);
|
||||
/** 科室列表(编辑器 ctx.depts 同结构:deptKey=id 字符串 + 启用成员数) */
|
||||
@GetMapping("/dept/list")
|
||||
public List<com.yunhai.flowcenter.service.FlowDeptAdminService.DeptVo> deptList(BoaUser user) {
|
||||
return deptAdminService.list(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
@PostMapping("/map/save")
|
||||
public FlwAssigneeMap mapSave(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
@PostMapping("/dept/create")
|
||||
public com.yunhai.flowcenter.entity.FlwDept deptCreate(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
return deptAdminService.create(adminGuard.requireAdmin(user, null), (String) body.get("deptName"));
|
||||
}
|
||||
|
||||
@PostMapping("/dept/rename")
|
||||
public com.yunhai.flowcenter.entity.FlwDept deptRename(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return mapAdminService.save(schId,
|
||||
(String) body.get("deptKey"), (String) body.get("deptName"),
|
||||
body.get("userId") == null ? null : Integer.valueOf(String.valueOf(body.get("userId"))),
|
||||
(String) body.get("userName"));
|
||||
return deptAdminService.rename(schId, requireId(body.get("id")), (String) body.get("deptName"));
|
||||
}
|
||||
|
||||
@PostMapping("/map/disable/{id}")
|
||||
public void mapDisable(BoaUser user, @PathVariable("id") Long id) {
|
||||
mapAdminService.disable(adminGuard.requireAdmin(user, null), id);
|
||||
/** 停用/启用科室(停用=解析不出办理人、发布校验拦新引用;成员不动) */
|
||||
@PostMapping("/dept/status")
|
||||
public void deptStatus(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
deptAdminService.setStatus(schId, requireId(body.get("id")), boolVal(body.get("enabled")));
|
||||
}
|
||||
|
||||
/** 启用映射(停用行的恢复入口;DEPT_KEY 解析只取启用行) */
|
||||
@PostMapping("/map/enable/{id}")
|
||||
public FlwAssigneeMap mapEnable(BoaUser user, @PathVariable("id") Long id) {
|
||||
return mapAdminService.enable(adminGuard.requireAdmin(user, null), id);
|
||||
/** 删除科室(级联删成员;被流程引用的 deptKey 将解析失败——发布校验/办理时会拦) */
|
||||
@PostMapping("/dept/delete")
|
||||
public void deptDelete(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
deptAdminService.delete(schId, requireId(body.get("id")));
|
||||
}
|
||||
|
||||
/** 成员检索(UserBase /oapi/user/byId) */
|
||||
@GetMapping("/dept/members")
|
||||
public List<com.yunhai.flowcenter.entity.FlwDeptMember> deptMembers(BoaUser user,
|
||||
@RequestParam("deptId") Long deptId) {
|
||||
return deptAdminService.members(adminGuard.requireAdmin(user, null), deptId);
|
||||
}
|
||||
|
||||
/** 批量加成员(已存在跳过;姓名经 UserBase 补全),返回新增数 */
|
||||
@PostMapping("/dept/memberAdd")
|
||||
public int deptMemberAdd(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
List<Integer> userIds = new java.util.ArrayList<>();
|
||||
Object raw = body.get("userIds");
|
||||
if (raw instanceof List) {
|
||||
for (Object o : (List<?>) raw) {
|
||||
if (o != null) {
|
||||
userIds.add(Integer.valueOf(String.valueOf(o)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return deptAdminService.memberAdd(schId, requireId(body.get("deptId")), userIds);
|
||||
}
|
||||
|
||||
@PostMapping("/dept/memberRemove")
|
||||
public void deptMemberRemove(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
deptAdminService.memberRemove(schId, requireId(body.get("memberId")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键拉取教师组·预览:学校子树内教师组容器 → 科室+组内成员(UserBase 组 id 即将落
|
||||
* flw_dept.source_group_id)。导入前先展示给管理员确认,不落库。
|
||||
*/
|
||||
@GetMapping("/dept/groupPreview")
|
||||
public List<com.yunhai.flowcenter.service.FlowMapAdminService.GroupDeptVo> groupPreview(BoaUser user) {
|
||||
return mapAdminService.previewGroupDepts(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
/** 一键拉取教师组·导入:upsert flw_dept(source_group_id 幂等,重复拉取=刷新组名与成员集) */
|
||||
@PostMapping("/dept/importGroups")
|
||||
public com.yunhai.flowcenter.service.FlowDeptAdminService.GroupImportResultVo importGroups(BoaUser user) {
|
||||
return deptAdminService.importGroups(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
private static Long longVal(Object o) {
|
||||
return o == null ? null : Long.valueOf(String.valueOf(o));
|
||||
}
|
||||
|
||||
/** 科室/成员行 id 缺失=调用方bug/脏请求:回业务错提示,不让 findById(null) 炸成笼统 500 */
|
||||
private static Long requireId(Object o) {
|
||||
Long v = longVal(o);
|
||||
if (v == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少 id");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static boolean boolVal(Object o) {
|
||||
return o == null ? false : Boolean.parseBoolean(String.valueOf(o));
|
||||
}
|
||||
|
||||
/** 成员检索(UserBase /oapi/user/byId;编辑器选人/姓名补全共用,非映射 CRUD,保留) */
|
||||
@GetMapping("/map/lookupUser")
|
||||
public com.yunhai.platform.utils.model.BoaUser lookupUser(BoaUser user,
|
||||
@RequestParam("userId") Integer userId) {
|
||||
@@ -154,21 +326,6 @@ public class FlowAdminController {
|
||||
return mapAdminService.lookupUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键拉取教师组·预览:学校子树内教师组容器 → 科室+组内成员(deptKey=UserBase 组 id)。
|
||||
* 导入前先展示给管理员确认,不落库。
|
||||
*/
|
||||
@GetMapping("/map/groupPreview")
|
||||
public List<com.yunhai.flowcenter.service.FlowMapAdminService.GroupDeptVo> groupPreview(BoaUser user) {
|
||||
return mapAdminService.previewGroupDepts(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
/** 一键拉取教师组·导入:批量落映射(已存在且启用中的 sch+dept+user 跳过),返回计数 */
|
||||
@PostMapping("/map/importGroups")
|
||||
public com.yunhai.flowcenter.service.FlowMapAdminService.GroupImportResultVo importGroups(BoaUser user) {
|
||||
return mapAdminService.importGroups(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
// ==================== 选人组件数据源 ====================
|
||||
|
||||
/** 选人组件树:学校组树(组→组内用户叶子),管理端「选择用户」弹窗共用 */
|
||||
@@ -206,6 +363,16 @@ public class FlowAdminController {
|
||||
return java.util.Collections.singletonMap("secret", secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调探测(2026-10-11 管理端增强):对该注册同步推送一条 flow.ping(未知事件码,接收方按
|
||||
* 规范 §6.4 记日志后 200),返回 {success, httpStatus, elapsedMs, respSnippet, error};
|
||||
* 结果同时落推送日志一行(event=flow.ping)。
|
||||
*/
|
||||
@PostMapping("/callback/probe/{id}")
|
||||
public Map<String, Object> probe(BoaUser user, @PathVariable("id") Long id) {
|
||||
return callbackAdminService.probe(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
@GetMapping("/callback/logs")
|
||||
public BasePageResult<FlwCallbackLog> logs(BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@@ -242,6 +409,23 @@ public class FlowAdminController {
|
||||
callbackAdminService.rePush(adminGuard.requireAdmin(user, null), logIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对接健康度体检(2026-10-15 议题二 P2):每应用一行六项检查 + 综合评分。
|
||||
* 数据全部来自已有资产(flw_callback_reg / flw_callback_log),零新增表;
|
||||
* 应用名从 flw_app 白名单补齐(缺失回落 appKey)。
|
||||
*/
|
||||
@GetMapping("/callback/health")
|
||||
public List<com.yunhai.flowcenter.model.AppHealthVo> callbackHealth(BoaUser user) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
java.util.Map<String, String> appNames = new java.util.HashMap<>();
|
||||
for (com.yunhai.flowcenter.entity.FlwApp app : appAdminService.list(schId)) {
|
||||
if (app.getAppKey() != null) {
|
||||
appNames.put(app.getAppKey(), app.getAppName());
|
||||
}
|
||||
}
|
||||
return callbackAdminService.health(schId, appNames);
|
||||
}
|
||||
|
||||
// ==================== 条件路由注册 / 重跑路由 ====================
|
||||
|
||||
/** 判断服务注册列表(flw_router 全局表;编辑器 routerKey 下拉与管理页共用) */
|
||||
@@ -274,26 +458,86 @@ public class FlowAdminController {
|
||||
/**
|
||||
* 重跑路由(判断失败挂起的恢复入口,2026-09-24 §4.4):重新调判断服务,成功即按出口续走;
|
||||
* 仍失败回业务错(办件继续挂起,route_log 已落新行)。
|
||||
* useLatestDef=true(F5-4 僵尸逃生门):按该 flowGuid 当前已发布最高版本重跑并刷新办件定义快照。
|
||||
*/
|
||||
@PostMapping("/instance/{id}/reroute")
|
||||
public void instanceReroute(BoaUser user, @PathVariable("id") Long id) {
|
||||
routerAdminService.reroute(adminGuard.requireAdmin(user, null), id);
|
||||
public void instanceReroute(BoaUser user, @PathVariable("id") Long id,
|
||||
@RequestParam(value = "useLatestDef", required = false, defaultValue = "false")
|
||||
boolean useLatestDef) {
|
||||
routerAdminService.reroute(adminGuard.requireAdmin(user, null), id, useLatestDef);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达式调试求值(2026-10-10 四议题):body {expression, variablesJson},与运行时同一求值核心
|
||||
* (ExpressionEvaluator);不落库、不走流程,发布前/挂起排查用。内置变量需在 variablesJson 里
|
||||
* 自行提供样例值(线上是引擎注入的办件上下文)。
|
||||
*/
|
||||
@PostMapping("/router/eval")
|
||||
public Map<String, Object> routerEval(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
adminGuard.requireAdmin(user, null);
|
||||
String expression = body.get("expression") == null ? "" : String.valueOf(body.get("expression")).trim();
|
||||
String variablesJson = body.get("variablesJson") == null ? "" : String.valueOf(body.get("variablesJson")).trim();
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
if (expression.isEmpty()) {
|
||||
result.put("result", false);
|
||||
result.put("error", "表达式为空");
|
||||
return result;
|
||||
}
|
||||
Map<String, Object> env;
|
||||
if (variablesJson.isEmpty()) {
|
||||
env = new java.util.HashMap<>();
|
||||
} else {
|
||||
try {
|
||||
env = objectMapper.readValue(variablesJson,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
result.put("result", false);
|
||||
result.put("error", "样例变量不是合法 JSON 对象:" + e.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
try {
|
||||
Object value = com.yunhai.flowcenter.engine.ExpressionEvaluator.evaluate(expression, env);
|
||||
result.put("result", Boolean.TRUE.equals(value));
|
||||
result.put("value", String.valueOf(value));
|
||||
} catch (Exception e) {
|
||||
result.put("result", false);
|
||||
result.put("error", String.valueOf(e.getMessage() != null ? e.getMessage() : e.toString()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 办件查询 ====================
|
||||
|
||||
/**
|
||||
* 管理端办件分页(F11-4 增 hung 筛选与行级红标):hung=true 只看路由挂起办件
|
||||
* (口径见 {@link com.yunhai.flowcenter.repository.FlwInstanceRepository#pageAdmin});
|
||||
* 每行回填 {@code routeHung}(按页一次 {@code findHungInstanceIds} 查询,非逐行 N+1)。
|
||||
*/
|
||||
@GetMapping("/instance/page")
|
||||
public BasePageResult<FlwInstance> instancePage(
|
||||
BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "hung", required = false) Boolean hung,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword,
|
||||
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword, hung,
|
||||
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
|
||||
size == null || size < 1 || size > 100 ? 10 : size,
|
||||
Sort.by(Sort.Order.desc("id"))));
|
||||
List<Long> pageIds = new java.util.ArrayList<>();
|
||||
for (FlwInstance i : p.getContent()) {
|
||||
pageIds.add(i.getId());
|
||||
}
|
||||
// 行级红标:不在挂起集合内的行显式置 false(前端只判 truthy,null 也行,但显式更可读)
|
||||
java.util.Set<Long> hungIds = pageIds.isEmpty() ? java.util.Collections.emptySet()
|
||||
: new java.util.HashSet<>(routeLogRepository.findHungInstanceIds(pageIds));
|
||||
for (FlwInstance i : p.getContent()) {
|
||||
i.setRouteHung(hungIds.contains(i.getId()));
|
||||
}
|
||||
int pi = page == null || page < 1 ? 1 : page;
|
||||
int ps = size == null || size < 1 || size > 100 ? 10 : size;
|
||||
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(), pi, ps,
|
||||
|
||||
@@ -45,33 +45,37 @@ public class FlowUserController {
|
||||
@GetMapping("/my")
|
||||
public BasePageResult<FlowModels.InstanceItem> my(BoaUser user,
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@RequestParam(value = "appKey", required = false) String appKey,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return queryService.pageMy(requireSchId(user), user.getId(), status, page, size);
|
||||
return queryService.pageMy(requireSchId(user), user.getId(), status, appKey, page, size);
|
||||
}
|
||||
|
||||
/** 我的待办(主路径,走 assignee 索引) */
|
||||
@GetMapping("/todo")
|
||||
public BasePageResult<FlowModels.TaskItem> todo(BoaUser user,
|
||||
@RequestParam(value = "appKey", required = false) String appKey,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return queryService.pageTodo(requireSchId(user), user.getId(), page, size);
|
||||
return queryService.pageTodo(requireSchId(user), user.getId(), appKey, page, size);
|
||||
}
|
||||
|
||||
/** 我的已办 */
|
||||
@GetMapping("/done")
|
||||
public BasePageResult<FlowModels.TaskItem> done(BoaUser user,
|
||||
@RequestParam(value = "appKey", required = false) String appKey,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return queryService.pageDone(requireSchId(user), user.getId(), page, size);
|
||||
return queryService.pageDone(requireSchId(user), user.getId(), appKey, page, size);
|
||||
}
|
||||
|
||||
/** 我的抄送(NOTIFY 节点落库;知会不占待办,独立页签) */
|
||||
@GetMapping("/cc/my")
|
||||
public BasePageResult<FlowModels.CcItem> ccMy(BoaUser user,
|
||||
@RequestParam(value = "appKey", required = false) String appKey,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return queryService.pageCc(requireSchId(user), user.getId(), page, size);
|
||||
return queryService.pageCc(requireSchId(user), user.getId(), appKey, page, size);
|
||||
}
|
||||
|
||||
/** 办件详情(提交人/办理人/本校管理员可见) */
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.yunhai.flowcenter.controller.oapi;
|
||||
|
||||
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.service.FlowQueryService;
|
||||
import com.yunhai.flowcenter.service.FlowRouterAdminService;
|
||||
import com.yunhai.flowcenter.service.OapiFlowService;
|
||||
import com.yunhai.platform.utils.model.BasePageResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -27,10 +31,14 @@ import java.util.List;
|
||||
* - GET /oapi/flow/diagram/url?instanceId=|flowGuid=&mode=&schId= → data={imgUrl,expiresIn}
|
||||
* - GET /oapi/flow/embed/url?instanceId=|bizType=&bizId=&mode=&schId= → data={pageUrl,expiresIn}
|
||||
* - POST /oapi/flow/callback/reg?schId= → data={secret}
|
||||
* - POST /oapi/flow/router/reg → data=null(判断服务自注册,归属=appKey)
|
||||
* 应用内审批(2026-09 第三步,原"刻意不提供审批动作"立场由该方案修订:登录身份由调用方
|
||||
* 应用按其 CAS 会话保证并以 operatorId 传入,本端按任务办理人逐字复核,审计链不断):
|
||||
* - GET /oapi/flow/tasks/todo?schId=&assigneeId=&bizType=&page=&size= → data=分页待办
|
||||
* - GET /oapi/flow/tasks/by-biz?schId=&bizType=&bizId=&operatorId= → data=当前人在该单据的待办(0/1/N)
|
||||
* - GET /oapi/flow/instances?schId=&bizType=&status=&page=&size= → data=分页办件
|
||||
* - GET /oapi/flow/tasks/done?schId=&assigneeId=&page=&size= → data=分页已办(过滤=签名 appKey→d.appKey,P3.5)
|
||||
* - GET /oapi/flow/cc?schId=&userId=&page=&size= → data=分页抄送我(过滤=签名 appKey→d.appKey,P3.5)
|
||||
* - POST /oapi/flow/task/{taskId}/approve?schId=&operatorId=&operatorName=(body: opinion/edgeName)
|
||||
* - POST /oapi/flow/task/{taskId}/reject?schId=&operatorId=&operatorName=(body: opinion 必填)
|
||||
*/
|
||||
@@ -40,6 +48,8 @@ import java.util.List;
|
||||
public class OapiFlowController {
|
||||
|
||||
private final OapiFlowService flowService;
|
||||
private final FlowRouterAdminService routerAdminService;
|
||||
private final FlowQueryService queryService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public List<OapiModels.FlowSummaryVo> list(@RequestParam("schId") Long schId) {
|
||||
@@ -54,8 +64,33 @@ public class OapiFlowController {
|
||||
|
||||
@PostMapping("/apply")
|
||||
public OapiModels.ApplyResultVo apply(@RequestParam("schId") Long schId,
|
||||
@RequestAttribute(value = "yh.app.auth", required = false) String appKey,
|
||||
@RequestBody OapiModels.ApplyBody body) {
|
||||
return flowService.apply(schId, body);
|
||||
return flowService.apply(schId, appKey, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带请求标识/轮次守卫的代发起(v2 §8,TASK_2 C.3)。
|
||||
*
|
||||
* <p>与 {@code /apply} 是<b>两条显式分支</b>而不是一个端点加 flag:旧端点行为逐字不变
|
||||
* (同 bizKey 命中 30/40 仍按 R4 重启推进),新端点声明"我带幂等守卫"——
|
||||
* 同 {@code requestToken} 重投只回读现状,{@code retryOnly} 命中终态也不重启,
|
||||
* 陈旧轮次拒绝重启。响应多带一个稳定分支码 {@code branch},消费方按码处置。
|
||||
* 请求标识与轮次由消费方的持久化出站命令携带(svc-hall {@code flow_apply_command})。</p>
|
||||
*/
|
||||
@PostMapping("/apply/v2")
|
||||
public FlowApplyRetryModels.ApplyResultV2 applyV2(@RequestParam("schId") Long schId,
|
||||
@RequestAttribute(value = "yh.app.auth",
|
||||
required = false) String appKey,
|
||||
@RequestBody FlowApplyRetryModels.ApplyRequestV2 body) {
|
||||
return flowService.applyV2(schId, appKey, body);
|
||||
}
|
||||
|
||||
/** 发起预览:返回开启发起时指派的节点与候选人(已剔除发起人本人),供子应用提交页选人 */
|
||||
@GetMapping("/apply/preview")
|
||||
public OapiModels.ApplyPreviewVo applyPreview(@RequestParam("schId") Long schId,
|
||||
@RequestParam("flowGuid") String flowGuid, @RequestParam("submitterId") Integer submitterId) {
|
||||
return flowService.applyPreview(schId, flowGuid, submitterId);
|
||||
}
|
||||
|
||||
@GetMapping("/instance")
|
||||
@@ -100,8 +135,9 @@ public class OapiFlowController {
|
||||
@RequestParam(value = "bizType", required = false) String bizType,
|
||||
@RequestParam(value = "bizId", required = false) String bizId,
|
||||
@RequestParam(value = "mode", required = false) String mode,
|
||||
@RequestParam(value = "taskId", required = false) Long taskId,
|
||||
@RequestParam("schId") Long schId) {
|
||||
return flowService.embedUrl(schId, instanceId, bizType, bizId, mode);
|
||||
return flowService.embedUrl(schId, instanceId, bizType, bizId, mode, taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,6 +159,16 @@ public class OapiFlowController {
|
||||
return flowService.registerCallback(schId, appKey, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断服务登记(2026-10-12 SERVICE 收口):由 {@code yh-flow-callback-spring-boot-starter} 启动自注册调用。
|
||||
* 无 schId——{@code flw_router} 是全局表;归属即验签后的 appKey(登记即持有,跨应用覆盖拒绝)。
|
||||
*/
|
||||
@PostMapping("/router/reg")
|
||||
public void registerRouter(@RequestAttribute(value = "yh.app.auth", required = false) String appKey,
|
||||
@RequestBody OapiModels.RouterRegBody body) {
|
||||
routerAdminService.registerByApp(appKey, body);
|
||||
}
|
||||
|
||||
// ===== 应用内审批(第三步):查询 + 办理 =====
|
||||
|
||||
/** 我的待办(oapi):assigneeId 维度分页,bizType 可选过滤 */
|
||||
@@ -136,6 +182,49 @@ public class OapiFlowController {
|
||||
return flowService.pageTodoTasks(schId, assigneeId, bizType, page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按业务单据查当前人待办(v2.1 业务详情卡片数据源):与 tasks/todo 互补——那个是"人→单据",
|
||||
* 这个是"单据→我的待办"。0/1/N 三种计数都由 HTTP 200 + tasks 数组表达,办件不存在与
|
||||
* 他人待办同样出空(不用于探测)。operatorId 由子应用后端从 CAS 会话取,前端不可传。
|
||||
*/
|
||||
@GetMapping("/tasks/by-biz")
|
||||
public OapiModels.ByBizTasksVo tasksByBiz(
|
||||
@RequestParam("schId") Long schId,
|
||||
@RequestParam("bizType") String bizType,
|
||||
@RequestParam("bizId") String bizId,
|
||||
@RequestParam("operatorId") Integer operatorId) {
|
||||
return flowService.tasksByBiz(schId, bizType, bizId, operatorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的已办(P3.5 应用内审批四页签):过滤接缝=<b>签名方应用身份</b>(d.appKey=验签 appKey),
|
||||
* 不收调用方 bizType 裸透传——办件挂本应用定义即含(天然覆盖新旧 bizType 存量),共享定义
|
||||
* 跨应用不泄漏(P2-02 裁决)。assigneeId 由子应用后端从 CAS 会话取。
|
||||
*/
|
||||
@GetMapping("/tasks/done")
|
||||
public BasePageResult<FlowModels.TaskItem> tasksDone(
|
||||
@RequestParam("schId") Long schId,
|
||||
@RequestParam("assigneeId") Integer assigneeId,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size,
|
||||
@RequestAttribute(value = "yh.app.auth", required = false) String appKey) {
|
||||
return queryService.pageDone(schId, assigneeId, appKey, page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 抄送我的(P3.5,NOTIFY 节点落库行,sent_time 倒序):过滤接缝同 tasks/done
|
||||
* (d.appKey=签名 appKey,不收 bizType)。userId 由子应用后端从 CAS 会话取。
|
||||
*/
|
||||
@GetMapping("/cc")
|
||||
public BasePageResult<FlowModels.CcItem> cc(
|
||||
@RequestParam("schId") Long schId,
|
||||
@RequestParam("userId") Integer userId,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size,
|
||||
@RequestAttribute(value = "yh.app.auth", required = false) String appKey) {
|
||||
return queryService.pageCc(schId, userId, appKey, page, size);
|
||||
}
|
||||
|
||||
/** 办件分页(oapi):bizType/status 可选过滤 */
|
||||
@GetMapping("/instances")
|
||||
public BasePageResult<OapiModels.InstanceItemVo> instances(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
/**
|
||||
* 发起时指派快照解析(2026-10-13 方案):assign_json 列文本 → nodeId → 指派人员快照。
|
||||
* 脏 JSON 只 log warn 返回空 Map(宽松回退口径,见方案 D3/I5)——引擎永不因缺指派拒绝或挂起。
|
||||
*/
|
||||
public final class AssignDesignations {
|
||||
private static final org.slf4j.Logger log =
|
||||
org.slf4j.LoggerFactory.getLogger(AssignDesignations.class);
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
private AssignDesignations() {}
|
||||
|
||||
/** @return nodeId → 指派人员快照;null/空串/脏 JSON → 空 Map */
|
||||
public static java.util.Map<String, java.util.List<AssigneeRef>> parse(String assignJson) {
|
||||
if (assignJson == null || assignJson.trim().isEmpty()) {
|
||||
return java.util.Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
java.util.Map<String, java.util.List<AssigneeRef>> result = MAPPER.readValue(assignJson,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, java.util.List<AssigneeRef>>>() {});
|
||||
return result == null ? java.util.Collections.emptyMap() : result;
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] assign_json 解析失败,按无指派回退全量候选:{}", e.getMessage());
|
||||
return java.util.Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import lombok.Value;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 办理人解析结果(id+姓名快照),任务创建与通知共用。
|
||||
*/
|
||||
@Value
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AssigneeRef {
|
||||
Integer userId;
|
||||
String userName;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.entity.FlwDeptMember;
|
||||
import com.yunhai.flowcenter.service.FlowDeptAdminService;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
@@ -14,15 +14,15 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 办理人解析(概览 §六):DEPT_KEY 查显式映射表(零跨服务依赖);USERS 定义直配,
|
||||
* 姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。SUBMITTER_DEPT/FORM_FIELD 二期。
|
||||
* 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源,
|
||||
* 零跨服务依赖);USERS 定义直配,姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AssigneeResolver {
|
||||
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final FlowDeptAdminService deptAdminService;
|
||||
private final OpenApiUserClient userClient;
|
||||
|
||||
public List<AssigneeRef> resolve(Long schId, FlowDefModel.Node node) {
|
||||
@@ -32,8 +32,7 @@ public class AssigneeResolver {
|
||||
}
|
||||
List<AssigneeRef> refs = new ArrayList<>();
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
List<FlwAssigneeMap> maps = mapRepository.findEnabled(schId, a.deptKey);
|
||||
for (FlwAssigneeMap m : maps) {
|
||||
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
|
||||
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||||
@@ -47,14 +46,14 @@ public class AssigneeResolver {
|
||||
}
|
||||
if (refs.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"节点[" + node.id + "]无可用办理人(映射可能已被停用)");
|
||||
"节点[" + node.id + "]无可用办理人(科室可能已被停用或成员为空)");
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义),
|
||||
* 但解析不出人(映射被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
|
||||
* 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
|
||||
* 绝不能因抄送对象缺失卡住流程推进。与 {@link #resolve} 的强口径相反。
|
||||
*/
|
||||
public List<AssigneeRef> resolveTolerant(Long schId, FlowDefModel.Node node) {
|
||||
@@ -64,7 +63,7 @@ public class AssigneeResolver {
|
||||
}
|
||||
List<AssigneeRef> refs = new ArrayList<>();
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
for (FlwAssigneeMap m : mapRepository.findEnabled(schId, a.deptKey)) {
|
||||
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
|
||||
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||||
|
||||
@@ -42,6 +42,9 @@ public class ConditionRouteService {
|
||||
*/
|
||||
public String decide(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node) {
|
||||
List<String> outlets = outletNames(model, node);
|
||||
if (node.expressionJudge()) {
|
||||
return decideByExpression(instance, model, node, outlets);
|
||||
}
|
||||
FlwRouter router = routerRepository.findByRouterKey(node.routerKey).orElse(null);
|
||||
if (router == null || router.getStatus() == null || router.getStatus() != 1) {
|
||||
log.error("[flow-center] 路由判断失败:routerKey={} 未注册或已停用(挂起):instance={} node={}",
|
||||
@@ -50,7 +53,8 @@ public class ConditionRouteService {
|
||||
}
|
||||
RouteJudgeClient.JudgeBody body = new RouteJudgeClient.JudgeBody(
|
||||
instance.getSchId(), instance.getId(), instance.getVersion(),
|
||||
instance.getBizType(), instance.getBizId(), node.id, node.name,
|
||||
instance.getBizType(), instance.getBizId(), instance.getSubmitterId(),
|
||||
node.id, node.name,
|
||||
node.routerKey, outlets, parseVariables(instance.getFormDataJson()));
|
||||
try {
|
||||
RouteJudgeClient.JudgeResult result = judgeClient.call(router, body);
|
||||
@@ -60,7 +64,7 @@ public class ConditionRouteService {
|
||||
return persistFailure(instance, node, outlets,
|
||||
"返回出口未命中 outlets:" + result.outlet + ";raw=" + result.rawResponse);
|
||||
}
|
||||
persist(instance, node, result.outlet, result.rawResponse);
|
||||
persist(instance, node, node.routerKey, result.outlet, result.rawResponse);
|
||||
log.info("[flow-center] 路由判断命中:instance={} node={} router={} outlet={}",
|
||||
instance.getId(), node.id, node.routerKey, result.outlet);
|
||||
return result.outlet;
|
||||
@@ -71,6 +75,54 @@ public class ConditionRouteService {
|
||||
}
|
||||
}
|
||||
|
||||
/** EXPRESSION 模式留痕的 router_key 快照(真实注册键不适用;列 NOT NULL) */
|
||||
static final String EXPRESSION_ROUTER_KEY = "EXPRESSION";
|
||||
|
||||
/**
|
||||
* EXPRESSION 模式(2026-10-10 四议题):按出边定义顺序对 condition 求值,首个 true 的出口名
|
||||
* 即结果;逐边结果摘要落 raw_response。全 false / 求值异常 → 挂起,沿用 SERVICE 模式纪律
|
||||
* (留痕 outlet=NULL + 告警 + 管理端重跑),不配默认出口。
|
||||
*/
|
||||
private String decideByExpression(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node,
|
||||
List<String> outlets) {
|
||||
Map<String, Object> env = builtinEnv(instance, node, parseVariables(instance.getFormDataJson()));
|
||||
StringBuilder trace = new StringBuilder("EXPRESSION judge: ");
|
||||
try {
|
||||
for (FlowDefModel.Edge e : model.outEdges(node.id)) {
|
||||
Object result = ExpressionEvaluator.evaluate(e.condition, env);
|
||||
trace.append("[").append(e.name).append("] ").append(e.condition)
|
||||
.append(" => ").append(result).append("; ");
|
||||
if (Boolean.TRUE.equals(result)) {
|
||||
int attempt = persist(instance, node, EXPRESSION_ROUTER_KEY, e.name, trace.toString());
|
||||
log.info("[flow-center] 表达式判断命中:instance={} node={} outlet={} attempt={}",
|
||||
instance.getId(), node.id, e.name, attempt);
|
||||
return e.name;
|
||||
}
|
||||
}
|
||||
log.error("[flow-center] 表达式全部未命中(挂起):instance={} node={} outlets={} trace={}",
|
||||
instance.getId(), node.id, outlets, trace);
|
||||
return persistFailure(instance, node, outlets, "表达式全部未命中:" + trace);
|
||||
} catch (Exception e) {
|
||||
log.error("[flow-center] 表达式求值异常(挂起):instance={} node={} {}",
|
||||
instance.getId(), node.id, e.toString());
|
||||
return persistFailure(instance, node, outlets, "表达式求值异常:" + e + ";" + trace);
|
||||
}
|
||||
}
|
||||
|
||||
/** 内置变量注入(EXPRESSION 求值 env;SERVICE 模式的 JudgeBody 同名字段一一对应) */
|
||||
static Map<String, Object> builtinEnv(FlwInstance instance, FlowDefModel.Node node,
|
||||
Map<String, Object> variables) {
|
||||
variables.put("schId", instance.getSchId());
|
||||
variables.put("instanceId", instance.getId());
|
||||
variables.put("instanceVersion", instance.getVersion());
|
||||
variables.put("bizType", instance.getBizType());
|
||||
variables.put("bizId", instance.getBizId());
|
||||
variables.put("nodeId", node.id);
|
||||
variables.put("nodeName", node.name);
|
||||
variables.put("submitterId", instance.getSubmitterId());
|
||||
return variables;
|
||||
}
|
||||
|
||||
/** CONDITION 出边名即判断契约的 outlets(发布期已保证非空唯一) */
|
||||
private static List<String> outletNames(FlowDefModel model, FlowDefModel.Node node) {
|
||||
List<String> names = new ArrayList<>();
|
||||
@@ -97,19 +149,21 @@ public class ConditionRouteService {
|
||||
|
||||
private String persistFailure(FlwInstance instance, FlowDefModel.Node node, List<String> outlets,
|
||||
String error) {
|
||||
int attempt = persist(instance, node, null, truncate(error));
|
||||
int attempt = persist(instance, node, node.expressionJudge() ? EXPRESSION_ROUTER_KEY : node.routerKey,
|
||||
null, truncate(error));
|
||||
alertSuspended(instance, node, outlets, error, attempt);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 落一行留痕(REQUIRES_NEW:外部调用已真实发生,不随外层回滚),返回本轮 attempt(告警 msgNo 用) */
|
||||
private int persist(FlwInstance instance, FlowDefModel.Node node, String outlet, String raw) {
|
||||
private int persist(FlwInstance instance, FlowDefModel.Node node, String routerKeyLabel,
|
||||
String outlet, String raw) {
|
||||
FlwRouteLog logRow = new FlwRouteLog();
|
||||
logRow.setSchId(instance.getSchId());
|
||||
logRow.setInstanceId(instance.getId());
|
||||
logRow.setNodeId(node.id);
|
||||
logRow.setNodeName(node.name);
|
||||
logRow.setRouterKey(node.routerKey);
|
||||
logRow.setRouterKey(routerKeyLabel);
|
||||
logRow.setOutlet(outlet);
|
||||
logRow.setRawResponse(raw);
|
||||
return routeLogStore.append(logRow).getAttempt();
|
||||
|
||||
@@ -66,7 +66,7 @@ public final class DefValidator {
|
||||
validateNotify(n, enabledDeptKeys, problems);
|
||||
break;
|
||||
case FlowDefModel.TYPE_CONDITION:
|
||||
validateCondition(n, enabledRouterKeys, problems);
|
||||
validateCondition(model, n, enabledRouterKeys, problems);
|
||||
break;
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT:
|
||||
case FlowDefModel.TYPE_PARALLEL_JOIN:
|
||||
@@ -243,12 +243,37 @@ public final class DefValidator {
|
||||
}
|
||||
|
||||
/**
|
||||
* CONDITION 条件分支节点校验(2026-09-24 §4):routerKey 必填且已注册启用——运行时才发现
|
||||
* 无效只会挂起,发布期就拦;出边=互斥分支,出口名即判断接口的 outlets 契约(命名/唯一/
|
||||
* 同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
|
||||
* CONDITION 条件分支节点校验(2026-09-24 §4):SERVICE 模式 routerKey 必填且已注册启用——
|
||||
* 运行时才发现无效只会挂起,发布期就拦;出边=互斥分支,出口名即判断接口的 outlets 契约
|
||||
* (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
|
||||
*
|
||||
* <p>EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦
|
||||
* (表单变量是子应用运行时传的,发布期无从核对 schema,一律放行、缺失按 null 注入求值)。
|
||||
*/
|
||||
private static void validateCondition(FlowDefModel.Node n, Set<String> enabledRouterKeys,
|
||||
private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set<String> enabledRouterKeys,
|
||||
List<String> problems) {
|
||||
if (n.judgeMode != null && !FlowDefModel.JUDGE_MODE_SERVICE.equals(n.judgeMode)
|
||||
&& !FlowDefModel.JUDGE_MODE_EXPRESSION.equals(n.judgeMode)) {
|
||||
problems.add(nodeLabel(n) + " 判断方式取值非法:" + n.judgeMode + "(支持 SERVICE/EXPRESSION)");
|
||||
return;
|
||||
}
|
||||
if (n.expressionJudge()) {
|
||||
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
|
||||
if (e.condition == null || e.condition.trim().isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " 的出口「" + e.name
|
||||
+ "」缺少条件表达式(EXPRESSION 模式每个出口必须配置)");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
warnBuiltinMisspelling(n, e);
|
||||
ExpressionEvaluator.compile(e.condition.trim());
|
||||
} catch (Exception ex) {
|
||||
problems.add(nodeLabel(n) + " 的出口「" + e.name + "」表达式不合法:"
|
||||
+ ex.getMessage());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (n.routerKey == null || n.routerKey.trim().isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " 缺少判断服务(routerKey)配置");
|
||||
} else if (enabledRouterKeys != null && !enabledRouterKeys.contains(n.routerKey.trim())) {
|
||||
@@ -257,6 +282,38 @@ public final class DefValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达式内置变量白名单(ConditionRouteService.builtinEnv 注入的同名集)。<br>
|
||||
* 2026-10-20 C1 起 {@link com.yunhai.flowcenter.service.FlowDefAdminService} 的键查①红牌
|
||||
* 复用本集合(表达式变量 ⊆ 快照字段 ∪ 内置变量)——两处必须是同一份,内置集扩了这边自动跟上。
|
||||
*/
|
||||
public static final Set<String> EXPRESSION_BUILTINS = new HashSet<>(java.util.Arrays.asList(
|
||||
"schId", "instanceId", "instanceVersion", "bizType", "bizId",
|
||||
"nodeId", "nodeName", "submitterId"));
|
||||
|
||||
/**
|
||||
* 内置变量疑似拼错提示(warn 不硬拦):变量名与某内置变量仅大小写不一致(如 schid)时
|
||||
* 提示一次——拼错的表达式静默把 null 与数字比较,全部不命中挂起,很难排查。
|
||||
*/
|
||||
private static void warnBuiltinMisspelling(FlowDefModel.Node n, FlowDefModel.Edge e) {
|
||||
try {
|
||||
for (String v : ExpressionEvaluator.variableNames(e.condition.trim())) {
|
||||
if (!EXPRESSION_BUILTINS.contains(v)) {
|
||||
for (String b : EXPRESSION_BUILTINS) {
|
||||
if (b.equalsIgnoreCase(v)) {
|
||||
LOG.warn("[flow-center] 节点 {} 出口「{}」表达式变量 {} 疑似内置变量 {} 拼写错误",
|
||||
n.id, e.name, v, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// 语法错时拿不到变量名,交给 compile 路径报问题
|
||||
}
|
||||
}
|
||||
|
||||
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 配置");
|
||||
@@ -282,6 +339,24 @@ public final class DefValidator {
|
||||
if (n.deadlineHours != null && n.deadlineHours <= 0) {
|
||||
problems.add(nodeLabel(n) + " deadlineHours 必须为正整数");
|
||||
}
|
||||
// 发起时指派策略(2026-10-13):存在即开启;发布期拦明显配错(mode 非法/SINGLE 带非 1 边界/边界倒挂)
|
||||
if (n.assignPolicy != null) {
|
||||
FlowDefModel.AssignPolicy p = n.assignPolicy;
|
||||
if (p.mode != null && !FlowDefModel.ASSIGN_MODE_SINGLE.equals(p.mode)
|
||||
&& !FlowDefModel.ASSIGN_MODE_MULTI.equals(p.mode)) {
|
||||
problems.add(nodeLabel(n) + " assignPolicy.mode 取值非法:" + p.mode + "(支持 SINGLE/MULTI)");
|
||||
}
|
||||
if (FlowDefModel.ASSIGN_MODE_SINGLE.equals(p.mode)
|
||||
&& ((p.min != null && p.min != 1) || (p.max != null && p.max != 1))) {
|
||||
problems.add(nodeLabel(n) + " SINGLE 指派的 min/max 必须缺省或为 1(恰选 1 人)");
|
||||
}
|
||||
if (p.min != null && p.min < 1) {
|
||||
problems.add(nodeLabel(n) + " assignPolicy.min 必须 ≥1");
|
||||
}
|
||||
if (p.min != null && p.max != null && p.min > p.max) {
|
||||
problems.add(nodeLabel(n) + " assignPolicy.min 不得大于 max");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void dfs(FlowDefModel model, String current, Set<String> visited,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.googlecode.aviator.AviatorEvaluator;
|
||||
import com.googlecode.aviator.AviatorEvaluatorInstance;
|
||||
import com.googlecode.aviator.Expression;
|
||||
import com.googlecode.aviator.Feature;
|
||||
import com.googlecode.aviator.Options;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 条件路由表达式求值核心(2026-10-10 四议题 Phase 4):CONDITION 节点 judgeMode=EXPRESSION 时,
|
||||
* 出边 condition 即 AviatorScript 表达式,按出边定义顺序取首个求值为 true 的出口。
|
||||
* 发布期校验(DefValidator)、运行时(ConditionRouteService)、调试端点(/admin/router/eval)
|
||||
* 共用本类——三处口径天然一致。
|
||||
*
|
||||
* <p><b>沙箱纪律(勿放宽)</b>:FEATURE_SET 只留 If(三元/条件),NewInstance/Module/Use/
|
||||
* Static* 全部不在集合内即禁用;ALLOWED_CLASS_SET=空不可变集 = 禁一切 Java 类访问(反射/静态
|
||||
* 方法/类字面量);表达式只能引用变量与 Aviator 内置纯函数(math/string/seq)。
|
||||
*
|
||||
* <p><b>缺失变量口径(宁挂起不误放行)</b>:顶层变量缺失(拼写错/表单未传)直接抛错由调用方
|
||||
* 挂起——Aviator 的 nil 参与大小比较时视作最小值,{@code x <= n} 这类兜底出口会对缺失字段
|
||||
* <i>静默放行</i>,必须拦住。判空请显式写 {@code x == nil}(嵌套属性缺失走 nil 语义,
|
||||
* {@code form.amount == nil} 可用)。env 中显式为 null 的值仍按 nil 语义求值。
|
||||
*
|
||||
* <p>线程安全:{@link AviatorEvaluatorInstance} 本身线程安全,编译结果带缓存(key=表达式串)。
|
||||
*/
|
||||
public final class ExpressionEvaluator {
|
||||
|
||||
private ExpressionEvaluator() {
|
||||
}
|
||||
|
||||
private static final AviatorEvaluatorInstance INSTANCE = createSandboxed();
|
||||
|
||||
private static AviatorEvaluatorInstance createSandboxed() {
|
||||
AviatorEvaluatorInstance instance = AviatorEvaluator.newInstance();
|
||||
instance.setOption(Options.FEATURE_SET, Feature.asSet(Feature.If));
|
||||
instance.setOption(Options.ALLOWED_CLASS_SET, Collections.emptySet());
|
||||
instance.setOption(Options.NIL_WHEN_PROPERTY_NOT_FOUND, Boolean.TRUE);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 求值(编译缓存复用)。顶层变量缺失直接抛错(调用方挂起)——Aviator 对缺失顶层变量一律
|
||||
* 按 nil 求值且不可配置,而 nil 视作最小值会让 {@code x <= n} 这类兜底出口静默放行。
|
||||
* 嵌套属性(form.amount)缺失走 nil 语义,判空请显式 {@code form.amount == nil}。
|
||||
*
|
||||
* @return 求值结果;调用方仅把 Boolean.TRUE 视为命中(null/非布尔一律不命中——宁挂起不误放行)
|
||||
*/
|
||||
public static Object evaluate(String expression, Map<String, Object> env) {
|
||||
Expression compiled = compile(expression);
|
||||
for (String name : compiled.getVariableNames()) {
|
||||
if (!env.containsKey(name)) {
|
||||
throw new IllegalStateException("表达式变量未提供:" + name
|
||||
+ "(拼写错误或表单未传;宁挂起不误放行,不按 nil 求值)");
|
||||
}
|
||||
}
|
||||
return compiled.execute(env);
|
||||
}
|
||||
|
||||
/** 语法校验/预编译(发布期拦语法错;与运行时走同一编译路径) */
|
||||
public static Expression compile(String expression) {
|
||||
return INSTANCE.compile(expression, true);
|
||||
}
|
||||
|
||||
/** 表达式引用的顶层变量名(发布期"内置变量疑似拼错"提示用) */
|
||||
public static List<String> variableNames(String expression) {
|
||||
return compile(expression).getVariableNames();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ public class FlowDefModel {
|
||||
*/
|
||||
public static final String TYPE_CONDITION = "CONDITION";
|
||||
|
||||
/** CONDITION 判断方式(2026-10-10 四议题):外部判断服务(缺省=旧口径,routerKey 寻址) */
|
||||
public static final String JUDGE_MODE_SERVICE = "SERVICE";
|
||||
/** CONDITION 判断方式:内置表达式引擎(出边 condition 即 AviatorScript 表达式) */
|
||||
public static final String JUDGE_MODE_EXPRESSION = "EXPRESSION";
|
||||
|
||||
/** 会签模式 */
|
||||
public static final String COUNTERSIGN_ALL = "ALL";
|
||||
public static final String COUNTERSIGN_ANY = "ANY";
|
||||
@@ -41,6 +46,10 @@ public class FlowDefModel {
|
||||
public static final String ASSIGNEE_DEPT_KEY = "DEPT_KEY";
|
||||
public static final String ASSIGNEE_USERS = "USERS";
|
||||
|
||||
/** 发起时指派选择模式:SINGLE=恰 1 人;MULTI=多选(min~max) */
|
||||
public static final String ASSIGN_MODE_SINGLE = "SINGLE";
|
||||
public static final String ASSIGN_MODE_MULTI = "MULTI";
|
||||
|
||||
public String code;
|
||||
public String name;
|
||||
public String start;
|
||||
@@ -63,12 +72,21 @@ public class FlowDefModel {
|
||||
public Assignee receivers;
|
||||
/** CONDITION 判断服务注册键(flw_router.router_key,发布期校验须已注册且启用) */
|
||||
public String routerKey;
|
||||
/** CONDITION 判断方式:SERVICE(缺省,routerKey 外部服务)|EXPRESSION(出边 condition 表达式) */
|
||||
public String judgeMode;
|
||||
|
||||
/** 是否表达式判断(null 视作 SERVICE,旧 contentJson 无此字段天然走服务口径) */
|
||||
public boolean expressionJudge() {
|
||||
return JUDGE_MODE_EXPRESSION.equals(judgeMode);
|
||||
}
|
||||
/** ALL(默认)|ANY */
|
||||
public String countersign;
|
||||
/** PARALLEL_JOIN 汇聚条件,一期仅 ALL */
|
||||
public String waitFor;
|
||||
/** 办理时限小时数(自然时间,任务 arrive_time + N 小时;可空) */
|
||||
public Integer deadlineHours;
|
||||
/** 发起时指派策略(2026-10-13 方案):存在即允许指派;缺省/缺失=不允许(向前兼容) */
|
||||
public AssignPolicy assignPolicy;
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -79,6 +97,16 @@ public class FlowDefModel {
|
||||
public List<Integer> userIds;
|
||||
}
|
||||
|
||||
/** 发起时指派策略:mode 必填(容错 null=MULTI);min/max 可空(缺省 1/候选全量数);required 仅子应用 UI 用 */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class AssignPolicy {
|
||||
/** SINGLE | MULTI */
|
||||
public String mode;
|
||||
public Integer min;
|
||||
public Integer max;
|
||||
public Boolean required;
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Edge {
|
||||
public String from;
|
||||
|
||||
@@ -209,10 +209,14 @@ public class FlowEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件。operator=null 回退发起人 */
|
||||
/** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件。operator=null 回退发起人。
|
||||
* 命中发起时指派快照则用指派子集替代全量候选(不变量 I1 收窄不改变 countersign 放行语义、I4 信任快照) */
|
||||
private void createTasks(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node,
|
||||
FlowOperator operator) {
|
||||
List<AssigneeRef> assignees = assigneeResolver.resolve(instance.getSchId(), node);
|
||||
List<AssigneeRef> designated = AssignDesignations.parse(instance.getAssignJson()).get(node.id);
|
||||
List<AssigneeRef> assignees = (designated != null && !designated.isEmpty())
|
||||
? designated
|
||||
: assigneeResolver.resolve(instance.getSchId(), node);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime deadline = node.deadlineHours == null ? null : now.plusHours(node.deadlineHours);
|
||||
FlowOperator op = operator != null ? operator
|
||||
|
||||
@@ -69,6 +69,15 @@ public class FlowEvent {
|
||||
private final String operatorName;
|
||||
/** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */
|
||||
private final Map<String, Object> data;
|
||||
private Map<String, Object> roundIdentity;
|
||||
|
||||
public void captureRoundIdentity(Integer round, String requestToken) {
|
||||
if (roundIdentity != null) return;
|
||||
Map<String, Object> identity = new HashMap<>();
|
||||
identity.put("applyRound", round);
|
||||
identity.put("requestToken", requestToken);
|
||||
roundIdentity = java.util.Collections.unmodifiableMap(identity);
|
||||
}
|
||||
|
||||
public static FlowEvent of(Type type, Long instanceId) {
|
||||
return new FlowEvent(type, instanceId, null, null, null, new HashMap<>());
|
||||
|
||||
@@ -16,7 +16,8 @@ import java.util.Map;
|
||||
* 身份即本应用 appKey(100019)——签名拦截器由 {@link YschOpenCall} 装配链注入,与回调推送同构。
|
||||
* 寻址:service_key 走服务目录三级解析(targets > services > 枚举兜底);
|
||||
* flw_router.base_path 非空时覆盖目录条目的 context-path(网关形态目录无前缀、实际服务有的场景),
|
||||
* 漏填必 401(规范 §4.2 同款坑)。信封解析复用 SDK({code,msg,data})。
|
||||
* 漏填必 401(规范 §4.2 同款坑)。<b>direct_host 非空的自注册行跳过目录解析直连</b>(2026-10-12 收口)。
|
||||
* 信封解析复用 SDK({code,msg,data})。
|
||||
*
|
||||
* <p>重试纪律:同步 3s×3 次退避重试(500ms/1500ms),业务方义务=幂等纯读轻实现。
|
||||
* 全部失败抛 {@link JudgeUnavailableException}(由调用方落 outlet=NULL 留痕并挂起)。
|
||||
@@ -38,13 +39,14 @@ public class RouteJudgeClient {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** 判断请求体(契约冻结:query 带 schId,业务方按 outlets 返回 {outlet}) */
|
||||
/** 判断请求体(契约冻结:query 带 schId,业务方按 outlets 返回 {outlet};submitterId 2026-10-10 增补,老实现忽略即可) */
|
||||
public static class JudgeBody {
|
||||
public final Long schId;
|
||||
public final Long instanceId;
|
||||
public final Integer instanceVersion;
|
||||
public final String bizType;
|
||||
public final String bizId;
|
||||
public final Integer submitterId;
|
||||
public final String nodeId;
|
||||
public final String nodeName;
|
||||
public final String routerKey;
|
||||
@@ -52,13 +54,14 @@ public class RouteJudgeClient {
|
||||
public final Map<String, Object> variables;
|
||||
|
||||
public JudgeBody(Long schId, Long instanceId, Integer instanceVersion, String bizType, String bizId,
|
||||
String nodeId, String nodeName, String routerKey, List<String> outlets,
|
||||
Map<String, Object> variables) {
|
||||
Integer submitterId, String nodeId, String nodeName, String routerKey,
|
||||
List<String> outlets, Map<String, Object> variables) {
|
||||
this.schId = schId;
|
||||
this.instanceId = instanceId;
|
||||
this.instanceVersion = instanceVersion;
|
||||
this.bizType = bizType;
|
||||
this.bizId = bizId;
|
||||
this.submitterId = submitterId;
|
||||
this.nodeId = nodeId;
|
||||
this.nodeName = nodeName;
|
||||
this.routerKey = routerKey;
|
||||
@@ -119,14 +122,29 @@ public class RouteJudgeClient {
|
||||
throw new JudgeUnavailableException("判断服务调用失败(已重试 " + MAX_ATTEMPTS + " 次)", last);
|
||||
}
|
||||
|
||||
/** 目标解析:目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),判断专用 3s 超时 */
|
||||
/**
|
||||
* 目标解析:
|
||||
* <ul>
|
||||
* <li>{@code direct_host} 非空(2026-10-12 starter 自注册行)→ {@link TargetSpec#fromUrl(String)}
|
||||
* 拆基址直连,<b>跳过服务目录三级解析</b>(service_key/base_path 手工核对坑消失);</li>
|
||||
* <li>否则(存量手工行):目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),行为逐字不变。</li>
|
||||
* </ul>
|
||||
* 两分支同设判断专用 3s 超时。
|
||||
*/
|
||||
private TargetSpec resolveTarget(FlwRouter router) {
|
||||
String directHost = router.getDirectHost();
|
||||
if (directHost != null && !directHost.trim().isEmpty()) {
|
||||
return withJudgeTimeout(TargetSpec.fromUrl(directHost.trim()));
|
||||
}
|
||||
TargetSpec resolved = openCall.resolveTarget(router.getServiceKey());
|
||||
String basePath = resolved.getBasePath();
|
||||
if (router.getBasePath() != null && !router.getBasePath().trim().isEmpty()) {
|
||||
basePath = router.getBasePath().trim();
|
||||
}
|
||||
TargetSpec spec = TargetSpec.of(resolved.getBaseHost(), basePath);
|
||||
return withJudgeTimeout(TargetSpec.of(resolved.getBaseHost(), basePath));
|
||||
}
|
||||
|
||||
private static TargetSpec withJudgeTimeout(TargetSpec spec) {
|
||||
spec.setConnectTimeoutMs((long) TIMEOUT_MS);
|
||||
spec.setReadTimeoutMs((long) TIMEOUT_MS);
|
||||
return spec;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 流程对接应用(2026-10-10 四议题·应用设置):本校"哪些应用可对接流程"的白名单。
|
||||
* 行来源=应用设置页从学校池(UserBase /oapi/app/list-by-school,学校组单点已开通)同步,
|
||||
* 同步即 enabled=1;管理员可停用(停用后 defSave 的归属应用校验拦截新引用)。
|
||||
* app_name 是学校池快照(含学校别名),每次同步静默刷新。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_app",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_app_sch_key", columnNames = {"sch_id", "app_key"}),
|
||||
indexes = @Index(name = "idx_flw_app_sch_enabled", columnList = "sch_id, enabled"))
|
||||
public class FlwApp extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 子应用 appKey(UserBase application_info.app_key 的字符串形态,与 flw_def.app_key 同口径) */
|
||||
@Column(name = "app_key", nullable = false, length = 64)
|
||||
private String appKey;
|
||||
|
||||
/** 应用名快照(学校池返回值,含学校别名;同步时刷新) */
|
||||
@Column(name = "app_name", nullable = false, length = 128)
|
||||
private String appName;
|
||||
|
||||
/** 1=对接流程(白名单内);0=停用(defSave 校验拦截) */
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private Integer enabled = 1;
|
||||
|
||||
/** 展示排序(学校池 orderNumber 快照,可空=0) */
|
||||
@Column(name = "sort_no")
|
||||
private Integer sortNo;
|
||||
}
|
||||
@@ -76,6 +76,10 @@ public class FlwCallbackLog extends BaseEntity {
|
||||
@Column(name = "last_error", length = 1000)
|
||||
private String lastError;
|
||||
|
||||
/** 响应体摘要(探测/probe 落,截 1000;常规推送不落——2xx 即成功不解析响应体) */
|
||||
@Column(name = "resp_body", columnDefinition = "TEXT")
|
||||
private String respBody;
|
||||
|
||||
/** 成功时间 */
|
||||
@Column(name = "push_time")
|
||||
private LocalDateTime pushTime;
|
||||
|
||||
@@ -14,14 +14,14 @@ import java.time.LocalDateTime;
|
||||
/**
|
||||
* 流程定义(概览 §4.1 + V2.0 Part D #2/#3 修订)。
|
||||
* flow_guid 绑定"这条流程线"而非某个版本:升版后业务方绑定关系零改动,新发起自动走
|
||||
* 当前已发布最高版本;item_code/form_id 留可空列(M1 不启用,原生模式/表单二期)。
|
||||
* 当前已发布最高版本;item_code/form_uuid 留可空列(M1 不启用,原生模式/表单二期)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_def",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_sch_code_ver",
|
||||
columnNames = {"sch_id", "code", "version"}),
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_guid_ver",
|
||||
columnNames = {"sch_id", "flow_guid", "version"}),
|
||||
indexes = @Index(name = "idx_flw_def_guid", columnList = "sch_id, flow_guid, status, version"))
|
||||
public class FlwDef extends BaseEntity {
|
||||
|
||||
@@ -33,7 +33,10 @@ public class FlwDef extends BaseEntity {
|
||||
@Column(name = "flow_guid", nullable = false, length = 32)
|
||||
private String flowGuid;
|
||||
|
||||
/** 流程编码(本应用内部管理键) */
|
||||
/**
|
||||
* 流程编码(管理端分类/检索标签,可重名)。2026-10-10 起唯一键改为 (sch_id, flow_guid, version),
|
||||
* 编码不再承担唯一性——多条流程线(如同为请假的产假/丧假)可共用一个编码。
|
||||
*/
|
||||
@Column(name = "code", nullable = false, length = 64)
|
||||
private String code;
|
||||
|
||||
@@ -41,6 +44,10 @@ public class FlwDef extends BaseEntity {
|
||||
@Column(name = "name", nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
/** 归属应用(子应用 appKey,可空=未分配;管理端"按应用筛选"维度,2026-10-10) */
|
||||
@Column(name = "app_key", length = 64)
|
||||
private String appKey;
|
||||
|
||||
/** 版本,发布后不可改,改定义=升版本 */
|
||||
@Column(name = "version", nullable = false)
|
||||
private Integer version;
|
||||
@@ -49,9 +56,23 @@ public class FlwDef extends BaseEntity {
|
||||
@Column(name = "item_code", length = 64)
|
||||
private String itemCode;
|
||||
|
||||
/** 关联表单定义(外部托管模式可无表单,M1 恒 null;二期通用表单预留) */
|
||||
@Column(name = "form_id")
|
||||
private Long formId;
|
||||
/** 关联表单定义 uuid(表单中心对外身份,32 位;M1 恒 null,二期对接时启用) */
|
||||
@Column(name = "form_uuid", length = 32)
|
||||
private String formUuid;
|
||||
|
||||
/**
|
||||
* 关联表单的<b>确切版本</b>(v3/TASK_1):与 {@link #formUuid} 成对,发布期按这组坐标取快照,
|
||||
* 中心返回的坐标与请求不符即阻断发布。
|
||||
*
|
||||
* <p>为什么必须钉版本而不是"发布时取当前发布版":同一条流程线的两次发布可能落到不同的表结构上,
|
||||
* 而表达式校验、办件回显都按嵌入的 formContract 判——版本不钉住,"这条流程当时用的哪张表"
|
||||
* 就成了只能猜的历史。存的是服务器侧核验过的版本号。</p>
|
||||
*
|
||||
* <p>null 只出现在本次改造之前已经关联了表单的存量行上(C1 第一刀没这列):
|
||||
* 那种行发布时仍走"当前发布版"的旧口径,改造后新保存/新关联一律成对写入。</p>
|
||||
*/
|
||||
@Column(name = "form_version")
|
||||
private Integer formVersion;
|
||||
|
||||
/** §5.1 流程定义 JSON */
|
||||
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
|
||||
@@ -64,4 +85,8 @@ public class FlwDef extends BaseEntity {
|
||||
/** 发布时间 */
|
||||
@Column(name = "publish_time")
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
/** 升版本改动说明(议题二③,可跳过;仅新版本行有值,草稿直改不写) */
|
||||
@Column(name = "change_log", length = 500)
|
||||
private String changeLog;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 科室(2026-10-10 四议题·科室管理):"组+成员"模型取代单用户映射行(flw_assignee_map)。
|
||||
* <b>deptKey = 本表自增主键 id 的字符串形态</b>(拍板 D1:本地自增,不做存量迁移——旧映射的
|
||||
* deptKey=UserBase 组 id,切源后解析失败属拍板接受的预期,需在科室管理重建后重新配置流程)。
|
||||
* source_group_id 存 UserBase 组 id 防重复拉取重复建组(uk 对 NULL 不生效,手工建组天然多行)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_dept",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_dept_sch_srcgroup", columnNames = {"sch_id", "source_group_id"}),
|
||||
indexes = @Index(name = "idx_flw_dept_sch_status", columnList = "sch_id, status"))
|
||||
public class FlwDept extends BaseEntity {
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 科室名 */
|
||||
@Column(name = "dept_name", nullable = false, length = 128)
|
||||
private String deptName;
|
||||
|
||||
/** 0=启用 / 1=停用(沿用 flw_assignee_map 的 status 语义;停用科室解析不出办理人) */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 0;
|
||||
|
||||
/**
|
||||
* 来源 UserBase 组 id(一键拉取时落,防重复建组);手工建组为 NULL(uk 对 NULL 不生效,
|
||||
* MySQL 允许多 NULL——手工多科室天然支持)
|
||||
*/
|
||||
@Column(name = "source_group_id")
|
||||
private Integer sourceGroupId;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 科室成员(2026-10-10 四议题·科室管理):user_name 是拉取/添加时快照(与旧映射行同款读模型——
|
||||
* 解析零跨服务调用)。status 预留停用语义(管理面只做物理增删,引擎只认 0)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_dept_member",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_dept_member", columnNames = {"dept_id", "user_id"}),
|
||||
indexes = @Index(name = "idx_flw_dept_member_dept", columnList = "dept_id, status"))
|
||||
public class FlwDeptMember extends BaseEntity {
|
||||
|
||||
/** 所属科室(flw_dept.id) */
|
||||
@Column(name = "dept_id", nullable = false)
|
||||
private Long deptId;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Integer userId;
|
||||
|
||||
/** 姓名快照(拉取/添加时落;解析与轨迹展示用) */
|
||||
@Column(name = "user_name", nullable = false, length = 128)
|
||||
private String userName;
|
||||
|
||||
/** 0=启用 / 1=停用 */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 0;
|
||||
}
|
||||
@@ -51,6 +51,10 @@ public class FlwInstance extends BaseEntity {
|
||||
@Column(name = "form_data_json", columnDefinition = "LONGTEXT")
|
||||
private String formDataJson;
|
||||
|
||||
/** 发起时指派快照:{nodeId:[{userId,userName}]};NULL=无指派(全量候选现状行为) */
|
||||
@Column(name = "assign_json", columnDefinition = "LONGTEXT")
|
||||
private String assignJson;
|
||||
|
||||
/** CMS 事项快照(原生模式,M1 不启用恒 null) */
|
||||
@Column(name = "item_code", length = 64)
|
||||
private String itemCode;
|
||||
@@ -74,6 +78,22 @@ public class FlwInstance extends BaseEntity {
|
||||
@Column(name = "biz_url", length = 500)
|
||||
private String bizUrl;
|
||||
|
||||
/**
|
||||
* 代发起调用方 app_key 快照(验签上下文 yh.app.auth,2026-10-12):验签域身份,
|
||||
* 审计/对账用——与 flw_callback_reg/flw_def 的 app_key 同一口径。
|
||||
*/
|
||||
@Column(name = "apply_app_key", length = 32)
|
||||
private String applyAppKey;
|
||||
|
||||
/**
|
||||
* 代发起调用方 app_code 快照(apply 时按 app_key 查 UserBase /oapi/app/by-key 换得):
|
||||
* 消息 jump_app 的权威来源(路由/跳转/判权域标识,projman app.name 口径如 notice/svc-hall)。
|
||||
* biz_url 页面即调用方页面,验签身份=页面属主由平台结构保证。UserBase 不可达时为空
|
||||
* →消息 jumpApp 空、回落发送方归属(现状行为),不阻断 apply。
|
||||
*/
|
||||
@Column(name = "apply_app_code", length = 32)
|
||||
private String applyAppCode;
|
||||
|
||||
/** 业务摘要 JSON 快照:办理页展示用(只收摘要,不收富文本正文,概览 §十边界) */
|
||||
@Column(name = "biz_snapshot", columnDefinition = "LONGTEXT")
|
||||
private String bizSnapshot;
|
||||
@@ -108,4 +128,32 @@ public class FlwInstance extends BaseEntity {
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Integer version;
|
||||
|
||||
/**
|
||||
* 最近一次成功受理代发起的<b>请求标识</b>(消费方持久化命令里带的 token,v2 §8,TASK_2 C.3)。
|
||||
*
|
||||
* <p>它回答的是"这次 apply 是同一命令的重投,还是新一轮发起"——biz uk 只说得出
|
||||
* "这个业务键已经有办件了",说不出这句。没有它时,消费方在响应丢失后重发同业务键,
|
||||
* 命中 30/40 会被 R4 语义重启推进,等于<b>把网络重试当成用户重提</b>。
|
||||
* 同 token 一律只回读现状、不重启;换 token 才是新一轮。</p>
|
||||
*/
|
||||
@Column(name = "apply_request_token", length = 64)
|
||||
private String applyRequestToken;
|
||||
|
||||
/**
|
||||
* 当前发起轮次(1 起;随新一轮请求标识进位)。
|
||||
*
|
||||
* <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
|
||||
* 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p>
|
||||
*/
|
||||
@Column(name = "apply_round")
|
||||
private Integer applyRound;
|
||||
|
||||
/**
|
||||
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
|
||||
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
|
||||
* 随实体序列化给前端画红标;业务代码不得读写该字段。
|
||||
*/
|
||||
@javax.persistence.Transient
|
||||
private Boolean routeHung;
|
||||
}
|
||||
|
||||
@@ -48,4 +48,18 @@ public class FlwRouter extends BaseEntity {
|
||||
/** 1 启用 / 0 停用(停用后发布校验拦截引用,存量在途办件重跑会失败挂起) */
|
||||
@Column(name = "status", nullable = false)
|
||||
private Integer status = 1;
|
||||
|
||||
/**
|
||||
* 自注册直连基址 {@code scheme://authority[/ctx]}(yh-flow-callback starter 启动登记)。
|
||||
* 非空时 {@code RouteJudgeClient} 跳过服务目录三级解析直连——service_key/base_path 手工核对两坑消失;
|
||||
* 空=存量手工行,继续按目录寻址(老行为零改动)。
|
||||
*/
|
||||
@Column(name = "direct_host", length = 255)
|
||||
private String directHost;
|
||||
|
||||
/**
|
||||
* 登记应用 appKey(自注册归属:upsert 时校验,防跨应用覆盖);管理端手工行为空=无归属。
|
||||
*/
|
||||
@Column(name = "reg_app_key", length = 64)
|
||||
private String regAppKey;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ public final class FlwEnums {
|
||||
public static final String NODE_RESUBMIT = "resubmit";
|
||||
/** 留痕事件行保留节点 id:撤回 */
|
||||
public static final String NODE_WITHDRAW = "withdraw";
|
||||
/** 留痕事件行保留节点 id:管理端按新版定义重跑路由(F5-4,nodeName 载明 v旧→v新) */
|
||||
public static final String NODE_REROUTE_DEF = "reroute-def";
|
||||
|
||||
private final int code;
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.flowcenter.enums;
|
||||
|
||||
import com.yunhai.platform.utils.exception.IErrorCode;
|
||||
|
||||
/** 管理面表单绑定契约:消费方按数值码判断恢复动作,不解析 msg。 */
|
||||
public enum FormBindingErrorCode implements IErrorCode {
|
||||
VERSION_REQUIRED(46101, "关联表单缺少明确版本"),
|
||||
INVALID_COORDINATE(46102, "关联表单坐标或版本不合法"),
|
||||
SCHEMA_UNAVAILABLE(46103, "关联表单 schema 拉取失败,可重试"),
|
||||
SCHEMA_CONTRACT_VIOLATION(46104, "关联表单 schema 坐标核验失败"),
|
||||
RESOURCE_UNAVAILABLE(46105, "关联表单或指定发布版本不存在或不可读"),
|
||||
ACCESS_DENIED(46106, "关联表单访问身份或授权被拒绝");
|
||||
|
||||
private final int code;
|
||||
private final String msg;
|
||||
|
||||
FormBindingErrorCode(int code, String msg) {
|
||||
this.code = code;
|
||||
this.msg = msg;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCode() { return code; }
|
||||
|
||||
@Override
|
||||
public String getMsg() { return msg; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.yunhai.flowcenter.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 对接健康度体检结果(2026-10-15 议题二 P2):
|
||||
* 每应用一行六项检查,数据全部来自已有资产(flw_callback_reg / flw_callback_log / probe),零新增表。
|
||||
*
|
||||
* <p>检查项固定六条(顺序即前端展示顺序),但用 List 承载以便未来增删:
|
||||
* <ol>
|
||||
* <li>登记地址形态(是否网关形态,非私网/开发机)</li>
|
||||
* <li>base_path 与应用 ctx 一致性</li>
|
||||
* <li>最近探测结果(最近的 flow.ping 日志)</li>
|
||||
* <li>近 7 日推送成功率</li>
|
||||
* <li>挂起 / 死信数</li>
|
||||
* <li>有 flowGuid 无登记行(漏注册)</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Data
|
||||
public class AppHealthVo {
|
||||
|
||||
/** 应用标识 */
|
||||
private String appKey;
|
||||
|
||||
private String appName;
|
||||
|
||||
/** 综合评分 0~100(六项加权;无数据项中性处理,不扣分) */
|
||||
private int score;
|
||||
|
||||
/** 总体结论:ok / warn / bad */
|
||||
private String level;
|
||||
|
||||
/** 一句话结论(前端顶部提示条用) */
|
||||
private String summary;
|
||||
|
||||
private List<CheckItem> checks;
|
||||
|
||||
/** 单条检查项 */
|
||||
@Data
|
||||
public static class CheckItem {
|
||||
|
||||
/** 检查项名称 */
|
||||
private String name;
|
||||
|
||||
/** ok / warn / bad */
|
||||
private String level;
|
||||
|
||||
/** 实测值展示文本(等宽字体渲染) */
|
||||
private String value;
|
||||
|
||||
/** 可选:修复建议(level != ok 时给出) */
|
||||
private String advice;
|
||||
|
||||
public CheckItem(String name, String level, String value) {
|
||||
this.name = name;
|
||||
this.level = level;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public CheckItem(String name, String level, String value, String advice) {
|
||||
this.name = name;
|
||||
this.level = level;
|
||||
this.value = value;
|
||||
this.advice = advice;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.yunhai.flowcenter.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 代发起的请求标识与轮次守卫(v2 §8,TASK_2 C.3)。
|
||||
*
|
||||
* <p><b>为什么是独立 DTO 而不是往 {@code OapiModels.ApplyBody} 加字段</b>(任务书原文要求):
|
||||
* 旧 ApplyBody 是多个消费应用共用的线上契约,往里塞"重投标识/轮次"会让所有消费方
|
||||
* 都要重新理解一遍字段语义;更重要的是,请求标识的分支行为<b>只对新端点成立</b>——
|
||||
* 旧端点保持逐字不变,新端点显式声明"我带守卫"。两侧分支写清楚,比共用一个 DTO 加 if 诚实。</p>
|
||||
*
|
||||
* <p>继承旧 body 是为了让 {@code /oapi/flow/apply/v2} 的接收形状与旧端点完全一致
|
||||
* (消费方只多带三个字段),不是为了共享可变字段。</p>
|
||||
*/
|
||||
public final class FlowApplyRetryModels {
|
||||
|
||||
private FlowApplyRetryModels() {
|
||||
}
|
||||
|
||||
/** v2 请求体:旧 apply 全字段 + 请求标识/轮次/仅补发开关 */
|
||||
@Getter
|
||||
@Setter
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class ApplyRequestV2 extends OapiModels.ApplyBody {
|
||||
/**
|
||||
* 本次命令的请求标识(消费方持久化后携带,重投同值)。
|
||||
* 与行上已落的标识相同 ⇒ 同一命令的重投 ⇒ <b>只回读现状,绝不重启推进</b>。
|
||||
*/
|
||||
private String requestToken;
|
||||
/** 发起轮次(1 起,单调);低于行上 applyRound 的是陈旧轮次迟到重投 */
|
||||
private Integer attempt;
|
||||
/**
|
||||
* true=只补发"确实还没有办件"的情况:命中既有实例一律回读现状,
|
||||
* 即使处于 30/40 也<b>不</b>触发 R4 重启。恢复链在"查无才补发"之分支上用它,
|
||||
* 这样"网络重投"与"用户授权重提"在中心侧也是两个不同的事实。
|
||||
*/
|
||||
private Boolean retryOnly;
|
||||
}
|
||||
|
||||
/** v2 响应:在旧 {instanceId,duplicated} 之上把"中心判出来的是哪一种命中"说清楚 */
|
||||
@Data
|
||||
public static class ApplyResultV2 {
|
||||
private Long instanceId;
|
||||
private boolean duplicated;
|
||||
/**
|
||||
* 稳定分支码(消费方按码处置,不靠文案):
|
||||
* <ul>
|
||||
* <li>{@code CREATED}:新建办件并推进;</li>
|
||||
* <li>{@code IDEMPOTENT}:在途/已完成(10/20)命中,纯幂等返回;</li>
|
||||
* <li>{@code SAME_REQUEST}:同 requestToken 的重投命中,<b>未重启</b>;</li>
|
||||
* <li>{@code STALE_ROUND}:请求轮次陈旧,未重启;</li>
|
||||
* <li>{@code REPLAY_ONLY}:retryOnly 命中既有实例(含 30/40),未重启;</li>
|
||||
* <li>{@code RESTARTED}:新一轮授权重提,R4 重启推进。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private String branch;
|
||||
/** 行上的当前轮次(消费方据此校正自己的命令表认知) */
|
||||
private Integer applyRound;
|
||||
/** 同一响应的真实状态/实例版本;分支判据保持不变。 */
|
||||
private Integer status;
|
||||
private Integer version;
|
||||
}
|
||||
|
||||
/** 分支码常量(与 {@link ApplyResultV2#branch} 同值域) */
|
||||
public static final class Branch {
|
||||
public static final String CREATED = "CREATED";
|
||||
public static final String IDEMPOTENT = "IDEMPOTENT";
|
||||
public static final String SAME_REQUEST = "SAME_REQUEST";
|
||||
public static final String STALE_ROUND = "STALE_ROUND";
|
||||
public static final String REPLAY_ONLY = "REPLAY_ONLY";
|
||||
public static final String RESTARTED = "RESTARTED";
|
||||
public static final String CONCURRENT_CREATED = "CONCURRENT_CREATED";
|
||||
|
||||
private Branch() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +1,182 @@
|
||||
package com.yunhai.flowcenter.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户面/管理面 VO(/api/flow/**)。
|
||||
*/
|
||||
public final class FlowModels {
|
||||
|
||||
private FlowModels() {
|
||||
}
|
||||
|
||||
/** 我的办件列表行 */
|
||||
@Data
|
||||
public static class InstanceItem {
|
||||
private Long instanceId;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private Integer status;
|
||||
private String currentNodes;
|
||||
private String submitterName;
|
||||
private String submitTime;
|
||||
private String finishTime;
|
||||
private String defName;
|
||||
private Integer defVersion;
|
||||
}
|
||||
|
||||
/** 待办/已办列表行 */
|
||||
@Data
|
||||
public static class TaskItem {
|
||||
private Long taskId;
|
||||
private Long instanceId;
|
||||
private String bizTitle;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private Integer instanceStatus;
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
private String assigneeName;
|
||||
private Integer status;
|
||||
private String opinion;
|
||||
private String arriveTime;
|
||||
private String handleTime;
|
||||
private String deadline;
|
||||
private Integer urgeCount;
|
||||
/**
|
||||
* 已办专用:本人对该办件的累计办理次数(同一办件多节点办理/退回重办只出一行,
|
||||
* 次数用这个字段兜住,前端提示"共办理 N 次");待办口径恒 null。
|
||||
*/
|
||||
private Integer doneCount;
|
||||
}
|
||||
|
||||
/** 抄送我的列表行(NOTIFY 落库行,flw_cc) */
|
||||
@Data
|
||||
public static class CcItem {
|
||||
private Long ccId;
|
||||
private Long instanceId;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private Integer instanceStatus;
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
private String sentTime;
|
||||
}
|
||||
|
||||
/** 办件详情(办理页/详情页数据源) */
|
||||
@Data
|
||||
public static class InstanceDetail {
|
||||
private Long instanceId;
|
||||
private Long schId;
|
||||
private String defCode;
|
||||
private Integer defVersion;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private String bizUrl;
|
||||
/** 业务摘要 JSON(办理页区块渲染;无富文本正文——概览 §十边界) */
|
||||
private String bizSnapshot;
|
||||
private String formDataJson;
|
||||
private Integer status;
|
||||
private String currentNodes;
|
||||
private Integer submitterId;
|
||||
private String submitterName;
|
||||
private String submitTime;
|
||||
private String finishTime;
|
||||
private Integer version;
|
||||
private String nodeSummary;
|
||||
private List<Map<String, Object>> trace;
|
||||
private List<Map<String, Object>> branches;
|
||||
/** 当前用户可否办理(存在本人的待办任务) */
|
||||
private boolean myPendingTask;
|
||||
private List<Long> myPendingTaskIds;
|
||||
/**
|
||||
* 本人待办所在节点的出口列表(多出口办理按钮,[{name,toNodeId,toNodeName}]);
|
||||
* 单出口/无待办为空数组——前端据此决定是否显示去向选择器。
|
||||
*/
|
||||
private List<Map<String, Object>> outlets;
|
||||
/** 可退回目标(主线已办审批节点去重,排除当前节点)[{nodeId,nodeName}] */
|
||||
private List<Map<String, Object>> returnTargets;
|
||||
/** 本人待办可否移交(恒=有本人待办,展示用) */
|
||||
private boolean canTransfer;
|
||||
}
|
||||
|
||||
/** 流程示意图出参(用户面 /diagram 与管理面 /admin/def/diagram 共用,方案 §3.1) */
|
||||
@Data
|
||||
public static class DiagramVo {
|
||||
private String flowGuid;
|
||||
private Long defId;
|
||||
private Long instanceId;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private Integer status;
|
||||
/** 实际出图口径:dept|user|auto(后端归一化后回显) */
|
||||
private String mode;
|
||||
private List<NodeVo> nodes;
|
||||
private List<EdgeVo> edges;
|
||||
/**
|
||||
* 布局单源(2026-10-03 三视图统一):nodeId → {x,y,w,h}(内容区坐标,min 归零)。
|
||||
* 查看端(前端 SVG / PNG)一律以此为准;layout(设计器画布坐标)已随画布退役删除。
|
||||
*/
|
||||
private Map<String, Map<String, Integer>> positions;
|
||||
}
|
||||
|
||||
/** 示意图节点(label/subLabel 已按视图解析好,前端只画不解析;state 仅 instance 口径有值) */
|
||||
@Data
|
||||
public static class NodeVo {
|
||||
private String id;
|
||||
private String type;
|
||||
private String label;
|
||||
private String subLabel;
|
||||
private String deptKey;
|
||||
private String assigneeType;
|
||||
private String countersign;
|
||||
private Integer deadlineHours;
|
||||
/**
|
||||
* 运行时状态(仅 instanceId 口径):done 已办过 / active 当前在办 / pending 未到 /
|
||||
* rejected 被拒绝 / 其他为 null。flowGuid 口径恒 null。
|
||||
*/
|
||||
private String state;
|
||||
}
|
||||
|
||||
/** 示意图连边(name=出口文案,多出口节点显示为连线标签) */
|
||||
@Data
|
||||
public static class EdgeVo {
|
||||
private String from;
|
||||
private String to;
|
||||
private String name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程定义源码(2026-09-18,「图示页-源码」抽屉数据源):只读展示/复制 contentJson,
|
||||
* 可见性口径与 {@link DiagramVo} 完全一致(instanceId 走办件可见性守卫,flowGuid 只给最高已发布版)。
|
||||
*/
|
||||
@Data
|
||||
public static class DefSourceVo {
|
||||
private Long defId;
|
||||
private String flowGuid;
|
||||
private String code;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private Integer status;
|
||||
/** 流程定义 JSON(nodes/edges/settings/layout,运行时格式) */
|
||||
private String contentJson;
|
||||
}
|
||||
}
|
||||
package com.yunhai.flowcenter.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户面/管理面 VO(/api/flow/**)。
|
||||
*/
|
||||
public final class FlowModels {
|
||||
|
||||
private FlowModels() {
|
||||
}
|
||||
|
||||
/** 我的办件列表行 */
|
||||
@Data
|
||||
public static class InstanceItem {
|
||||
private Long instanceId;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private Integer status;
|
||||
private String currentNodes;
|
||||
private String submitterName;
|
||||
private String submitTime;
|
||||
private String finishTime;
|
||||
private String defName;
|
||||
private Integer defVersion;
|
||||
}
|
||||
|
||||
/** 待办/已办列表行 */
|
||||
@Data
|
||||
public static class TaskItem {
|
||||
private Long taskId;
|
||||
private Long instanceId;
|
||||
private String bizTitle;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private Integer instanceStatus;
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
private String assigneeName;
|
||||
/**
|
||||
* 提交人姓名(办件发起人快照)。列表列"提交人"以此为准,勿与 assigneeName(当前办理人)混用。
|
||||
*/
|
||||
private String submitterName;
|
||||
private Integer status;
|
||||
private String opinion;
|
||||
private String arriveTime;
|
||||
private String handleTime;
|
||||
private String deadline;
|
||||
private Integer urgeCount;
|
||||
/**
|
||||
* 已办专用:本人对该办件的累计办理次数(同一办件多节点办理/退回重办只出一行,
|
||||
* 次数用这个字段兜住,前端提示"共办理 N 次");待办口径恒 null。
|
||||
*/
|
||||
private Integer doneCount;
|
||||
}
|
||||
|
||||
/** 抄送我的列表行(NOTIFY 落库行,flw_cc) */
|
||||
@Data
|
||||
public static class CcItem {
|
||||
private Long ccId;
|
||||
private Long instanceId;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private Integer instanceStatus;
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
private String sentTime;
|
||||
}
|
||||
|
||||
/** 办件详情(办理页/详情页数据源) */
|
||||
@Data
|
||||
public static class InstanceDetail {
|
||||
private Long instanceId;
|
||||
private Long schId;
|
||||
private String defCode;
|
||||
private Integer defVersion;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private String bizUrl;
|
||||
/**
|
||||
* 业务详情的**绝对地址**(bizUrl 为裸相对路径,由 applyAppCode 查应用注册表 pc_url 拼出,
|
||||
* 口径同消息中心跳转);无法解析为 null ⇒ 前端不渲染"查看业务详情"入口。
|
||||
* 保留 bizUrl 原值不动:嵌入/去壳态由父页面按自己路由接管,绝对地址只服务流内直开。
|
||||
*/
|
||||
private String bizUrlAbs;
|
||||
/** 业务摘要 JSON(办理页区块渲染;无富文本正文——概览 §十边界) */
|
||||
private String bizSnapshot;
|
||||
private String formDataJson;
|
||||
private Integer status;
|
||||
private String currentNodes;
|
||||
private Integer submitterId;
|
||||
private String submitterName;
|
||||
private String submitTime;
|
||||
private String finishTime;
|
||||
private Integer version;
|
||||
private String nodeSummary;
|
||||
private List<Map<String, Object>> trace;
|
||||
private List<Map<String, Object>> branches;
|
||||
/** 当前用户可否办理(存在本人的待办任务) */
|
||||
private boolean myPendingTask;
|
||||
private List<Long> myPendingTaskIds;
|
||||
/**
|
||||
* 本人待办所在节点的出口列表(多出口办理按钮,[{name,toNodeId,toNodeName}]);
|
||||
* 单出口/无待办为空数组——前端据此决定是否显示去向选择器。
|
||||
*/
|
||||
private List<Map<String, Object>> outlets;
|
||||
/** 可退回目标(主线已办审批节点去重,排除当前节点)[{nodeId,nodeName}] */
|
||||
private List<Map<String, Object>> returnTargets;
|
||||
/** 本人待办可否移交(恒=有本人待办,展示用) */
|
||||
private boolean canTransfer;
|
||||
}
|
||||
|
||||
/** 流程示意图出参(用户面 /diagram 与管理面 /admin/def/diagram 共用,方案 §3.1) */
|
||||
@Data
|
||||
public static class DiagramVo {
|
||||
private String flowGuid;
|
||||
private Long defId;
|
||||
private Long instanceId;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private Integer status;
|
||||
/** 实际出图口径:dept|user|auto(后端归一化后回显) */
|
||||
private String mode;
|
||||
private List<NodeVo> nodes;
|
||||
private List<EdgeVo> edges;
|
||||
/**
|
||||
* 布局单源(2026-10-03 三视图统一):nodeId → {x,y,w,h}(内容区坐标,min 归零)。
|
||||
* 查看端(前端 SVG / PNG)一律以此为准;layout(设计器画布坐标)已随画布退役删除。
|
||||
*/
|
||||
private Map<String, Map<String, Integer>> positions;
|
||||
}
|
||||
|
||||
/** 示意图节点(label/subLabel 已按视图解析好,前端只画不解析;state 仅 instance 口径有值) */
|
||||
@Data
|
||||
public static class NodeVo {
|
||||
private String id;
|
||||
private String type;
|
||||
private String label;
|
||||
private String subLabel;
|
||||
private String deptKey;
|
||||
private String assigneeType;
|
||||
private String countersign;
|
||||
private Integer deadlineHours;
|
||||
/**
|
||||
* 运行时状态(仅 instanceId 口径):done 已办过 / active 当前在办 / pending 未到 /
|
||||
* rejected 被拒绝 / 其他为 null。flowGuid 口径恒 null。
|
||||
*/
|
||||
private String state;
|
||||
}
|
||||
|
||||
/** 示意图连边(name=出口文案,多出口节点显示为连线标签) */
|
||||
@Data
|
||||
public static class EdgeVo {
|
||||
private String from;
|
||||
private String to;
|
||||
private String name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程定义源码(2026-09-18,「图示页-源码」抽屉数据源):只读展示/复制 contentJson,
|
||||
* 可见性口径与 {@link DiagramVo} 完全一致(instanceId 走办件可见性守卫,flowGuid 只给最高已发布版)。
|
||||
*/
|
||||
@Data
|
||||
public static class DefSourceVo {
|
||||
private Long defId;
|
||||
private String flowGuid;
|
||||
private String code;
|
||||
private String name;
|
||||
private Integer version;
|
||||
private Integer status;
|
||||
/** 流程定义 JSON(nodes/edges/settings/layout,运行时格式) */
|
||||
private String contentJson;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.yunhai.flowcenter.model;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -40,6 +41,59 @@ public final class OapiModels {
|
||||
private String submitterName;
|
||||
/** 表单数据对象(业务方编辑页产出,无表单模式不传) */
|
||||
private Map<String, Object> formData;
|
||||
/** 发起时指派(可选,2026-10-13):[{nodeId, userIds}],校验失败业务码拒绝发起 */
|
||||
private List<AssignItem> assigns;
|
||||
/**
|
||||
* 期望的定义版本(可选,2026-10-17 S2.7 版本竞态):调用方拿 apply/preview 时回带的
|
||||
* {@code defVersion} 原样传回。不匹配即业务码拒绝,报「流程已更新,请刷新后重新选择审批人」,
|
||||
* 不再让选人结果落到改版后的线上而报「指派节点不存在」。不传=现状行为逐字不变(存量零影响)。
|
||||
*/
|
||||
private Integer expectedDefVersion;
|
||||
}
|
||||
|
||||
/** 发起时指派项(apply 可选字段) */
|
||||
@Data
|
||||
public static class AssignItem {
|
||||
private String nodeId;
|
||||
private List<Integer> userIds;
|
||||
}
|
||||
|
||||
/** 发起预览响应(GET /oapi/flow/apply/preview):只含开启指派的节点 */
|
||||
@Data
|
||||
public static class ApplyPreviewVo {
|
||||
private String flowGuid;
|
||||
/**
|
||||
* 本次预览依据的已发布定义版本(2026-10-17 S2.7):调用方在 apply 时以
|
||||
* {@code expectedDefVersion} 原样回带,改版即人类可读拒绝而非「指派节点不存在」。
|
||||
*/
|
||||
private Integer defVersion;
|
||||
private List<PreviewNodeVo> nodes;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PreviewNodeVo {
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
/** ANY|ALL(后端缺省补 ALL) */
|
||||
private String countersign;
|
||||
private PreviewSelectionVo selection;
|
||||
private List<PreviewUserVo> candidates;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PreviewSelectionVo {
|
||||
/** SINGLE|MULTI */
|
||||
private String mode;
|
||||
private Integer min;
|
||||
private Integer max;
|
||||
/** 仅子应用 UI 必选提示;引擎不消费 */
|
||||
private Boolean required;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PreviewUserVo {
|
||||
private Integer userId;
|
||||
private String userName;
|
||||
}
|
||||
|
||||
/** 代发起响应(biz uk 幂等:重复代发起 HTTP 200 + duplicated:true 返回既有办件) */
|
||||
@@ -53,6 +107,8 @@ public final class OapiModels {
|
||||
@Data
|
||||
public static class InstanceVo {
|
||||
private Long instanceId;
|
||||
private Integer applyRound;
|
||||
private String requestToken;
|
||||
private Integer status;
|
||||
private Integer version;
|
||||
private String nodeSummary;
|
||||
@@ -88,6 +144,22 @@ public final class OapiModels {
|
||||
private String secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断服务登记请求体(POST /oapi/flow/router/reg,2026-10-12 SERVICE 收口):
|
||||
* 由 yh-flow-callback-spring-boot-starter 启动时自动提交,字段与 SDK
|
||||
* {@code com.yunhai.platform.openapi.flow.model.FlowRouterRegBody} 逐字对齐。
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class RouterRegBody {
|
||||
private String routerKey;
|
||||
private String name;
|
||||
/** 判断端点路径(如 /oapi/flow/route/judge) */
|
||||
private String judgePath;
|
||||
/** 直连基址 scheme://authority[/ctx] */
|
||||
private String directHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件进度(结构化轨迹,2026-09-16 用户拍板:SDK 对外提供图3/图4时间线数据源)。
|
||||
* steps 按 nodeId 任务到达顺序聚合(同人多任务行保留原始顺序),发起申请固定为第 0 步。
|
||||
@@ -197,4 +269,45 @@ public final class OapiModels {
|
||||
/** 多出口节点办理时所选出口名(单出口可省) */
|
||||
private String edgeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按业务单据定位的当前人待办(tasks/by-biz 出参,v2.1 应用内审批卡片数据源)。
|
||||
* 无办件与"有办件但本人无待办"同为 tasks=[] + HTTP 200,出参不区分二者(防探测)。
|
||||
*/
|
||||
@Data
|
||||
public static class ByBizTasksVo {
|
||||
/** 办件 id;无办件时 null */
|
||||
private Long instanceId;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
/** 办件状态;无办件时 null */
|
||||
private Integer status;
|
||||
/**
|
||||
* 办件当前活跃节点——**节点 id** 逗号串,原文照抄 {@code flw_instance.current_nodes},
|
||||
* 与 {@code instances}/{@code progress} 同口径(不是节点名,渲染人读名称请取
|
||||
* {@code tasks[].nodeName})。无办件时 null。
|
||||
*/
|
||||
private String currentNodes;
|
||||
/** 当前人 PENDING 任务,按 taskId 升序;无待办为空数组 */
|
||||
private List<ByBizTaskVo> tasks = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** by-biz 单条待办:只含办理该任务必需的定位信息,不透出他人办理人/意见 */
|
||||
@Data
|
||||
public static class ByBizTaskVo {
|
||||
private Long taskId;
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
private String arriveTime;
|
||||
/** 出口列表;单出口节点为空数组(与 FlowQueryService.detail 的 outlets 口径一致) */
|
||||
private List<ByBizOutletVo> outlets = new ArrayList<>();
|
||||
}
|
||||
|
||||
/** by-biz 出口(多出口节点办理时须选其一,透传给 approve 的 edgeName) */
|
||||
@Data
|
||||
public static class ByBizOutletVo {
|
||||
private String name;
|
||||
private String toNodeId;
|
||||
private String toNodeName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +67,10 @@ public class UserMsgClient {
|
||||
/**
|
||||
* 发送站内信(content 必填;msgNo 幂等,重发安全)。
|
||||
*
|
||||
* @param jumpApp 跳转页面归属应用(可空):页面属业务方(bizUrl 落地页)时填业务方应用名
|
||||
* (=instance.bizType),接收方优先按它查基址;页面属流程中心自己(/task、/instance)
|
||||
* 传 null,由接收方按 bizType 回落
|
||||
* @param jumpApp 跳转页面归属应用(可空,值域=app_code 如 notice/svc-hall):页面属业务方
|
||||
* (bizUrl 落地页)时填 apply 调用方 app_code(=instance.applyAppCode,apply
|
||||
* 时由验签身份按 by-key 换得),接收方优先按它查基址;页面属流程中心自己
|
||||
* (/task、/instance)传 null,由接收方按发送方 appKey 回落
|
||||
*/
|
||||
public void sendUserMsg(Integer schId, List<Integer> userIds, Integer msgType, String bizType,
|
||||
String bizId, String title, String content, String jumpPath, String jumpApp,
|
||||
|
||||
@@ -20,6 +20,9 @@ import java.util.Collections;
|
||||
* - 任务被办理:todo -1(办理人)
|
||||
* - 完成/拒绝/撤回:msgType=1、bizType=flow.result、content 必填(结果+意见)、msgNo 带 version
|
||||
* (重提重启多轮终态互不幂等吞掉)
|
||||
* - 退回:bizType=flow.returned、msgNo 带 version,通知被退回节点的新待办人
|
||||
* 结果/退回与待办/抄送同跳转策略(2026-10-17 F8 缺口①):bizUrl 优先 + jumpApp=applyAppCode,
|
||||
* 空回落中心页且归属留空——四类消息的页面归属口径从此一致。
|
||||
* - 催办:bizType=flow.urge、msgNo=urge:{taskId}:{第N次}
|
||||
* 全部在 AFTER_COMMIT 监听器里发送,失败不阻断(UserMsgClient 内部容错)。
|
||||
*/
|
||||
@@ -73,11 +76,12 @@ public class UserMsgNotifier {
|
||||
String content = "「" + safe(instance.getBizTitle()) + "」等待您审批"
|
||||
+ (task.getNodeName() == null ? "" : "(" + task.getNodeName() + ")");
|
||||
// 跳转路径:办件自带 bizUrl(业务方详情页,改造后为裸相对路径)优先——应用内审批免跳
|
||||
// 流程中心,页面归属=业务方(jumpApp=instance.bizType,接收方按它查基址拼绝对地址);
|
||||
// 未传 bizUrl 回落流程中心办理页 /task/{taskId},页面属自己(jumpApp 留空按 bizType 回落)
|
||||
boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
|
||||
String jumpPath = useBizUrl ? instance.getBizUrl() : "/task/" + task.getId();
|
||||
String jumpApp = useBizUrl ? instance.getBizType() : null;
|
||||
// 流程中心,页面归属=apply 调用方(jumpApp=instance.applyAppCode,apply 时由验签身份
|
||||
// 按_by-key 换得的 app_code,接收方按它查基址拼绝对地址);applyAppCode 空(老数据/
|
||||
// UserBase 不可达)或未传 bizUrl 回落流程中心页(/task/{taskId}、/instance/{id}),
|
||||
// 页面属自己(jumpApp 留空按发送方回落)。bizType 是消息分类,不是路由键,不再冒充。
|
||||
String jumpPath = jumpPath(instance, "/task/" + task.getId());
|
||||
String jumpApp = jumpApp(instance);
|
||||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||||
Collections.singletonList(task.getAssigneeId()), 2,
|
||||
"flow.task", String.valueOf(instance.getId()), title, content,
|
||||
@@ -122,7 +126,8 @@ public class UserMsgNotifier {
|
||||
userMsgClient.sendUserMsg(instance.getSchId().intValue(), targets, 1,
|
||||
"flow.returned", String.valueOf(instance.getId()),
|
||||
"办件被退回:" + safe(instance.getBizTitle()), content.toString(),
|
||||
"/instance/" + instance.getId(), null,
|
||||
// F8 缺口①:退回消息与待办/抄送同策略——业务方详情页优先、归属申报给 apply 调用方
|
||||
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance),
|
||||
"returned:" + instance.getId() + ":" + instance.getVersion(), 30);
|
||||
}
|
||||
|
||||
@@ -143,15 +148,13 @@ public class UserMsgNotifier {
|
||||
String content = "「" + bizTitle + "」抄送给您"
|
||||
+ (nodeName == null || String.valueOf(nodeName).isEmpty()
|
||||
? "" : "(" + nodeName + ")");
|
||||
// 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=业务方)优先,空回落办件详情(属自己)
|
||||
boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
|
||||
String jumpPath = useBizUrl ? instance.getBizUrl() : "/instance/" + instance.getId();
|
||||
String jumpApp = useBizUrl ? instance.getBizType() : null;
|
||||
// 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=apply 调用方 applyAppCode)优先,
|
||||
// 空回落办件详情(属自己)
|
||||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||||
Collections.singletonList((Integer) ccUserId), 1,
|
||||
"flow.cc", String.valueOf(instance.getId()),
|
||||
"您有新的抄送:" + bizTitle, content,
|
||||
jumpPath, jumpApp, "cc:" + ccId, 30);
|
||||
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance), "cc:" + ccId, 30);
|
||||
}
|
||||
|
||||
private void notifyResult(FlowEvent event) {
|
||||
@@ -185,7 +188,9 @@ public class UserMsgNotifier {
|
||||
Collections.singletonList(instance.getSubmitterId()), 1,
|
||||
"flow.result", String.valueOf(instance.getId()),
|
||||
"办件状态更新:" + bizTitle, content.toString(),
|
||||
"/instance/" + instance.getId(), null,
|
||||
// F8 缺口①:结果消息与待办/抄送同策略——业务方详情页优先(子应用详情页 iframe
|
||||
// 显终态进度),jumpApp 申报给 apply 调用方;bizUrl 空仍回落中心办件详情
|
||||
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance),
|
||||
"result:" + instance.getId() + ":" + instance.getVersion(), 30);
|
||||
}
|
||||
|
||||
@@ -206,6 +211,23 @@ public class UserMsgNotifier {
|
||||
"/task/" + task.getId(), null, "urge:" + task.getId() + ":" + count, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 四类消息(待办/抄送/结果/退回)共用的跳转路径策略:办件 bizUrl 优先,空/空白回落中心页。
|
||||
* bizUrl 自 2026-09-26 起是裸相对路径(不含域名/ctx/#),接收方按 jumpApp 查基址拼绝对地址。
|
||||
*/
|
||||
private static String jumpPath(FlwInstance instance, String centerFallback) {
|
||||
return hasBizUrl(instance) ? instance.getBizUrl() : centerFallback;
|
||||
}
|
||||
|
||||
/** 页面归属:走 bizUrl 时申报给 apply 调用方(applyAppCode),回落中心页时留空按发送方归属 */
|
||||
private static String jumpApp(FlwInstance instance) {
|
||||
return hasBizUrl(instance) ? instance.getApplyAppCode() : null;
|
||||
}
|
||||
|
||||
private static boolean hasBizUrl(FlwInstance instance) {
|
||||
return instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
|
||||
}
|
||||
|
||||
private FlwInstance instanceOf(Long instanceId) {
|
||||
return instanceId == null ? null : instanceRepository.findById(instanceId).orElse(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwApp;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface FlwAppRepository extends JpaRepository<FlwApp, Long> {
|
||||
|
||||
List<FlwApp> findBySchIdOrderBySortNoAscIdAsc(Long schId);
|
||||
|
||||
List<FlwApp> findBySchIdAndEnabledOrderBySortNoAscIdAsc(Long schId, Integer enabled);
|
||||
|
||||
/** defSave 归属应用白名单校验:本校启用集合是否包含该 appKey */
|
||||
@Query("select count(a) > 0 from FlwApp a where a.schId = :schId and a.appKey = :appKey and a.enabled = 1")
|
||||
boolean isEnabledApp(@Param("schId") Long schId, @Param("appKey") String appKey);
|
||||
}
|
||||
@@ -35,4 +35,53 @@ public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog,
|
||||
Page<FlwCallbackLog> pageHung(@Param("schId") Long schId, @Param("minRetry") int minRetry, Pageable pageable);
|
||||
|
||||
List<FlwCallbackLog> findByIdInAndSchId(Collection<Long> ids, Long schId);
|
||||
|
||||
// ==================== 健康度体检聚合(2026-10-15) ====================
|
||||
// 全部为只读统计,不新增表;口径见 FlowCallbackAdminService#health。
|
||||
|
||||
/** 近 7 日推送成败计数:按 状态(FULLY_QUALIFIED) 返回 [status, count] */
|
||||
@Query("select l.status, count(l) from FlwCallbackLog l "
|
||||
+ "where l.schId = :schId and l.pushTime >= :since "
|
||||
+ "group by l.status")
|
||||
List<Object[]> countByStatusSince(@Param("schId") Long schId, @Param("since") LocalDateTime since);
|
||||
|
||||
/** 死信数(status=FULLY_QUALIFIED 由调用方传入) */
|
||||
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.status = :status")
|
||||
long countByStatus(@Param("schId") Long schId, @Param("status") Integer status);
|
||||
|
||||
/**
|
||||
* 挂起数:待推且重试次数达阈值。
|
||||
* 与 pageHung 同口径(管理端"挂起"列表的计数版)。
|
||||
*/
|
||||
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.status = 0 "
|
||||
+ "and l.retryCount >= :minRetry")
|
||||
long countHung(@Param("schId") Long schId, @Param("minRetry") int minRetry);
|
||||
|
||||
/** 某应用的最近一次推送时间(判活) */
|
||||
@Query("select max(l.pushTime) from FlwCallbackLog l where l.schId = :schId and l.status = 1")
|
||||
LocalDateTime lastSuccessTime(@Param("schId") Long schId);
|
||||
|
||||
// -------- 按应用 appKey 维度过滤(健康度面板每应用一行,用 regId 归属关联) --------
|
||||
|
||||
/** 某注册集合近 7 日成败计数 */
|
||||
@Query("select l.status, count(l) from FlwCallbackLog l "
|
||||
+ "where l.schId = :schId and l.regId in :regIds and l.pushTime >= :since "
|
||||
+ "group by l.status")
|
||||
List<Object[]> countByStatusSinceForRegs(@Param("schId") Long schId,
|
||||
@Param("regIds") Collection<Long> regIds,
|
||||
@Param("since") LocalDateTime since);
|
||||
|
||||
/** 某注册集合的死信数 */
|
||||
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId "
|
||||
+ "and l.regId in :regIds and l.status = :status")
|
||||
long countByStatusForRegs(@Param("schId") Long schId,
|
||||
@Param("regIds") Collection<Long> regIds,
|
||||
@Param("status") Integer status);
|
||||
|
||||
/** 某注册集合的挂起数 */
|
||||
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.regId in :regIds "
|
||||
+ "and l.status = 0 and l.retryCount >= :minRetry")
|
||||
long countHungForRegs(@Param("schId") Long schId,
|
||||
@Param("regIds") Collection<Long> regIds,
|
||||
@Param("minRetry") int minRetry);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -14,4 +16,8 @@ public interface FlwCallbackRegRepository extends JpaRepository<FlwCallbackReg,
|
||||
List<FlwCallbackReg> findBySchIdAndFlowGuidAndStatus(Long schId, String flowGuid, Integer status);
|
||||
|
||||
List<FlwCallbackReg> findBySchIdOrderByUpdateTimeDesc(Long schId);
|
||||
|
||||
/** "按应用筛选"下拉数据源之一:本校注册过回调的应用 */
|
||||
@Query("select distinct r.appKey from FlwCallbackReg r where r.schId = :schId")
|
||||
List<String> findDistinctAppKeysBySchId(@Param("schId") Long schId);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,23 @@ import com.yunhai.flowcenter.entity.FlwCc;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface FlwCcRepository extends JpaRepository<FlwCc, Long> {
|
||||
|
||||
/** 抄送我的(用户面页签,走 user+sent_time 索引) */
|
||||
Page<FlwCc> findBySchIdAndUserIdOrderBySentTimeDesc(Long schId, Integer userId, Pageable pageable);
|
||||
/** 抄送我的(用户面页签,走 user+sent_time 索引;appKey 可空=不按应用过滤,口径=定义归属 flw_def.app_key) */
|
||||
@Query("select c from FlwCc c join FlwInstance i on i.id = c.instanceId join FlwDef d on d.id = i.defId "
|
||||
+ "where c.schId = :schId and c.userId = :userId "
|
||||
+ "and (:appKey is null or d.appKey = :appKey) order by c.sentTime desc")
|
||||
Page<FlwCc> pageCc(@Param("schId") Long schId, @Param("userId") Integer userId,
|
||||
@Param("appKey") String appKey, Pageable pageable);
|
||||
|
||||
/** 办件轨迹/进度留痕 */
|
||||
List<FlwCc> findByInstanceIdOrderByIdAsc(Long instanceId);
|
||||
|
||||
/** 可见性守卫用(F7 前置补丁):本办件是否抄送过该人——只读可见,不授予办理权 */
|
||||
boolean existsByInstanceIdAndUserId(Long instanceId, Integer userId);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -18,16 +19,12 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
|
||||
/** 任意状态(回调注册只要求流程线存在) */
|
||||
Optional<FlwDef> findFirstBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid);
|
||||
|
||||
/** 同名草稿(保存新建时复用,避免 uk 冲突) */
|
||||
Optional<FlwDef> findFirstBySchIdAndCodeAndStatus(Long schId, String code, Integer status);
|
||||
|
||||
@Query("select coalesce(max(d.version), 0) from FlwDef d where d.schId = :schId and d.code = :code")
|
||||
int maxVersionByCode(@Param("schId") Long schId, @Param("code") String code);
|
||||
|
||||
@Query("select coalesce(max(d.version), 0) from FlwDef d where d.schId = :schId and d.flowGuid = :flowGuid")
|
||||
int maxVersionByGuid(@Param("schId") Long schId, @Param("flowGuid") String flowGuid);
|
||||
|
||||
Optional<FlwDef> findFirstBySchIdAndCodeOrderByVersionDesc(Long schId, String code);
|
||||
/** "按应用筛选"下拉数据源之一:本校 flw_def 已标注的应用 */
|
||||
@Query("select distinct d.appKey from FlwDef d where d.schId = :schId and d.appKey is not null")
|
||||
List<String> findDistinctAppKeys(@Param("schId") Long schId);
|
||||
|
||||
List<FlwDef> findBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid);
|
||||
|
||||
@@ -40,4 +37,7 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
|
||||
+ "and d.version = (select max(d2.version) from FlwDef d2 where d2.schId = d.schId "
|
||||
+ "and d2.flowGuid = d.flowGuid and d2.status = 1) order by d.updateTime desc")
|
||||
List<FlwDef> findPublishedLatestPerGuid(@Param("schId") Long schId);
|
||||
|
||||
/** 在途统计(议题二④):一次取回多条流程线的全部版本行 */
|
||||
List<FlwDef> findBySchIdAndFlowGuidIn(Long schId, Collection<String> flowGuids);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwDeptMember;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FlwDeptMemberRepository extends JpaRepository<FlwDeptMember, Long> {
|
||||
|
||||
List<FlwDeptMember> findByDeptIdOrderByIdAsc(Long deptId);
|
||||
|
||||
List<FlwDeptMember> findByDeptIdAndStatusOrderByIdAsc(Long deptId, Integer status);
|
||||
|
||||
Optional<FlwDeptMember> findByDeptIdAndUserId(Long deptId, Integer userId);
|
||||
|
||||
void deleteByDeptId(Long deptId);
|
||||
|
||||
long countByDeptIdAndStatus(Long deptId, Integer status);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwDept;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FlwDeptRepository extends JpaRepository<FlwDept, Long> {
|
||||
|
||||
List<FlwDept> findBySchIdOrderByIdAsc(Long schId);
|
||||
|
||||
List<FlwDept> findBySchIdAndStatusOrderByIdAsc(Long schId, Integer status);
|
||||
|
||||
Optional<FlwDept> findBySchIdAndId(Long schId, Long id);
|
||||
|
||||
/** 一键拉取幂等:同源 UserBase 组只建一行 */
|
||||
Optional<FlwDept> findBySchIdAndSourceGroupId(Long schId, Integer sourceGroupId);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -17,18 +18,32 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
|
||||
|
||||
List<FlwInstance> findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status);
|
||||
|
||||
/** 我提交的办件(分页,状态可空) */
|
||||
@Query("select i from FlwInstance i where i.schId = :schId and i.submitterId = :submitterId "
|
||||
+ "and (:status is null or i.status = :status) order by i.id desc")
|
||||
/** 我提交的办件(分页,状态/应用可空;应用口径=定义归属 flw_def.app_key) */
|
||||
@Query("select i from FlwInstance i join FlwDef d on d.id = i.defId "
|
||||
+ "where i.schId = :schId and i.submitterId = :submitterId "
|
||||
+ "and (:status is null or i.status = :status) "
|
||||
+ "and (:appKey is null or d.appKey = :appKey) order by i.id desc")
|
||||
Page<FlwInstance> pageMy(@Param("schId") Long schId, @Param("submitterId") Integer submitterId,
|
||||
@Param("status") Integer status, Pageable pageable);
|
||||
@Param("status") Integer status, @Param("appKey") String appKey,
|
||||
Pageable pageable);
|
||||
|
||||
/** 管理端办件分页(状态/关键字可空) */
|
||||
/**
|
||||
* 管理端办件分页(状态/关键字可空)。
|
||||
* hung=true 只看「路由挂起」:审批中(status=10)且存在某 (instance,node) 的<b>最大 attempt</b>
|
||||
* flw_route_log 行 outlet 仍为 NULL(与 {@link com.yunhai.flowcenter.repository.FlwRouteLogRepository#findHungInstanceIds}
|
||||
* 同口径——历史低 attempt 失败行不参与判定,防「重跑已成功仍报挂起」误报);hung=null/false 不过滤。
|
||||
*/
|
||||
@Query("select i from FlwInstance i where i.schId = :schId "
|
||||
+ "and (:status is null or i.status = :status) "
|
||||
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) order by i.id desc")
|
||||
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) "
|
||||
+ "and (:hung is null or :hung = false or (i.status = 10 and exists ("
|
||||
+ "select r.id from FlwRouteLog r where r.instanceId = i.id and r.outlet is null "
|
||||
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
|
||||
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)))) "
|
||||
+ "order by i.id desc")
|
||||
Page<FlwInstance> pageAdmin(@Param("schId") Long schId, @Param("status") Integer status,
|
||||
@Param("keyword") String keyword, Pageable pageable);
|
||||
@Param("keyword") String keyword, @Param("hung") Boolean hung,
|
||||
Pageable pageable);
|
||||
|
||||
/** 按业务类型/状态分页(oapi instances 查询;bizType/status 均可空=不过滤) */
|
||||
@Query("select i from FlwInstance i where i.schId = :schId "
|
||||
@@ -36,4 +51,8 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
|
||||
+ "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);
|
||||
|
||||
/** 在途办件数(议题二④):按 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")
|
||||
List<Object[]> countApprovingByDefIds(@Param("schId") Long schId, @Param("defIds") Collection<Long> defIds);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long> {
|
||||
@@ -16,4 +17,16 @@ public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long>
|
||||
@Query("select coalesce(max(l.attempt), 0) from FlwRouteLog l "
|
||||
+ "where l.instanceId = :instanceId and l.nodeId = :nodeId")
|
||||
int maxAttempt(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
|
||||
|
||||
/**
|
||||
* 台账「路由挂起」红标数据源(F11-4):页内办件中仍挂起的 instanceId 集合。
|
||||
* 判定口径:某 (instance, node) 的<b>最大 attempt</b> 行 outlet 仍为 NULL 才算挂着——
|
||||
* 退回重走/管理端重跑会写更高 attempt 的新行,历史低 attempt 的失败行(outlet NULL)不参与判定,
|
||||
* 防「已重跑成功仍报挂起」的误报。
|
||||
*/
|
||||
@Query("select distinct r.instanceId from FlwRouteLog r where r.instanceId in :instanceIds "
|
||||
+ "and r.outlet is null "
|
||||
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
|
||||
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)")
|
||||
List<Long> findHungInstanceIds(@Param("instanceIds") Collection<Long> instanceIds);
|
||||
}
|
||||
|
||||
@@ -52,10 +52,13 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
|
||||
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId")
|
||||
int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId);
|
||||
|
||||
/** 我的待办(主路径,走 assignee 索引) */
|
||||
@Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 "
|
||||
/** 我的待办(主路径,走 assignee 索引;appKey 可空=不按应用过滤,口径=办件定义归属应用 flw_def.app_key) */
|
||||
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
|
||||
+ "where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 "
|
||||
+ "and (:appKey is null or d.appKey = :appKey) "
|
||||
+ "order by t.arriveTime asc, t.id asc")
|
||||
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable);
|
||||
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
|
||||
@Param("appKey") String appKey, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 我的待办(oapi 版,bizType 可选过滤):join 办件表过滤业务类型,排序口径与 {@link #pageTodo}
|
||||
@@ -75,14 +78,19 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
|
||||
*
|
||||
* <p>状态集由调用方传 {@link com.yunhai.flowcenter.enums.FlwEnums.TaskStatus#handledCodes()}
|
||||
* (勿硬编码字面量);留痕事件行 6/7 不在其中,故不会出现在已办里。
|
||||
*
|
||||
* <p>appKey 可空=不过滤,join 均为多对一(办件→定义各一行),不影响"一个办件一行"的分页口径。
|
||||
*/
|
||||
@Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId "
|
||||
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
|
||||
+ "where t.schId = :schId and t.assigneeId = :assigneeId "
|
||||
+ "and t.status in :statuses "
|
||||
+ "and (:appKey is null or d.appKey = :appKey) "
|
||||
+ "and t.id = (select max(t2.id) from FlwTask t2 where t2.instanceId = t.instanceId "
|
||||
+ " and t2.assigneeId = :assigneeId and t2.status in :statuses) "
|
||||
+ "order by t.handleTime desc, t.id desc")
|
||||
Page<FlwTask> pageDone(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
|
||||
@Param("statuses") List<Integer> statuses, Pageable pageable);
|
||||
@Param("statuses") List<Integer> statuses, @Param("appKey") String appKey,
|
||||
Pageable pageable);
|
||||
|
||||
/** 已办累计次数(按办件批量,配合 pageDone 的"一个办件一行") */
|
||||
@Query("select t.instanceId, count(t) from FlwTask t where t.instanceId in :instanceIds "
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.platform.openapi.base.OpenApiAppClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* 办件 biz_url 绝对化(办件详情/办理页"查看业务详情")。
|
||||
*
|
||||
* <p>bizUrl 自 2026-09-26 起是**裸相对路径**(不含域名/ctx/#,见 UserMsgNotifier 跳转策略):落地基址由
|
||||
* 接收方按"页面归属"解析。消息链路(UserHome 消息中心)已按 jumpApp→application_info.pc_url 拼;
|
||||
* 流程中心自己是另一类接收方——用户在流内直接点"查看业务详情"时没有宿主页面可借基址,必须自己解析,
|
||||
* 否则裸路径会落到 flow-center 自己的 origin 上(实测 /list/detail?serid=78 404)。
|
||||
*
|
||||
* <p>解析口径与消息中心逐字一致({@code MsgCenterProxyService#jumpUrl}):绝对地址直通 → 否则
|
||||
* 按办件快照的 applyAppCode 查应用注册表 pc_url(老数据没有 app_code 时用 applyAppKey 兜底)
|
||||
* → 丢 fragment、去尾斜杠 → {@code base + "#" + path}。查不到 pc_url 返回 null(前端据此不渲染入口),**不猜**。
|
||||
*
|
||||
* <p>缓存:pc_url 属低频变更的注册表数据,按应用标识缓存 5 分钟;查无缓存 60 秒(防脏值刷重复出站)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BizUrlResolver {
|
||||
|
||||
private static final long HIT_TTL_MS = 5 * 60_000L;
|
||||
private static final long MISS_TTL_MS = 60_000L;
|
||||
|
||||
private final OpenApiAppClient appClient;
|
||||
|
||||
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* @param appCode 页面归属应用(办件 apply_app_code,如 notice)
|
||||
* @param appKey 验签身份 app_key(老数据 app_code 为空时的兜底)
|
||||
* @param bizUrl 办件快照里的 biz_url(裸相对路径,或历史存量绝对地址)
|
||||
* @return 绝对地址;绝对地址原样返回,无法解析返回 null
|
||||
*/
|
||||
public String resolve(String appCode, String appKey, String bizUrl) {
|
||||
if (bizUrl == null || bizUrl.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String url = bizUrl.trim();
|
||||
if (isAbsolute(url)) {
|
||||
return url;
|
||||
}
|
||||
String pcUrl = null;
|
||||
if (hasText(appCode)) {
|
||||
pcUrl = pcUrl("code:" + appCode.trim(), appClient::getAppByCode);
|
||||
}
|
||||
if (pcUrl == null && hasText(appKey)) {
|
||||
pcUrl = pcUrl("key:" + appKey.trim(), appClient::getAppByKey);
|
||||
}
|
||||
if (pcUrl == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder base = new StringBuilder(pcUrl);
|
||||
int hash = base.indexOf("#");
|
||||
if (hash >= 0) {
|
||||
base.setLength(hash);
|
||||
}
|
||||
while (base.length() > 0 && base.charAt(base.length() - 1) == '/') {
|
||||
base.setLength(base.length() - 1);
|
||||
}
|
||||
if (base.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
return base + "#" + (url.startsWith("/") ? url : "/" + url);
|
||||
}
|
||||
|
||||
private static boolean isAbsolute(String url) {
|
||||
return url.startsWith("http://") || url.startsWith("https://");
|
||||
}
|
||||
|
||||
private static boolean hasText(String v) {
|
||||
return v != null && !v.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String pcUrl(String cacheKey, Function<String, JsonNode> fetcher) {
|
||||
long now = System.currentTimeMillis();
|
||||
CacheEntry cached = cache.get(cacheKey);
|
||||
if (cached != null && cached.expireAt > now) {
|
||||
return cached.pcUrl;
|
||||
}
|
||||
String pcUrl = null;
|
||||
try {
|
||||
JsonNode app = fetcher.apply(cacheKey.substring(cacheKey.indexOf(':') + 1));
|
||||
if (app != null && app.hasNonNull("pcUrl")) {
|
||||
String v = app.get("pcUrl").asText(null);
|
||||
if (hasText(v)) {
|
||||
pcUrl = v.trim();
|
||||
}
|
||||
}
|
||||
if (pcUrl == null) {
|
||||
log.warn("[flow-center] biz_url 归属应用无 pc_url,跳转入口隐藏:{}", cacheKey);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 不可达按 miss 缓存,避免办件详情页每请求打一次 UserBase
|
||||
log.warn("[flow-center] biz_url 归属应用查址失败(UserBase 不可达?){}:{}", cacheKey, e.toString());
|
||||
}
|
||||
cache.put(cacheKey, new CacheEntry(pcUrl, now + (pcUrl == null ? MISS_TTL_MS : HIT_TTL_MS)));
|
||||
return pcUrl;
|
||||
}
|
||||
|
||||
private static final class CacheEntry {
|
||||
private final String pcUrl;
|
||||
private final long expireAt;
|
||||
|
||||
private CacheEntry(String pcUrl, long expireAt) {
|
||||
this.pcUrl = pcUrl;
|
||||
this.expireAt = expireAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 科室口径文案的唯一出口(2026-09-18):DEPT_KEY 节点的"科室名"取值只在 {@link #mappedDeptName}
|
||||
* 里实现一次——流程图(FlowDiagramService 的科室视图)与流程轨迹(FlowQueryService 的科室口径)
|
||||
* 共用,避免两边对同一份映射表给出不同名字。
|
||||
* 科室口径文案的唯一出口(2026-09-18 建,2026-10-10 切源 flw_dept):DEPT_KEY 节点的"科室名"
|
||||
* 取值只在这里实现一次——流程图(FlowDiagramService 的科室视图)与流程轨迹(FlowQueryService
|
||||
* 的科室口径)共用,避免两边对同一份科室表给出不同名字。
|
||||
*/
|
||||
final class DeptLabelSupport {
|
||||
|
||||
@@ -15,18 +11,14 @@ final class DeptLabelSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射行里的科室名:取第一个非空 deptName(一个 deptKey 的表现通常一致;科室名可空,
|
||||
* 空则返回 null 由调用方决定回落链)。
|
||||
* 科室名(flw_dept.dept_name):科室不存在/已删返回 null,由调用方决定回落链
|
||||
* (科室不存在≠科室停用——停用科室仍有名字,轨迹/图示照常显示)。
|
||||
*/
|
||||
static String mappedDeptName(List<FlwAssigneeMap> maps) {
|
||||
if (maps == null) {
|
||||
static String deptName(FlowDeptAdminService deptAdminService, Long schId, String deptKey) {
|
||||
try {
|
||||
return deptAdminService.deptName(schId, deptKey);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
for (FlwAssigneeMap m : maps) {
|
||||
if (m.getDeptName() != null && !m.getDeptName().trim().isEmpty()) {
|
||||
return m.getDeptName();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,10 @@ import java.util.Map;
|
||||
* 而 img 标签带不了 app-sign 请求头,因此《流程中心流程图查看方案.md》§二"不做服务端出图"的
|
||||
* 边界对外场景作废——本类在服务端把布局 JSON 画成 PNG;前端手绘 SVG 方案仅流程中心自有页面继续用。
|
||||
* <p>
|
||||
* 布局(2026-09-17 重构,与前端 flowLayout 同口径):自左向右拓扑分层(最长路径),并行分支组以
|
||||
* SPLIT 中心线为轴垂直居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN),忽略设计器
|
||||
* 画布坐标(那是全屏编辑器坐标系,直接缩放会失真);连线为正交折线(右中→中线→左中)。
|
||||
* 布局(2026-09-17 重构,与前端 flowLayout 同口径;2026-10-09 竖排):自上向下拓扑分层(最长路径),
|
||||
* 并行分支组以 SPLIT 竖直中线为轴左右居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN),
|
||||
* 忽略设计器画布坐标(那是全屏编辑器坐标系,直接缩放会失真);连线为正交折线(底中→中线→顶中)。
|
||||
* 名单(subLabel)按盒宽换行 ≤2 行,放不下以"…等 N 人"收尾(与前端 SVG 同口径)。
|
||||
* 配色与前端示意图一致(蓝审批卡/绿开始/灰结束/橙菱形),节点颜色按运行时状态
|
||||
* done/active/rejected/pending 上色,图例仅 instance 口径绘制。
|
||||
* 防御出图:环/孤儿节点/缺失端点均降级为"必出图",不抛渲染异常(外部 img 场景无错误页可跳)。
|
||||
@@ -140,7 +141,7 @@ public class DiagramImageService {
|
||||
FontMetrics titleFm = g.getFontMetrics();
|
||||
g.drawString(title, (imgW - titleFm.stringWidth(title)) / 2f, MARGIN + titleFont.getSize() + 2);
|
||||
|
||||
// 连线(先画线后画节点,节点底色盖住线头):正交折线 右中→中线→左中
|
||||
// 连线(先画线后画节点,节点底色盖住线头):正交折线 底中→竖直段→水平折→顶中
|
||||
Map<String, Map<String, String>> edgeNames = new HashMap<>();
|
||||
for (FlowModels.EdgeVo e : edges) {
|
||||
edgeNames.computeIfAbsent(e.getFrom(), k -> new HashMap<>()).put(e.getTo(), e.getName());
|
||||
@@ -152,19 +153,20 @@ public class DiagramImageService {
|
||||
if (from == null || to == null) {
|
||||
continue;
|
||||
}
|
||||
int x1 = from[0] + from[2];
|
||||
int y1 = from[1] + from[3] / 2;
|
||||
int x2 = to[0];
|
||||
int y2 = to[1] + to[3] / 2;
|
||||
int mid = (x1 + x2) / 2;
|
||||
int x1 = from[0] + from[2] / 2;
|
||||
int y1 = from[1] + from[3];
|
||||
int x2 = to[0] + to[2] / 2;
|
||||
int y2 = to[1];
|
||||
int mid = (y1 + y2) / 2;
|
||||
g.setColor(C_LINE);
|
||||
g.drawLine(x1, y1, mid, y1);
|
||||
g.drawLine(mid, y1, mid, y2);
|
||||
g.drawLine(mid, y2, x2 - ARROW, y2);
|
||||
drawArrowHead(g, x2 - ARROW, y2);
|
||||
// 多出口分支文案:折线竖段中点白底小字(单出口连线不标,避免噪音)
|
||||
g.drawLine(x1, y1, x1, mid);
|
||||
g.drawLine(x1, mid, x2, mid);
|
||||
g.drawLine(x2, mid, x2, y2 - ARROW);
|
||||
drawArrowHead(g, x2, y2 - ARROW);
|
||||
// 多出口分支文案:折线水平段中点白底小字(单出口连线不标,避免噪音)。
|
||||
// 同源多出口共享下方竖直段,标签挂各自折线中点((x1+x2)/2)才不会互相叠字。
|
||||
if (edgeNames.get(e.getFrom()).size() > 1 && e.getName() != null && !e.getName().isEmpty()) {
|
||||
drawMidLabel(g, mid, (y1 + y2) / 2, e.getName(), smallFont);
|
||||
drawMidLabel(g, (x1 + x2) / 2, mid, e.getName(), smallFont);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,41 +256,43 @@ public class DiagramImageService {
|
||||
return;
|
||||
}
|
||||
if (FlowDefModel.TYPE_NOTIFY.equals(type)) {
|
||||
// 抄送:圆角卡(青框白底,label + 单行 subLabel)——瞬时节点不占 active 红,
|
||||
// 状态着色与审批一致(done 绿框表示本办件已抄送过)
|
||||
// 抄送:圆角卡(青框白底)——瞬时节点不占 active 红,
|
||||
// 状态着色与审批一致(done 绿框表示本办件已抄送过);标题 + ≤2 行名单(仅标题取青色)
|
||||
g.setColor(C_NOTIFY_FILL);
|
||||
g.fillRoundRect(p[0], p[1], p[2], p[3], 12, 12);
|
||||
g.setColor(borderOf(n));
|
||||
g.setStroke(new BasicStroke("active".equals(n.getState()) ? 2.4f : 1.6f));
|
||||
g.drawRoundRect(p[0], p[1], p[2], p[3], 12, 12);
|
||||
g.setFont(labelF);
|
||||
g.setColor(C_NOTIFY_BORDER);
|
||||
String label = truncate(g, n.getLabel(), p[2] - 20);
|
||||
g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f, p[1] + 26);
|
||||
if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) {
|
||||
g.setFont(subF);
|
||||
g.setColor(C_SUB);
|
||||
String sub = truncate(g, n.getSubLabel(), p[2] - 20);
|
||||
g.drawString(sub, p[0] + (p[2] - g.getFontMetrics().stringWidth(sub)) / 2f, p[1] + 46);
|
||||
}
|
||||
drawNameCardText(g, n, p, labelF, subF, C_NOTIFY_BORDER);
|
||||
return;
|
||||
}
|
||||
// 审批:圆角卡(默认蓝框;done 绿/active 红/pending 灰/rejected 红底),label + 单行 subLabel
|
||||
// 审批:圆角卡(默认蓝框;done 绿/active 红/pending 灰/rejected 红底),标题 + ≤2 行名单
|
||||
g.setColor("rejected".equals(n.getState()) ? C_REJECT_FILL : Color.WHITE);
|
||||
g.fillRoundRect(p[0], p[1], p[2], p[3], 12, 12);
|
||||
boolean active = "active".equals(n.getState());
|
||||
g.setColor(borderOf(n));
|
||||
g.setStroke(new BasicStroke(active ? 2.4f : 1.6f));
|
||||
g.drawRoundRect(p[0], p[1], p[2], p[3], 12, 12);
|
||||
drawNameCardText(g, n, p, labelF, subF, C_TEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 名单卡文本(76 高盒竖排口径):标题 1 行居中 + 名单 ≤2 行居中(换行规则见 {@link #wrapNameList});
|
||||
* 名单 2 行时标题基线 25、名单基线 44/61,单行名单时 27/50(视觉居中微调,与前端 SVG 同口径)。
|
||||
*/
|
||||
private void drawNameCardText(Graphics2D g, FlowModels.NodeVo n, int[] p, Font labelF, Font subF, Color labelColor) {
|
||||
String[] lines = wrapNameList(g.getFontMetrics(subF), n.getSubLabel(), p[2] - 20);
|
||||
g.setFont(labelF);
|
||||
g.setColor(C_TEXT);
|
||||
g.setColor(labelColor);
|
||||
String label = truncate(g, n.getLabel(), p[2] - 20);
|
||||
g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f, p[1] + 26);
|
||||
if (n.getSubLabel() != null && !n.getSubLabel().isEmpty()) {
|
||||
g.setFont(subF);
|
||||
g.setColor(C_SUB);
|
||||
String sub = truncate(g, n.getSubLabel(), p[2] - 20);
|
||||
g.drawString(sub, p[0] + (p[2] - g.getFontMetrics().stringWidth(sub)) / 2f, p[1] + 46);
|
||||
g.drawString(label, p[0] + (p[2] - g.getFontMetrics().stringWidth(label)) / 2f,
|
||||
p[1] + (lines.length >= 2 ? 25 : 27));
|
||||
g.setFont(subF);
|
||||
g.setColor(C_SUB);
|
||||
int[] subBases = lines.length >= 2 ? new int[]{44, 61} : new int[]{50};
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
g.drawString(lines[i], p[0] + (p[2] - g.getFontMetrics().stringWidth(lines[i])) / 2f,
|
||||
p[1] + subBases[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,12 +306,12 @@ public class DiagramImageService {
|
||||
g.drawString(s, midX - sw / 2f, midY + fm.getAscent() / 2f - 1);
|
||||
}
|
||||
|
||||
/** 箭头指向 +x(分层自左向右,目标恒在右侧) */
|
||||
/** 箭头指向 +y(分层自上向下,目标恒在下方) */
|
||||
private void drawArrowHead(Graphics2D g, int x, int y) {
|
||||
Path2D p = new Path2D.Float();
|
||||
p.moveTo(x + ARROW, y);
|
||||
p.lineTo(x, y - ARROW / 2.0 + 1);
|
||||
p.lineTo(x, y + ARROW / 2.0 - 1);
|
||||
p.moveTo(x, y + ARROW);
|
||||
p.lineTo(x - ARROW / 2.0 + 1, y);
|
||||
p.lineTo(x + ARROW / 2.0 - 1, y);
|
||||
p.closePath();
|
||||
g.setColor(C_LINE);
|
||||
g.fill(p);
|
||||
@@ -420,10 +424,13 @@ public class DiagramImageService {
|
||||
}
|
||||
|
||||
private static String truncate(Graphics2D g, String s, int width) {
|
||||
return truncate(g.getFontMetrics(), s, width);
|
||||
}
|
||||
|
||||
private static String truncate(FontMetrics fm, String s, int width) {
|
||||
if (s == null || s.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
if (fm.stringWidth(s) <= width) {
|
||||
return s;
|
||||
}
|
||||
@@ -432,4 +439,69 @@ public class DiagramImageService {
|
||||
}
|
||||
return s + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* 名单换行(≤2 行,竖排名单卡口径,与前端 FlowDiagram.vue 同规则):按「、」整名装箱换行
|
||||
* (「 · 」后缀语随最后一名同行,整名超宽时按字截断防御);全量放得下原样分行;放不下时
|
||||
* 两行装满后以「…等 N 人」收尾(N=未展示人数,第 2 行预留尾缀宽度)。
|
||||
*/
|
||||
static String[] wrapNameList(FontMetrics fm, String s, int width) {
|
||||
if (s == null || s.isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
String[] people = s.split("、", -1);
|
||||
// 全量贪心装箱(行数不限):先判断是否真的放得下
|
||||
List<String> packed = new ArrayList<>();
|
||||
StringBuilder cur = new StringBuilder();
|
||||
for (String raw : people) {
|
||||
String p = fm.stringWidth(raw) > width ? truncate(fm, raw, width) : raw;
|
||||
String cand = cur.length() == 0 ? p : cur + "、" + p;
|
||||
if (fm.stringWidth(cand) <= width) {
|
||||
cur.setLength(0);
|
||||
cur.append(cand);
|
||||
} else {
|
||||
packed.add(cur.toString());
|
||||
cur = new StringBuilder(p);
|
||||
}
|
||||
}
|
||||
packed.add(cur.toString());
|
||||
if (packed.size() <= 2) {
|
||||
return packed.toArray(new String[0]);
|
||||
}
|
||||
// 放不下:两行装满 + 「…等 N 人」尾缀(第 2 行装箱时预留尾缀宽度)
|
||||
int n = people.length;
|
||||
StringBuilder l1 = new StringBuilder();
|
||||
int shown = 0;
|
||||
while (shown < n) {
|
||||
String p = fm.stringWidth(people[shown]) > width ? truncate(fm, people[shown], width) : people[shown];
|
||||
String cand = l1.length() == 0 ? p : l1 + "、" + p;
|
||||
if (fm.stringWidth(cand) <= width) {
|
||||
l1.setLength(0);
|
||||
l1.append(cand);
|
||||
shown++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
StringBuilder l2 = new StringBuilder();
|
||||
while (shown < n) {
|
||||
int restAfter = n - shown - 1;
|
||||
String tail = restAfter > 0 ? "…等 " + restAfter + " 人" : "";
|
||||
String p = fm.stringWidth(people[shown]) > width ? truncate(fm, people[shown], width) : people[shown];
|
||||
String cand = l2.length() == 0 ? p : l2 + "、" + p;
|
||||
if (fm.stringWidth(cand) + (tail.isEmpty() ? 0 : fm.stringWidth(tail)) <= width) {
|
||||
l2.setLength(0);
|
||||
l2.append(cand);
|
||||
shown++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
int rest = n - shown;
|
||||
if (l2.length() == 0) {
|
||||
// 第 2 行连一名+尾缀都放不下:整行只展示尾缀(人数语义完整优先于名单)
|
||||
return new String[]{l1.toString(), "…等 " + rest + " 人"};
|
||||
}
|
||||
return new String[]{l1.toString(), l2.append(rest > 0 ? "…等 " + rest + " 人" : "").toString()};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,20 +11,22 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 流程图布局单源(2026-10-03 三视图统一):自 DiagramImageService 原样搬移(算法与常量不变,
|
||||
* PNG 出图逐像素不变)。拓扑最长路径分层(层=列,自左向右),并行分支组以 SPLIT 中心线为轴
|
||||
* 垂直居中(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的 JOIN);输出内容区坐标
|
||||
* (minX/minY 已归零),PNG 渲染与 DiagramVo.positions 共同消费——改布局只改这里。
|
||||
* 流程图布局单源(2026-10-03 三视图统一;2026-10-09 竖排改造):拓扑最长路径分层
|
||||
* (层=行,自上向下),层内按定义顺序横排、整层块对竖直中线水平居中;并行分支组以
|
||||
* SPLIT 竖直中线为轴左右对称展开(SPLIT↔JOIN 配对按各出边可达集求交、取 BFS 序最早的
|
||||
* JOIN);输出内容区坐标(minX/minY 已归零),PNG 渲染与 DiagramVo.positions 共同消费——
|
||||
* 改布局只改这里。
|
||||
*/
|
||||
@Service
|
||||
public class DiagramLayoutService {
|
||||
|
||||
/** 逻辑坐标常量(盒型在槽内居中;列间距留给正交折线走线) */
|
||||
static final int SLOT_W = 210; // 列槽宽(盒型在槽内水平居中)
|
||||
static final int GAP_X = 84; // 列间距(留给正交折线走线)
|
||||
static final int ROW_H = 92; // 行槽高(盒型在槽内垂直居中)
|
||||
/** 逻辑坐标常量(盒型在槽内居中;层间距留给竖直走线与多出口文案,分支列间无需走线空间) */
|
||||
static final int SLOT_H = 100; // 层槽高(盒型在槽内垂直居中)
|
||||
static final int GAP_Y = 56; // 层间距(竖直段 + 多出口文案空间)
|
||||
static final int SLOT_W = 280; // 分支槽宽(盒型在槽内水平居中)
|
||||
static final int GAP_X = 44; // 分支列间距
|
||||
|
||||
/** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡、并行/条件菱形(取外接矩形) */
|
||||
/** 各类型盒型(w,h):开始/结束胶囊、审批圆角卡(名单 2 行)、并行/条件菱形(取外接矩形) */
|
||||
static int[] boxOf(String type) {
|
||||
switch (type == null ? "" : type) {
|
||||
case FlowDefModel.TYPE_START:
|
||||
@@ -35,7 +37,7 @@ public class DiagramLayoutService {
|
||||
case FlowDefModel.TYPE_CONDITION:
|
||||
return new int[]{132, 78};
|
||||
default:
|
||||
return new int[]{200, 64};
|
||||
return new int[]{264, 76};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +58,7 @@ public class DiagramLayoutService {
|
||||
inDeg.merge(e.getTo(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
// 拓扑最长路径分层(层=列,自左向右);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
|
||||
// 拓扑最长路径分层(层=行,自上向下);环兜底:无入度的从层 0 起算,成环残留节点强制层 0(仍必出图)
|
||||
Map<String, Integer> level = new HashMap<>();
|
||||
List<String> queue = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> en : inDeg.entrySet()) {
|
||||
@@ -80,37 +82,39 @@ public class DiagramLayoutService {
|
||||
level.putIfAbsent(n.getId(), 0);
|
||||
}
|
||||
|
||||
// 初始网格:列 x 由层号决定,列内按定义顺序纵排、整列块以内容区中线居中
|
||||
// 初始网格:行 y 由层号决定,层内按定义顺序横排、整层块以内容区竖直中线居中
|
||||
Map<Integer, List<FlowModels.NodeVo>> layers = new LinkedHashMap<>();
|
||||
int maxLevel = 0;
|
||||
int maxRows = 1;
|
||||
int maxCols = 1;
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
int l = level.get(n.getId());
|
||||
maxLevel = Math.max(maxLevel, l);
|
||||
layers.computeIfAbsent(l, k -> new ArrayList<>()).add(n);
|
||||
}
|
||||
for (List<FlowModels.NodeVo> layer : layers.values()) {
|
||||
maxRows = Math.max(maxRows, layer.size());
|
||||
maxCols = Math.max(maxCols, layer.size());
|
||||
}
|
||||
int axisH = maxRows * ROW_H;
|
||||
int axisW = (maxCols - 1) * (SLOT_W + GAP_X) + SLOT_W;
|
||||
Map<String, int[]> box = new HashMap<>();
|
||||
for (Map.Entry<Integer, List<FlowModels.NodeVo>> en : layers.entrySet()) {
|
||||
List<FlowModels.NodeVo> layer = en.getValue();
|
||||
int blockH = layer.size() * ROW_H;
|
||||
int top0 = (axisH - blockH) / 2;
|
||||
int blockW = (layer.size() - 1) * (SLOT_W + GAP_X) + SLOT_W;
|
||||
int left0 = (axisW - blockW) / 2;
|
||||
for (int i = 0; i < layer.size(); i++) {
|
||||
FlowModels.NodeVo n = layer.get(i);
|
||||
int[] wh = boxOf(n.getType());
|
||||
box.put(n.getId(), new int[]{
|
||||
en.getKey() * (SLOT_W + GAP_X) + (SLOT_W - wh[0]) / 2,
|
||||
top0 + i * ROW_H + (ROW_H - wh[1]) / 2,
|
||||
left0 + i * (SLOT_W + GAP_X) + (SLOT_W - wh[0]) / 2,
|
||||
en.getKey() * (SLOT_H + GAP_Y) + (SLOT_H - wh[1]) / 2,
|
||||
wh[0], wh[1]});
|
||||
}
|
||||
}
|
||||
|
||||
// 并行分支组以 SPLIT 中心线为轴垂直居中(内层组先处理,最后整体抬回顶部 0)
|
||||
// 并行分支组以 SPLIT 竖直中线为轴左右居中(内层组先处理,最后整体推回左边界 0)
|
||||
centerParallelGroups(nodes, out, box);
|
||||
|
||||
// 条件分支分列(2026-10-12):CONDITION 多出口各分支水平分列、隐式汇合点吸附回条件节点
|
||||
// 中线——修复不等长/空分支被拓扑分层压成单列、跳层边直穿中间节点且出口文案错位的问题
|
||||
spreadConditionBranches(nodes, out, box);
|
||||
|
||||
// 归零(2026-10-03 三视图统一新增):全体平移使 minX=minY=0,positions 消费端无需再算包围盒
|
||||
if (!box.isEmpty()) {
|
||||
int minX = Integer.MAX_VALUE;
|
||||
@@ -130,10 +134,143 @@ public class DiagramLayoutService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开。
|
||||
* 条件分支分列(2026-10-12):CONDITION 多出口按 SPLIT 同款"分支列"处理——第 i 个出口的
|
||||
* 分支成员整体平移到「条件节点竖直中线 + (i-(n-1)/2)·列距」的列中心,出口汇合点(各分支
|
||||
* 可达集交集的 BFS 最早节点,即隐式 JOIN)不移动、留在中线上。
|
||||
* <p>
|
||||
* 背景:等长分支天然被拓扑分层排进同一层横排(旧测试 conditionBranches 同层用例);
|
||||
* 不等长/空分支(如"校长室→抄送→审批 / 其他→直达结束")各层退化单列,跳层出口边变成
|
||||
* 穿过中间节点的直线、出口文案叠在链中间。本 pass 对已正常的等长场景是恒等变换
|
||||
* (列目标=当前居中位),对不等长场景把各分支拉回各自列。
|
||||
* 嵌套/顺序多个条件节点按定义序处理:外层先整体平移分支(内层条件节点随之移动),
|
||||
* 内层处理时以移动后的自身中线为轴再展开,位移可组合。
|
||||
*/
|
||||
private void spreadConditionBranches(List<FlowModels.NodeVo> nodes, Map<String, List<String>> out,
|
||||
Map<String, int[]> box) {
|
||||
int colSpan = SLOT_W + GAP_X;
|
||||
for (FlowModels.NodeVo cond : nodes) {
|
||||
if (!FlowDefModel.TYPE_CONDITION.equals(cond.getType()) || !box.containsKey(cond.getId())) {
|
||||
continue;
|
||||
}
|
||||
List<String> heads = out.getOrDefault(cond.getId(), new ArrayList<>());
|
||||
if (heads.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
// 各分支可达集(含汇合点),用于定位隐式 JOIN
|
||||
List<java.util.Set<String>> reachSets = new ArrayList<>();
|
||||
for (String head : heads) {
|
||||
java.util.Set<String> set = new java.util.HashSet<>();
|
||||
List<String> q = new ArrayList<>();
|
||||
q.add(head);
|
||||
while (!q.isEmpty()) {
|
||||
String cur = q.remove(0);
|
||||
if (!box.containsKey(cur) || set.contains(cur)) {
|
||||
continue;
|
||||
}
|
||||
set.add(cur);
|
||||
q.addAll(out.getOrDefault(cur, new ArrayList<>()));
|
||||
}
|
||||
reachSets.add(set);
|
||||
}
|
||||
java.util.Set<String> common = reachSets.isEmpty() ? new java.util.HashSet<>() : reachSets.get(0);
|
||||
for (java.util.Set<String> set : reachSets) {
|
||||
common = new java.util.HashSet<>(common);
|
||||
common.retainAll(set);
|
||||
}
|
||||
if (common.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
// 隐式 JOIN:BFS 序最早(近者优先,跳过条件节点自身)
|
||||
Map<String, Integer> order = new HashMap<>();
|
||||
order.put(cond.getId(), 0);
|
||||
List<String> bfs = new ArrayList<>();
|
||||
bfs.add(cond.getId());
|
||||
while (!bfs.isEmpty()) {
|
||||
String cur = bfs.remove(0);
|
||||
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
|
||||
if (!order.containsKey(to)) {
|
||||
order.put(to, order.size());
|
||||
bfs.add(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
String join = null;
|
||||
int joinOrder = Integer.MAX_VALUE;
|
||||
for (String id : order.keySet()) {
|
||||
if (id.equals(cond.getId()) || !common.contains(id)) {
|
||||
continue;
|
||||
}
|
||||
int o = order.get(id);
|
||||
if (o < joinOrder) {
|
||||
joinOrder = o;
|
||||
join = id;
|
||||
}
|
||||
}
|
||||
if (join == null) {
|
||||
continue;
|
||||
}
|
||||
int[] cb = box.get(cond.getId());
|
||||
int axisX = cb[0] + cb[2] / 2;
|
||||
int n = heads.size();
|
||||
java.util.Set<String> forkClaimed = new java.util.HashSet<>();
|
||||
for (int i = 0; i < n; i++) {
|
||||
// 分支成员:从 head 出发、不穿隐式 JOIN、同一汇合点内不重复认领(菱形交叉兜底)
|
||||
List<String> members = new ArrayList<>();
|
||||
List<String> q = new ArrayList<>();
|
||||
q.add(heads.get(i));
|
||||
while (!q.isEmpty()) {
|
||||
String cur = q.remove(0);
|
||||
if (cur.equals(join) || !box.containsKey(cur) || forkClaimed.contains(cur)) {
|
||||
continue;
|
||||
}
|
||||
forkClaimed.add(cur);
|
||||
members.add(cur);
|
||||
q.addAll(out.getOrDefault(cur, new ArrayList<>()));
|
||||
}
|
||||
if (members.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
// 链形分支(每层至多一个成员)逐节点对齐到分支列中心——多层分支整块平移保不了同列;
|
||||
// 非链形(分支内嵌并行等)回退整块平移,保住内部结构不塌列
|
||||
boolean chain = true;
|
||||
java.util.Set<Integer> rows = new java.util.HashSet<>();
|
||||
for (String m : members) {
|
||||
if (!rows.add(box.get(m)[1])) {
|
||||
chain = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
int left = Integer.MAX_VALUE;
|
||||
int right = Integer.MIN_VALUE;
|
||||
for (String m : members) {
|
||||
int[] b = box.get(m);
|
||||
left = Math.min(left, b[0]);
|
||||
right = Math.max(right, b[0] + b[2]);
|
||||
}
|
||||
// (i·列距) − ((n−1)·列距)/2:偶数出口也有精确的 ±列距/2 偏移(colSpan 恒偶,整除无误差)
|
||||
int target = axisX + i * colSpan - (n - 1) * colSpan / 2;
|
||||
if (chain) {
|
||||
for (String m : members) {
|
||||
int[] b = box.get(m);
|
||||
b[0] += target - (b[0] + b[2] / 2);
|
||||
}
|
||||
} else {
|
||||
int delta = target - (left + right) / 2;
|
||||
if (delta != 0) {
|
||||
for (String m : members) {
|
||||
box.get(m)[0] += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 并行分支组水平居中:分支不再从 SPLIT 行向右堆叠,而是以 SPLIT 竖直中线为轴左右对称展开。
|
||||
* SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN);
|
||||
* 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。
|
||||
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 0。
|
||||
* 组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 水平中点吸附回 SPLIT 中线。
|
||||
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最左列推回 0。
|
||||
*/
|
||||
private void centerParallelGroups(List<FlowModels.NodeVo> nodes, Map<String, List<String>> out,
|
||||
Map<String, int[]> box) {
|
||||
@@ -141,12 +278,12 @@ public class DiagramLayoutService {
|
||||
final String split;
|
||||
final String join;
|
||||
final List<String> members;
|
||||
final int maxX;
|
||||
Group(String split, String join, List<String> members, int maxX) {
|
||||
final int maxY;
|
||||
Group(String split, String join, List<String> members, int maxY) {
|
||||
this.split = split;
|
||||
this.join = join;
|
||||
this.members = members;
|
||||
this.maxX = maxX;
|
||||
this.maxY = maxY;
|
||||
}
|
||||
}
|
||||
List<Group> groups = new ArrayList<>();
|
||||
@@ -224,37 +361,37 @@ public class DiagramLayoutService {
|
||||
}
|
||||
int maxLevel = 0;
|
||||
for (String m : members) {
|
||||
maxLevel = Math.max(maxLevel, box.get(m)[0]);
|
||||
maxLevel = Math.max(maxLevel, box.get(m)[1]);
|
||||
}
|
||||
groups.add(new Group(sid, join, members, maxLevel));
|
||||
}
|
||||
// maxX 降序 = 内层组先处理
|
||||
groups.sort((a, b) -> Integer.compare(b.maxX, a.maxX));
|
||||
// maxY 降序 = 内层组先处理
|
||||
groups.sort((a, b) -> Integer.compare(b.maxY, a.maxY));
|
||||
for (Group gp : groups) {
|
||||
int[] s = box.get(gp.split);
|
||||
int axisY = s[1] + s[3] / 2;
|
||||
int top = Integer.MAX_VALUE;
|
||||
int bottom = Integer.MIN_VALUE;
|
||||
int axisX = s[0] + s[2] / 2;
|
||||
int left = Integer.MAX_VALUE;
|
||||
int right = Integer.MIN_VALUE;
|
||||
for (String m : gp.members) {
|
||||
int[] b = box.get(m);
|
||||
top = Math.min(top, b[1]);
|
||||
bottom = Math.max(bottom, b[1] + b[3]);
|
||||
left = Math.min(left, b[0]);
|
||||
right = Math.max(right, b[0] + b[2]);
|
||||
}
|
||||
int delta = axisY - (top + bottom) / 2;
|
||||
int delta = axisX - (left + right) / 2;
|
||||
for (String m : gp.members) {
|
||||
box.get(m)[1] += delta;
|
||||
box.get(m)[0] += delta;
|
||||
}
|
||||
int[] j = box.get(gp.join);
|
||||
j[1] += axisY - (j[1] + j[3] / 2);
|
||||
j[0] += axisX - (j[0] + j[2] / 2);
|
||||
}
|
||||
// 上移出界(y<0)的组把整图平移回 0(同 delta 平移不破坏各组居中)
|
||||
int minY = Integer.MAX_VALUE;
|
||||
// 左移出界(x<0)的组把整图平移回 0(同 delta 平移不破坏各组居中)
|
||||
int minX = Integer.MAX_VALUE;
|
||||
for (int[] b : box.values()) {
|
||||
minY = Math.min(minY, b[1]);
|
||||
minX = Math.min(minX, b[0]);
|
||||
}
|
||||
if (minY < 0) {
|
||||
if (minX < 0) {
|
||||
for (int[] b : box.values()) {
|
||||
b[1] -= minY;
|
||||
b[0] -= minX;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
@@ -14,6 +15,8 @@ import java.util.List;
|
||||
/**
|
||||
* 用户面访问守卫(概览 §十):提交人只读写自己办件;办理人只读写分派给自己的任务;
|
||||
* 管理员本校可查、不可代批。
|
||||
* 2026-10-17(F7 前置补丁):放行口径增「该办件 flw_cc 行接收人」——抄送接收人点开工作台
|
||||
* "抄送我的"行或抄送消息回落链接此前一律 403,抄送自留因此不闭环。只读可见,办理权仍按 assigneeId。
|
||||
* 管理员口径与 FlowAdminGuard / svc-hall ApplyViewGuard / Notice NoticeFlowGuard 对齐:
|
||||
* type==1(超管)或 roleId∈{2,5,7}(校级管理员)——三者必须同口径,否则校级管理员
|
||||
* 能列办件却在详情页 403(本类原先只认 type==1,2026-09-14 校正)。
|
||||
@@ -26,8 +29,9 @@ public class FlowAccessGuard {
|
||||
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
|
||||
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlwCcRepository ccRepository;
|
||||
|
||||
/** 办件详情可见:提交人本人 / 该办件任务办理人 / 本校管理员(超管或校级管理员) */
|
||||
/** 办件详情可见:提交人本人 / 该办件任务办理人 / 本校管理员(超管或校级管理员)/ 该办件抄送接收人 */
|
||||
public void requireInstanceVisible(FlwInstance instance, BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
|
||||
@@ -37,12 +41,19 @@ public class FlowAccessGuard {
|
||||
}
|
||||
if (!taskRepository.findByInstanceIdOrderByIdAsc(instance.getId()).stream()
|
||||
.anyMatch(t -> user.getId().equals(t.getAssigneeId()))) {
|
||||
if (!isAdminOf(instance, user)) {
|
||||
// F7 前置补丁:抄送接收人可读(工作台"抄送我的"行与抄送消息回落链接不再 403)。
|
||||
// 只授予可见,办理权仍在 FlowTaskService 按 assigneeId 复核。
|
||||
if (!isCcRecipient(instance, user) && !isAdminOf(instance, user)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅提交人/办理人/本校管理员可查看该办件");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 该用户是否本办件的抄送接收人(flw_cc 行,逐人一行) */
|
||||
private boolean isCcRecipient(FlwInstance instance, BoaUser user) {
|
||||
return ccRepository.existsByInstanceIdAndUserId(instance.getId(), user.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 本校管理员(可查不可代批;办理权在 FlowTaskService 里再按 assigneeId 校验)。
|
||||
* 超管与校级管理员均限本校——跨校仍 403,不放大可见范围。
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwApp;
|
||||
import com.yunhai.flowcenter.repository.FlwAppRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.platform.openapi.base.OpenApiAppClient;
|
||||
import com.yunhai.platform.utils.model.BoaApp;
|
||||
import com.yunhai.platform.utils.model.BoaAppType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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-10 四议题):flw_app 白名单的同步与开关。
|
||||
*
|
||||
* <p><b>源池口径(拍板 D7)</b>:UserBase {@code /oapi/app/list-by-school}——学校组单点已开通
|
||||
* (sys_group_app_rel,不并子组/角色模块),clientType 传 null 不过滤端(拍板 D3,避免漏纯移动端应用)。
|
||||
* 同步即入白名单 enabled=1(拍板 D2:只做最优实现,不做存量 appKey 兜底——不在学校池的应用
|
||||
* 进不了白名单,defSave 引用会被拦,属拍板接受的后果);池内应用名变化静默刷新(学校别名);
|
||||
* 学校池已移除的本地行保留(enabled 交管理员处置)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowAppAdminService {
|
||||
|
||||
private final FlwAppRepository appRepository;
|
||||
private final OpenApiAppClient appClient;
|
||||
private final FlwCallbackRegRepository callbackRegRepository;
|
||||
|
||||
/**
|
||||
* 应用维度辅助统计(2026-10-11 应用管理增强):key=appKey,value=回调注册数(本校范围)。
|
||||
* 应用设置页展示"对接深度"用;不区分注册启停(停用注册同样计入,管理员感知总量)。
|
||||
*/
|
||||
public Map<String, Long> stats(Long schId) {
|
||||
Map<String, Long> out = new HashMap<>();
|
||||
for (com.yunhai.flowcenter.entity.FlwCallbackReg reg : callbackRegRepository.findBySchIdOrderByUpdateTimeDesc(schId)) {
|
||||
out.merge(reg.getAppKey(), 1L, Long::sum);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表(管理端应用设置页)。附带一次静默学校池同步:UserBase 不可达/学校未开通任何应用时
|
||||
* 降级只返回本地行(首次使用前 flw_app 为空表是正常态,管理员可先在 UserBase 侧开通)。
|
||||
*/
|
||||
public List<FlwApp> list(Long schId) {
|
||||
try {
|
||||
syncFromSchoolPool(schId);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 学校池同步失败,降级返回本地应用行:sch={} {}", schId, e.toString());
|
||||
}
|
||||
return appRepository.findBySchIdOrderBySortNoAscIdAsc(schId);
|
||||
}
|
||||
|
||||
/** 开关某应用是否对接流程(停用后 defSave 归属应用校验拦截新引用;存量定义不受影响) */
|
||||
@Transactional
|
||||
public FlwApp enable(Long schId, Long id, boolean enabled) {
|
||||
FlwApp app = appRepository.findById(id)
|
||||
.orElseThrow(() -> new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "应用行不存在:" + id));
|
||||
if (!app.getSchId().equals(schId)) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "应用行不属于该学校");
|
||||
}
|
||||
app.setEnabled(enabled ? 1 : 0);
|
||||
return appRepository.save(app);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校池 → flw_app diff 同步(新增 enabled=1 / 改名静默刷新 / 池外保留)。
|
||||
* 幂等:按 (sch_id, app_key) 唯一键 upsert。
|
||||
*/
|
||||
@Transactional
|
||||
public void syncFromSchoolPool(Long schId) {
|
||||
List<BoaAppType> pool = appClient.getSchoolApp(schId.intValue(), null);
|
||||
Map<String, BoaApp> poolApps = new HashMap<>();
|
||||
for (BoaAppType type : pool) {
|
||||
for (BoaApp app : type.getApps()) {
|
||||
if (app.getAppKey() != null) {
|
||||
poolApps.put(String.valueOf(app.getAppKey()), app);
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, FlwApp> local = new HashMap<>();
|
||||
for (FlwApp row : appRepository.findBySchIdOrderBySortNoAscIdAsc(schId)) {
|
||||
local.put(row.getAppKey(), row);
|
||||
}
|
||||
int inserted = 0;
|
||||
int refreshed = 0;
|
||||
for (Map.Entry<String, BoaApp> e : poolApps.entrySet()) {
|
||||
BoaApp poolApp = e.getValue();
|
||||
FlwApp row = local.get(e.getKey());
|
||||
if (row == null) {
|
||||
row = new FlwApp();
|
||||
row.setSchId(schId);
|
||||
row.setAppKey(e.getKey());
|
||||
row.setAppName(poolApp.getName() == null || poolApp.getName().isEmpty()
|
||||
? e.getKey() : poolApp.getName());
|
||||
row.setEnabled(1);
|
||||
row.setSortNo(poolApp.getOrderNumber() == null ? 0 : poolApp.getOrderNumber());
|
||||
appRepository.save(row);
|
||||
inserted++;
|
||||
} else if (poolApp.getName() != null && !poolApp.getName().isEmpty()
|
||||
&& !poolApp.getName().equals(row.getAppName())) {
|
||||
// 学校别名变化:静默刷新名称快照(开关不动)
|
||||
row.setAppName(poolApp.getName());
|
||||
row.setSortNo(poolApp.getOrderNumber() == null ? row.getSortNo() : poolApp.getOrderNumber());
|
||||
appRepository.save(row);
|
||||
refreshed++;
|
||||
}
|
||||
}
|
||||
if (inserted > 0 || refreshed > 0) {
|
||||
log.info("[flow-center] 学校池同步:sch={} 池内 {} 应用,新增 {} 行,刷新名 {} 行",
|
||||
schId, poolApps.size(), inserted, refreshed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.yunhai.flowcenter.callback.CallbackPushService;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.AppHealthVo;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
@@ -55,6 +56,15 @@ public class FlowCallbackAdminService {
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调探测(2026-10-11 管理端增强):对指定注册签发一条 flow.ping 未知事件码走真实管道
|
||||
* 同步一发,验证接收方可达性与验签配置。停用注册同样允许探测(管理员排障需要)。
|
||||
*/
|
||||
public java.util.Map<String, Object> probe(Long schId, Long regId) {
|
||||
FlwCallbackReg reg = requireReg(schId, regId);
|
||||
return pushService.probe(schId, reg);
|
||||
}
|
||||
|
||||
/** 推送日志分页(状态/事件/办件筛选) */
|
||||
public BasePageResult<FlwCallbackLog> pageLogs(Long schId, Integer status, String event,
|
||||
Long instanceId, Integer page, Integer size) {
|
||||
@@ -135,6 +145,206 @@ public class FlowCallbackAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 对接健康度体检(2026-10-15 议题二 P2) ====================
|
||||
|
||||
/** 私网/开发机地址特征(登记地址应为网关形态,见《子应用接入流程中心规范》§4.2) */
|
||||
private static final java.util.regex.Pattern PRIVATE_HOST = java.util.regex.Pattern.compile(
|
||||
"://(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|"
|
||||
+ "10\\.[0-9.]+|192\\.168\\.[0-9.]+|172\\.(1[6-9]|2[0-9]|3[01])\\.[0-9.]+)"
|
||||
+ "(:[0-9]+)?");
|
||||
|
||||
/** 挂起判定阈值(与管理端「挂起列表」同口径) */
|
||||
private static final int HUNG_RETRY_MIN = 3;
|
||||
|
||||
/**
|
||||
* 全量应用健康度体检(每应用一行)。
|
||||
*
|
||||
* <p>数据全部来自已有表:{@code flw_callback_reg}(登记地址/base_path)、
|
||||
* {@code flw_callback_log}(推送成败/死信/挂起)。不新增任何表。
|
||||
*
|
||||
* <p>应用清单来自注册表 distinct appKey(未接入回调的应用不参与体检——
|
||||
* 无注册行本身即"未接入",由向导负责,不是健康度问题)。
|
||||
*
|
||||
* @param appNames appKey → 应用名(来自 flw_app;缺失时回落 appKey)
|
||||
*/
|
||||
public List<AppHealthVo> health(Long schId, java.util.Map<String, String> appNames) {
|
||||
List<FlwCallbackReg> allRegs = regRepository.findBySchIdOrderByUpdateTimeDesc(schId);
|
||||
java.time.LocalDateTime since = java.time.LocalDateTime.now().minusDays(7);
|
||||
|
||||
// 按 appKey 归组(同一应用的多个 flowGuid 注册合并体检)
|
||||
java.util.Map<String, List<FlwCallbackReg>> byApp = new java.util.LinkedHashMap<>();
|
||||
for (FlwCallbackReg reg : allRegs) {
|
||||
if (reg.getAppKey() == null || reg.getAppKey().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
byApp.computeIfAbsent(reg.getAppKey(), k -> new java.util.ArrayList<>()).add(reg);
|
||||
}
|
||||
|
||||
List<AppHealthVo> out = new java.util.ArrayList<>();
|
||||
for (java.util.Map.Entry<String, List<FlwCallbackReg>> e : byApp.entrySet()) {
|
||||
out.add(buildAppHealth(schId, e.getKey(),
|
||||
appNames == null ? null : appNames.get(e.getKey()), e.getValue(), since));
|
||||
}
|
||||
// 问题应用优先(bad → warn → ok),同级按评分升序
|
||||
out.sort(java.util.Comparator
|
||||
.comparingInt((AppHealthVo v) -> levelRank(v.getLevel()))
|
||||
.thenComparingInt(AppHealthVo::getScore));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int levelRank(String level) {
|
||||
if ("bad".equals(level)) {
|
||||
return 0;
|
||||
}
|
||||
if ("warn".equals(level)) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
/** 单应用体检:六项检查逐项判定并加权算分 */
|
||||
private AppHealthVo buildAppHealth(Long schId, String appKey, String appName,
|
||||
List<FlwCallbackReg> regs, java.time.LocalDateTime since) {
|
||||
List<Long> regIds = new java.util.ArrayList<>();
|
||||
for (FlwCallbackReg r : regs) {
|
||||
regIds.add(r.getId());
|
||||
}
|
||||
|
||||
List<AppHealthVo.CheckItem> checks = new java.util.ArrayList<>();
|
||||
int score = 100;
|
||||
|
||||
// ---- 1) 登记地址形态:任何一条是私网/开发机即 bad ----
|
||||
String badUrl = null;
|
||||
for (FlwCallbackReg r : regs) {
|
||||
if (r.getCallbackUrl() != null && PRIVATE_HOST.matcher(r.getCallbackUrl()).find()) {
|
||||
badUrl = r.getCallbackUrl();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (badUrl != null) {
|
||||
checks.add(new AppHealthVo.CheckItem("登记地址为网关形态", "bad", badUrl,
|
||||
"删除本地镜像行 → 管理端重新保存绑定(走 starter 自注册)"));
|
||||
score -= 40;
|
||||
} else {
|
||||
FlwCallbackReg first = regs.get(0);
|
||||
checks.add(new AppHealthVo.CheckItem("登记地址为网关形态", "ok",
|
||||
abbreviate(first.getCallbackUrl())));
|
||||
}
|
||||
|
||||
// ---- 2) base_path 与 ctx 一致性:注册里 basePath 为空的条数 ----
|
||||
long emptyBase = regs.stream().filter(r -> r.getBasePath() == null || r.getBasePath().isEmpty()).count();
|
||||
if (emptyBase > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("base_path 已登记", "warn",
|
||||
emptyBase + " 条注册未登记 base_path",
|
||||
"漏填 base_path 会导致推送全 401,症状与「收不到回调」同构"));
|
||||
score -= 10;
|
||||
} else {
|
||||
checks.add(new AppHealthVo.CheckItem("base_path 已登记", "ok",
|
||||
regs.get(0).getBasePath()));
|
||||
}
|
||||
|
||||
// ---- 3) 近 7 日推送成功率 ----
|
||||
long success = 0;
|
||||
long pending = 0;
|
||||
long dead = 0;
|
||||
List<Object[]> statusRows = logRepository.countByStatusSinceForRegs(schId, regIds, since);
|
||||
for (Object[] row : statusRows) {
|
||||
int st = ((Number) row[0]).intValue();
|
||||
long cnt = ((Number) row[1]).longValue();
|
||||
if (st == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
|
||||
success = cnt;
|
||||
} else if (st == FlwEnums.CallbackLogStatus.DEAD.getCode()) {
|
||||
dead = cnt;
|
||||
} else {
|
||||
pending = cnt;
|
||||
}
|
||||
}
|
||||
long total = success + pending + dead;
|
||||
if (total == 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "warn", "近 7 日无推送记录"));
|
||||
score -= 5;
|
||||
} else {
|
||||
double rate = success * 100.0 / total;
|
||||
String txt = String.format(java.util.Locale.ROOT, "%.1f%%(%d/%d)", rate, success, total);
|
||||
if (rate >= 99.0) {
|
||||
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "ok", txt));
|
||||
} else if (rate > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "warn", txt,
|
||||
"存在推送失败,查推送日志末次结果定位"));
|
||||
score -= 15;
|
||||
} else {
|
||||
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "bad", txt,
|
||||
"推送全失败:优先核对登记地址与 base_path"));
|
||||
score -= 30;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 4) 挂起 / 死信数 ----
|
||||
long hung = logRepository.countHungForRegs(schId, regIds, HUNG_RETRY_MIN);
|
||||
long deadCnt = logRepository.countByStatusForRegs(schId, regIds,
|
||||
FlwEnums.CallbackLogStatus.DEAD.getCode());
|
||||
if (deadCnt > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("挂起 / 死信数", "bad", hung + " / " + deadCnt,
|
||||
"死信需管理端确认后重推或标记处理"));
|
||||
score -= 20;
|
||||
} else if (hung > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("挂起 / 死信数", "warn", hung + " / 0",
|
||||
"持续重试中,疑似消费方停机"));
|
||||
score -= 12;
|
||||
} else {
|
||||
checks.add(new AppHealthVo.CheckItem("挂起 / 死信数", "ok", "0 / 0"));
|
||||
}
|
||||
|
||||
// ---- 5) 停用注册告警 ----
|
||||
long disabled = regs.stream().filter(r -> r.getStatus() != null && r.getStatus() != 0).count();
|
||||
if (disabled > 0) {
|
||||
checks.add(new AppHealthVo.CheckItem("注册启用状态", "warn",
|
||||
disabled + " 条注册已停用", "停用后该流程不再收推送,确认是否为预期"));
|
||||
score -= 8;
|
||||
} else {
|
||||
checks.add(new AppHealthVo.CheckItem("注册启用状态", "ok",
|
||||
regs.size() + " 条全部启用"));
|
||||
}
|
||||
|
||||
AppHealthVo vo = new AppHealthVo();
|
||||
vo.setAppKey(appKey);
|
||||
vo.setAppName(appName == null || appName.isEmpty() ? appKey : appName);
|
||||
vo.setScore(Math.max(0, score));
|
||||
vo.setChecks(checks);
|
||||
|
||||
String level = checks.stream().anyMatch(c -> "bad".equals(c.getLevel())) ? "bad"
|
||||
: checks.stream().anyMatch(c -> "warn".equals(c.getLevel())) ? "warn" : "ok";
|
||||
vo.setLevel(level);
|
||||
vo.setSummary(buildSummary(regs.size(), level, badUrl, deadCnt, hung));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static String buildSummary(int regCount, String level, String badUrl,
|
||||
long dead, long hung) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(regCount == 0 ? "无注册" : regCount + " 条注册");
|
||||
if ("ok".equals(level)) {
|
||||
sb.append(" · 对接正常");
|
||||
return sb.toString();
|
||||
}
|
||||
if (badUrl != null) {
|
||||
sb.append(" · 存在私网登记地址,推送必然失败");
|
||||
}
|
||||
if (dead > 0) {
|
||||
sb.append(" · ").append(dead).append(" 条死信");
|
||||
} else if (hung > 0) {
|
||||
sb.append(" · ").append(hung).append(" 条挂起重试中");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String abbreviate(String url) {
|
||||
if (url == null) {
|
||||
return "-";
|
||||
}
|
||||
return url.length() <= 56 ? url : "…" + url.substring(url.length() - 55);
|
||||
}
|
||||
|
||||
private FlwCallbackReg requireReg(Long schId, Long regId) {
|
||||
FlwCallbackReg reg = regRepository.findById(regId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "回调注册不存在:" + regId));
|
||||
|
||||
@@ -7,17 +7,24 @@ import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -30,17 +37,33 @@ import java.util.stream.Collectors;
|
||||
public class FlowDefAdminService {
|
||||
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final com.yunhai.flowcenter.repository.FlwRouterRepository routerRepository;
|
||||
private final com.yunhai.flowcenter.repository.FlwCallbackRegRepository regRepository;
|
||||
private final com.yunhai.flowcenter.repository.FlwAppRepository appRepository;
|
||||
private final com.yunhai.flowcenter.repository.FlwInstanceRepository instanceRepository;
|
||||
private final FlowDeptAdminService deptAdminService;
|
||||
private final FlowEngine flowEngine;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FormCenterSchemaClient formCenterSchemaClient;
|
||||
|
||||
/** 定义列表:status 空=全量(各流程线各版本都展示,管理端"版本历史"内聚在行操作里) */
|
||||
public List<FlwDef> list(Long schId, Integer status) {
|
||||
if (status != null) {
|
||||
return defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status);
|
||||
/** 定义列表:status/appKey 可选过滤(各流程线各版本都展示;appKey 精确匹配,2026-10-10 按应用筛选) */
|
||||
public List<FlwDef> list(Long schId, Integer status, String appKey) {
|
||||
List<FlwDef> rows = status != null
|
||||
? defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status)
|
||||
: defRepository.findBySchIdOrderByUpdateTimeDesc(schId);
|
||||
String key = appKey == null ? null : appKey.trim();
|
||||
if (key != null && !key.isEmpty()) {
|
||||
rows = rows.stream().filter(d -> key.equals(d.getAppKey())).collect(Collectors.toList());
|
||||
}
|
||||
return defRepository.findBySchIdOrderByUpdateTimeDesc(schId);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 应用清单("按应用筛选"下拉数据源):本校 flw_def 已标注 ∪ 已注册回调的应用,去重排序 */
|
||||
public List<String> distinctAppKeys(Long schId) {
|
||||
TreeSet<String> keys = new TreeSet<>();
|
||||
keys.addAll(defRepository.findDistinctAppKeys(schId));
|
||||
keys.addAll(regRepository.findDistinctAppKeysBySchId(schId));
|
||||
return new ArrayList<>(keys);
|
||||
}
|
||||
|
||||
/** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */
|
||||
@@ -54,14 +77,26 @@ public class FlowDefAdminService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存(新建语义按 baseGuid/同名草稿分派,避免 uk(sch,code,version) 冲突):
|
||||
* 保存(2026-10-10 起唯一键为 uk(sch,flow_guid,version),编码降级为可重名的分类标签):
|
||||
* - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1)
|
||||
* - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本"
|
||||
* - id 空 + 同名草稿存在:复用该草稿直改
|
||||
* - id 空 + 全新 code:新流程线(flowGuid 生成,version=1)
|
||||
* - id 空:永远新开一条流程线(flowGuid 生成,version=1);同编码多条线合法,前端保存前有软提示
|
||||
* appKey=归属应用(筛选维度,2026-10-12 拍板必填):新建必须显式传;升版本/直改可继承源行,
|
||||
* 但源行为空时必须显式补填——存量 NULL 线随编辑自然收敛(白名单只校验显式传入值,继承值不重校)
|
||||
* changeLog=升版本改动说明(议题二③):只随新建行落库(三个建新行分支),草稿直改行不写也不清空
|
||||
*/
|
||||
@Transactional
|
||||
public FlwDef save(Long schId, Long id, String code, String name, String contentJson, String baseGuid) {
|
||||
public FlwDef save(Long schId, Long id, String code, String name, String contentJson,
|
||||
String baseGuid, String appKey, String changeLog) {
|
||||
String cleanLog = (changeLog == null || changeLog.trim().isEmpty()) ? null
|
||||
: changeLog.trim().substring(0, Math.min(500, changeLog.trim().length()));
|
||||
String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim();
|
||||
// 归属应用白名单(2026-10-10 应用设置):显式传入非空时必须 ∈ flw_app(sch, enabled=1);
|
||||
// 继承值不重校(既成事实,应用停用不应卡死升版本)。不在校池/未开启的应用由此拦下(拍板 D2:不做存量兜底)
|
||||
if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试");
|
||||
}
|
||||
// JSON 先解析再落库(语法错误不进库)
|
||||
FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson);
|
||||
if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) {
|
||||
@@ -69,8 +104,8 @@ public class FlowDefAdminService {
|
||||
}
|
||||
FlwDef def;
|
||||
if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) {
|
||||
// 在既有流程线上开新版本草稿
|
||||
defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim())
|
||||
// 在既有流程线上开新版本草稿(appKey 缺省继承源线)
|
||||
FlwDef base = defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "baseGuid 流程线不存在"));
|
||||
def = new FlwDef();
|
||||
def.setSchId(schId);
|
||||
@@ -78,25 +113,26 @@ public class FlowDefAdminService {
|
||||
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());
|
||||
} else if (id == null) {
|
||||
FlwDef draft = defRepository.findFirstBySchIdAndCodeAndStatus(
|
||||
schId, code.trim(), FlwEnums.DefStatus.DRAFT.getCode()).orElse(null);
|
||||
if (draft != null) {
|
||||
// 同名草稿复用直改
|
||||
def = draft;
|
||||
} else {
|
||||
def = new FlwDef();
|
||||
def.setSchId(schId);
|
||||
def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase());
|
||||
def.setCode(code.trim());
|
||||
def.setVersion(defRepository.maxVersionByCode(schId, code.trim()) + 1);
|
||||
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
}
|
||||
// 新建:永远新开一条流程线,版本从 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);
|
||||
} else {
|
||||
def = requireDef(schId, id);
|
||||
if (!def.getCode().equals(code.trim())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程编码不可修改");
|
||||
}
|
||||
if (cleanAppKey != null) {
|
||||
def.setAppKey(cleanAppKey);
|
||||
}
|
||||
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
|
||||
// 升版本:复制行,保持 flowGuid
|
||||
FlwDef next = new FlwDef();
|
||||
@@ -105,12 +141,25 @@ public class FlowDefAdminService {
|
||||
next.setCode(def.getCode());
|
||||
next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1);
|
||||
next.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
next.setAppKey(cleanAppKey != null ? cleanAppKey : def.getAppKey());
|
||||
next.setFormUuid(def.getFormUuid());
|
||||
next.setFormVersion(def.getFormVersion());
|
||||
def = next;
|
||||
}
|
||||
}
|
||||
def.setCode(code.trim());
|
||||
def.setName(name.trim());
|
||||
def.setContentJson(contentJson);
|
||||
// 归属应用必填(2026-10-12 拍板):各分支归一后落库值不得为空——新建必须显式传;
|
||||
// 升版本/直改继承源行,源行为空的存量线编辑时强制补填(19 条存量随编辑自然收敛)
|
||||
if (def.getAppKey() == null || def.getAppKey().trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"归属应用必填:请选择归属应用后再保存(应用需先在 流程管理 > 应用设置 中开启)");
|
||||
}
|
||||
// 改动说明只写新建行(新建/baseGuid 开版/已发布复制升版三分支 id 必为 null);草稿直改不动
|
||||
if (def.getId() == null) {
|
||||
def.setChangeLog(cleanLog);
|
||||
}
|
||||
def.setContentJson(withoutFormContract(contentJson)); // Only publish may author a contract snapshot.
|
||||
// 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主)
|
||||
if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) {
|
||||
def.setCode(parsed.code.trim());
|
||||
@@ -118,23 +167,244 @@ public class FlowDefAdminService {
|
||||
if (parsed.name != null && !parsed.name.trim().isEmpty()) {
|
||||
def.setName(parsed.name.trim());
|
||||
}
|
||||
return defRepository.save(def);
|
||||
try {
|
||||
return defRepository.save(def);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// 撞唯一键 (sch,flow_guid,version):并发升版本同一线时可能撞号,转可理解提示(原样抛出=笼统"内部服务错误")
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布 */
|
||||
/**
|
||||
* 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。<br>
|
||||
* <b>发布后不可改,改=升版本</b>由既有升版分派天然保证(非 DRAFT 直改必走升版新行);
|
||||
* 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库);
|
||||
* 非空=设置,保存前按确切版本远端核验,失败不写入。
|
||||
* <br>v3/TASK_1 追加 {@code formVersion}:与 formUuid <b>成对</b>,规则是——
|
||||
* ① 两个都给了值=设关联(版本按服务端收到的整数存,之后发布按这组确切坐标核验);
|
||||
* ② 只给一个=拒(半个坐标既存不下也没法核验,比"存了再说"便宜);
|
||||
* ③ formUuid 给空串=取消关联,<b>同时清空版本</b>(留着一个没人认领的版本号是下一轮误用的起点);
|
||||
* ④ formUuid 给 null=这条指令不存在(两个坐标都不动,兼容旧调用面)。
|
||||
*/
|
||||
@Transactional
|
||||
public FlwDef save(Long schId, Long id, String code, String name, String contentJson,
|
||||
String baseGuid, String appKey, String changeLog, String formUuid, Integer formVersion) {
|
||||
boolean clearing = formUuid != null && formUuid.trim().isEmpty();
|
||||
boolean pairing = formUuid != null && !clearing && formVersion == null;
|
||||
boolean versionOnly = formUuid == null && formVersion != null;
|
||||
if (pairing || versionOnly) {
|
||||
throw new BizException(formVersion == null ? FormBindingErrorCode.VERSION_REQUIRED
|
||||
: FormBindingErrorCode.INVALID_COORDINATE,
|
||||
"关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid="
|
||||
+ (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion);
|
||||
}
|
||||
if (formUuid != null && !clearing) {
|
||||
checkedSchema(schId, formUuid.trim(), formVersion, "保存"); // Validate before the first save.
|
||||
}
|
||||
FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
|
||||
if (formUuid != null) {
|
||||
String clean = formUuid.trim();
|
||||
saved.setFormUuid(clean.isEmpty() ? null : clean);
|
||||
saved.setFormVersion(clean.isEmpty() ? null : formVersion);
|
||||
}
|
||||
try {
|
||||
return defRepository.save(saved);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布。<br>
|
||||
* 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照
|
||||
* 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验),
|
||||
* 并做表达式<b>键查①</b>红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试);
|
||||
* 运行期对 form-center 零依赖。form_uuid 为空(存量线/Mode C)行为逐字不变。 */
|
||||
@Transactional
|
||||
public FlwDef publish(Long schId, Long id) {
|
||||
FlwDef def = requireDef(schId, id);
|
||||
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态");
|
||||
}
|
||||
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿");
|
||||
}
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
|
||||
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
|
||||
Map<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
|
||||
def.getFormVersion(), "发布");
|
||||
List<String> problems = new ArrayList<>();
|
||||
validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
|
||||
validateExpressionTypesAndDomains(model, snapshot, problems);
|
||||
if (!problems.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"发布校验失败(表达式三查):\n" + String.join("\n", problems));
|
||||
}
|
||||
def.setContentJson(embedFormContract(def.getContentJson(), snapshot));
|
||||
} else if (def.getFormVersion() != null) {
|
||||
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
|
||||
}
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setPublishTime(LocalDateTime.now());
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达式键查①(C1 三查之一,红牌):对每条 EXPRESSION 判断的出边表达式,
|
||||
* 变量集 ⊆ 快照字段 keys ∪ 内置变量({@link DefValidator#EXPRESSION_BUILTINS})。
|
||||
* 不执行表达式、不改沙箱(口径与 v2 方案 §4.7 一致)。
|
||||
*/
|
||||
private void validateExpressionKeysAgainstSnapshot(FlowDefModel model, Map<String, Object> snapshot,
|
||||
List<String> problems) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
|
||||
java.util.Set<String> fieldKeys = new java.util.HashSet<>();
|
||||
if (fields != null) {
|
||||
for (Map<String, Object> f : fields) {
|
||||
Object k = f.get("key");
|
||||
if (k != null) {
|
||||
fieldKeys.add(String.valueOf(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fieldKeys.isEmpty()) {
|
||||
problems.add("关联表单快照无字段(formContract.fields 为空):请检查表单是否已发布");
|
||||
return;
|
||||
}
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (n == null || !n.expressionJudge()) {
|
||||
continue;
|
||||
}
|
||||
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
|
||||
if (e.condition == null || e.condition.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
List<String> vars;
|
||||
try {
|
||||
vars = com.yunhai.flowcenter.engine.ExpressionEvaluator.variableNames(e.condition.trim());
|
||||
} catch (Exception ex) {
|
||||
// 语法错已由 DefValidator 拦下,这里理论上到不了;防御口径跳过
|
||||
continue;
|
||||
}
|
||||
for (String v : vars) {
|
||||
if (!fieldKeys.contains(v) && !DefValidator.EXPRESSION_BUILTINS.contains(v)) {
|
||||
problems.add("节点「" + n.name + "」出口「" + e.name + "」表达式变量 " + v
|
||||
+ " 不在关联表单字段集内(快照 " + fieldKeys.size() + " 个字段)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
|
||||
private String embedFormContract(String contentJson, Map<String, Object> snapshot) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
|
||||
objectMapper.readTree(contentJson);
|
||||
root.set("formContract", objectMapper.valueToTree(snapshot));
|
||||
return objectMapper.writeValueAsString(root);
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "快照嵌入失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 三查②③(2026-10-20 C1 第一刀余量项) =====================
|
||||
|
||||
/** 数值桶:可与数值字面量比大小的快照字段类型 */
|
||||
private static final java.util.Set<String> NUMERIC_TYPES =
|
||||
new java.util.HashSet<>(java.util.Arrays.asList("number"));
|
||||
/** 值域字面量型(enum 域查③适用):options 收进快照 enum 的类型 */
|
||||
private static final java.util.Set<String> ENUM_TYPES =
|
||||
new java.util.HashSet<>(java.util.Arrays.asList("select", "radio"));
|
||||
|
||||
/** `字段 op '字面量'` / `字段 op 数值`(op 含两侧空格容忍) */
|
||||
private static final java.util.regex.Pattern FIELD_STR_EQ = java.util.regex.Pattern.compile(
|
||||
"([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=)\\s*'([^']*)'");
|
||||
private static final java.util.regex.Pattern FIELD_NUM_CMP = java.util.regex.Pattern.compile(
|
||||
"([A-Za-z_][A-Za-z0-9_]*)\\s*(>=|<=|>|<)\\s*(-?\\d+(\\.\\d+)?)");
|
||||
private static final java.util.regex.Pattern NUM_FIELD_CMP = java.util.regex.Pattern.compile(
|
||||
"(-?\\d+(\\.\\d+)?)\\s*(>=|<=|>|<)\\s*([A-Za-z_][A-Za-z0-9_]*)");
|
||||
|
||||
/**
|
||||
* 类型桶查②红牌 + 域查③红牌(<b>启发式实现</b>:正则模式匹配字面量比较形态,非 AST——
|
||||
* v2 §4.7 保守边界:不执行表达式、不做隐式转换,只拦"数值比较 × 非数值字段"与
|
||||
* "枚举等值 × 字面量不在值域"两类高发错配;不命中的形态留给运行期既有挂起纪律)。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void validateExpressionTypesAndDomains(FlowDefModel model, Map<String, Object> snapshot,
|
||||
List<String> problems) {
|
||||
List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
|
||||
if (fields == null || fields.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Map<String, Object>> fieldByKey = new HashMap<>();
|
||||
for (Map<String, Object> f : fields) {
|
||||
Object k = f.get("key");
|
||||
if (k != null) {
|
||||
fieldByKey.put(String.valueOf(k), f);
|
||||
}
|
||||
}
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (n == null || !n.expressionJudge()) {
|
||||
continue;
|
||||
}
|
||||
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
|
||||
if (e.condition == null || e.condition.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String where = "节点「" + n.name + "」出口「" + e.name + "」";
|
||||
String expr = e.condition.trim();
|
||||
// ② 类型桶查:数值比较算子作用于非数值字段 → 红牌
|
||||
java.util.regex.Matcher m = FIELD_NUM_CMP.matcher(expr);
|
||||
while (m.find()) {
|
||||
checkNumericBucket(where, m.group(1), m.group(2), fieldByKey, problems);
|
||||
}
|
||||
m = NUM_FIELD_CMP.matcher(expr);
|
||||
while (m.find()) {
|
||||
checkNumericBucket(where, m.group(4), m.group(3), fieldByKey, problems);
|
||||
}
|
||||
// ② 类型桶查(字符串侧):文本字面量等值比较数值字段 → 红牌
|
||||
// ③ 域查:枚举字段等值比较的字面量 ∉ 快照 enum 值域 → 红牌
|
||||
m = FIELD_STR_EQ.matcher(expr);
|
||||
while (m.find()) {
|
||||
String key = m.group(1);
|
||||
String op = m.group(2);
|
||||
String literal = m.group(3);
|
||||
Map<String, Object> field = fieldByKey.get(key);
|
||||
if (field == null) {
|
||||
continue; // 内置变量或未知键:①已拦/不属本查
|
||||
}
|
||||
String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
|
||||
if (NUMERIC_TYPES.contains(type)) {
|
||||
problems.add(where + ":字段 " + key + " 是 number 类型,不能与字符串字面量 '"
|
||||
+ literal + "' 做 " + op + " 比较(类型桶查②)");
|
||||
} else if (ENUM_TYPES.contains(type) && "==".equals(op)) {
|
||||
List<String> enums = (List<String>) field.get("enum");
|
||||
if (enums != null && !enums.isEmpty() && !enums.contains(literal)) {
|
||||
problems.add(where + ":字段 " + key + " 的值域是 " + enums
|
||||
+ ",字面量 '" + literal + "' 不在其中(域查③)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkNumericBucket(String where, String key, String op,
|
||||
Map<String, Map<String, Object>> fieldByKey, List<String> problems) {
|
||||
Map<String, Object> field = fieldByKey.get(key);
|
||||
if (field == null) {
|
||||
return;
|
||||
}
|
||||
String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
|
||||
if (type != null && !NUMERIC_TYPES.contains(type)) {
|
||||
problems.add(where + ":字段 " + key + " 是 " + type + " 类型,不能做数值比较 "
|
||||
+ key + " " + op + " 数字(类型桶查②)");
|
||||
}
|
||||
}
|
||||
|
||||
/** 停用:不可新发起,存量跑完 */
|
||||
@Transactional
|
||||
public void offline(Long schId, Long id) {
|
||||
@@ -159,19 +429,121 @@ public class FlowDefAdminService {
|
||||
}
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
|
||||
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
|
||||
checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "启用");
|
||||
} else if (def.getFormVersion() != null) {
|
||||
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
|
||||
}
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setPublishTime(LocalDateTime.now());
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version, String operation) {
|
||||
if (version == null) {
|
||||
throw new BizException(FormBindingErrorCode.VERSION_REQUIRED, "关联表单缺少明确版本,"
|
||||
+ operation + "已阻断,请重新选择表单与版本");
|
||||
}
|
||||
if (version < 1 || uuid == null || uuid.trim().isEmpty()) {
|
||||
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE,
|
||||
"关联表单必须选择确切的正整数版本," + operation + "已阻断,请重新选择");
|
||||
}
|
||||
try {
|
||||
return formCenterSchemaClient.fetchSchema(schId, uuid, version);
|
||||
} catch (FormCenterSchemaClient.SchemaUnavailableException e) {
|
||||
throw new BizException(FormBindingErrorCode.SCHEMA_UNAVAILABLE,
|
||||
"关联表单快照拉取失败," + operation + "已阻断(可重试)", e);
|
||||
} catch (FormCenterSchemaClient.SchemaContractViolationException e) {
|
||||
throw new BizException(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION,
|
||||
"关联表单版本核验失败," + operation + "已阻断(请重新选择表单与版本)", e);
|
||||
} catch (FormCenterSchemaClient.SchemaResourceUnavailableException e) {
|
||||
throw new BizException(FormBindingErrorCode.RESOURCE_UNAVAILABLE,
|
||||
"关联表单或指定发布版本不存在或不可读," + operation + "已阻断", e);
|
||||
} catch (FormCenterSchemaClient.SchemaAccessDeniedException e) {
|
||||
throw new BizException(FormBindingErrorCode.ACCESS_DENIED,
|
||||
"关联表单访问身份或授权被拒绝," + operation + "已阻断,请检查授权", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String withoutFormContract(String contentJson) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
|
||||
objectMapper.readTree(contentJson);
|
||||
root.remove("formContract");
|
||||
return objectMapper.writeValueAsString(root);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义必须是有效的JSON对象");
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一流程线全部版本(降序) */
|
||||
public List<FlwDef> versions(Long schId, String flowGuid) {
|
||||
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid);
|
||||
}
|
||||
|
||||
/** 在途办件数(议题二④):flowGuid → 审批中(10)办件数;口径=def_id 精确匹配该线各版本;请求的 guid 全部有键(无在途=0) */
|
||||
public Map<String, Long> inflightCounts(Long schId, Collection<String> flowGuids) {
|
||||
Map<String, Long> result = new HashMap<>();
|
||||
if (flowGuids == null || flowGuids.isEmpty()) return result;
|
||||
flowGuids.forEach(g -> result.put(g, 0L));
|
||||
List<FlwDef> defs = defRepository.findBySchIdAndFlowGuidIn(schId, flowGuids);
|
||||
if (defs.isEmpty()) return result;
|
||||
Map<Long, String> idToGuid = new HashMap<>();
|
||||
defs.forEach(d -> idToGuid.put(d.getId(), d.getFlowGuid()));
|
||||
instanceRepository.countApprovingByDefIds(schId, idToGuid.keySet()).forEach(r -> {
|
||||
Long defId = ((Number) r[0]).longValue();
|
||||
Long cnt = ((Number) r[1]).longValue();
|
||||
result.merge(idToGuid.get(defId), cnt, Long::sum);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据原地编辑(2026-10-10 四议题·议题2,拍板 D4):已发布/停用/草稿均可只改 name/appKey
|
||||
* 而<b>不升版本</b>——结构改动仍走 save 的升版本路径,两者完全隔离(本方法不碰
|
||||
* code/flow_guid/version/status,不进升版本分支)。appKey 校验同 save 口径(显式传入须在白名单)。
|
||||
* content_json 顶层 name 同步更新(只动顶层字段不动结构,消除"行 name 与 JSON name 的脏窗口";
|
||||
* 亦修复 API 直调方经 parsed.name 覆盖行字段时读到旧名的口径)。
|
||||
* 并发说明:与升版本路径无共享可变行(升版本建新行),不加锁。
|
||||
*/
|
||||
@Transactional
|
||||
public FlwDef updateMeta(Long schId, Long id, String name, String appKey) {
|
||||
FlwDef def = requireDef(schId, id);
|
||||
if (name == null || name.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程名称不能为空");
|
||||
}
|
||||
String cleanName = name.trim();
|
||||
String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim();
|
||||
if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试");
|
||||
}
|
||||
def.setName(cleanName);
|
||||
def.setAppKey(cleanAppKey);
|
||||
def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName));
|
||||
try {
|
||||
return defRepository.save(def);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时编辑,请刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/** contentJson 顶层 name 同步(ObjectNode 保字段顺序;结构零改动),返回新 JSON 串 */
|
||||
private String syncTopLevelName(String contentJson, String name) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
|
||||
objectMapper.readTree(contentJson);
|
||||
root.put("name", name);
|
||||
return objectMapper.writeValueAsString(root);
|
||||
} catch (Exception e) {
|
||||
// contentJson 在 save 期已保证可解析;防御口径:解析失败不同步(行 name 已更新,JSON 维持原值)
|
||||
return contentJson;
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> enabledDeptKeys(Long schId) {
|
||||
return mapRepository.findEnabledBySchId(schId).stream()
|
||||
.map(FlwAssigneeMap::getDeptKey).collect(Collectors.toSet());
|
||||
// 2026-10-10 科室管理切源:发布校验白名单改查 flw_dept/flw_dept_member(拍板 D1)
|
||||
return deptAdminService.enabledDeptKeys(schId);
|
||||
}
|
||||
|
||||
/** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwDept;
|
||||
import com.yunhai.flowcenter.entity.FlwDeptMember;
|
||||
import com.yunhai.flowcenter.repository.FlwDeptMemberRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDeptRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 科室管理(2026-10-10 四议题·议题3,拍板 D1 选 B):"科室组+成员"模型取代单用户映射行。
|
||||
* deptKey=本地自增主键;不做存量迁移(旧 flw_assignee_map 的 deptKey=UserBase 组 id,切源后
|
||||
* 解析失败属预期,需在本科室管理重建后重新配置流程定义)。一键拉取保留为初始化语义
|
||||
* (source_group_id 幂等防重复建组,重复拉取=刷新组名与成员集)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowDeptAdminService {
|
||||
|
||||
private final FlwDeptRepository deptRepository;
|
||||
private final FlwDeptMemberRepository memberRepository;
|
||||
/** 复用旧映射服务的组树拉取(previewGroupDepts 口径:教师组容器子组=科室+成员)与用户检索 */
|
||||
private final FlowMapAdminService mapAdminService;
|
||||
|
||||
// ==================== 读模型(编辑器/校验共用) ====================
|
||||
|
||||
/** 科室列表(编辑器 ctx.depts 结构:deptKey=id 字符串;count=启用成员数,停用科室恒 0) */
|
||||
public List<DeptVo> list(Long schId) {
|
||||
List<DeptVo> out = new ArrayList<>();
|
||||
for (FlwDept dept : deptRepository.findBySchIdOrderByIdAsc(schId)) {
|
||||
DeptVo vo = new DeptVo();
|
||||
vo.setDeptKey(String.valueOf(dept.getId()));
|
||||
vo.setDeptName(dept.getDeptName());
|
||||
vo.setStatus(dept.getStatus());
|
||||
vo.setCount(dept.getStatus() == 0
|
||||
? (int) memberRepository.countByDeptIdAndStatus(dept.getId(), 0) : 0);
|
||||
out.add(vo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 发布校验白名单:启用且有 ≥1 启用成员的科室 deptKey 全集(替代旧 enabledDeptKeys) */
|
||||
public Set<String> enabledDeptKeys(Long schId) {
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (FlwDept dept : deptRepository.findBySchIdAndStatusOrderByIdAsc(schId, 0)) {
|
||||
if (memberRepository.countByDeptIdAndStatus(dept.getId(), 0) > 0) {
|
||||
keys.add(String.valueOf(dept.getId()));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** 办理人解析(AssigneeResolver/图示/轨迹共用,宽容口径):deptKey 不存在或科室停用返回
|
||||
* 空列表(旧 findEnabled 同语义——示意图/轨迹渲染遇已删科室不炸;强口径由调用方判空抛) */
|
||||
public List<FlwDeptMember> resolveEnabled(Long schId, String deptKey) {
|
||||
FlwDept dept = findDept(schId, deptKey);
|
||||
if (dept == null || dept.getStatus() != 0) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return memberRepository.findByDeptIdAndStatusOrderByIdAsc(dept.getId(), 0);
|
||||
}
|
||||
|
||||
/** 科室名(DeptLabelSupport 唯一出口:流程图科室视图 + 轨迹富化) */
|
||||
public String deptName(Long schId, String deptKey) {
|
||||
FlwDept dept = findDept(schId, deptKey);
|
||||
return dept == null ? null : dept.getDeptName();
|
||||
}
|
||||
|
||||
// ==================== 科室 CRUD ====================
|
||||
|
||||
@Transactional
|
||||
public FlwDept create(Long schId, String deptName) {
|
||||
String name = requireName(deptName);
|
||||
FlwDept dept = new FlwDept();
|
||||
dept.setSchId(schId);
|
||||
dept.setDeptName(name);
|
||||
dept.setStatus(0);
|
||||
return deptRepository.save(dept);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FlwDept rename(Long schId, Long id, String deptName) {
|
||||
FlwDept dept = requireDeptById(schId, id);
|
||||
dept.setDeptName(requireName(deptName));
|
||||
return deptRepository.save(dept);
|
||||
}
|
||||
|
||||
/** 停用=科室级(成员不动,恢复即回);停用科室解析不出办理人、发布校验拦新引用 */
|
||||
@Transactional
|
||||
public FlwDept setStatus(Long schId, Long id, boolean enabled) {
|
||||
FlwDept dept = requireDeptById(schId, id);
|
||||
dept.setStatus(enabled ? 0 : 1);
|
||||
return deptRepository.save(dept);
|
||||
}
|
||||
|
||||
/** 删除科室(级联物理删成员;被流程定义引用的 deptKey 将解析失败——发布校验/办理时会拦) */
|
||||
@Transactional
|
||||
public void delete(Long schId, Long id) {
|
||||
FlwDept dept = requireDeptById(schId, id);
|
||||
memberRepository.deleteByDeptId(dept.getId());
|
||||
deptRepository.delete(dept);
|
||||
}
|
||||
|
||||
// ==================== 成员维护 ====================
|
||||
|
||||
public List<FlwDeptMember> members(Long schId, Long deptId) {
|
||||
requireDeptById(schId, deptId);
|
||||
return memberRepository.findByDeptIdOrderByIdAsc(deptId);
|
||||
}
|
||||
|
||||
/** 批量加成员(姓名经 UserBase 补全,失败降级 #id;已存在跳过) */
|
||||
@Transactional
|
||||
public int memberAdd(Long schId, Long deptId, List<Integer> userIds) {
|
||||
FlwDept dept = requireDeptById(schId, deptId);
|
||||
int added = 0;
|
||||
for (Integer userId : userIds == null ? new ArrayList<Integer>() : userIds) {
|
||||
if (userId == null || memberRepository.findByDeptIdAndUserId(dept.getId(), userId).isPresent()) {
|
||||
continue;
|
||||
}
|
||||
FlwDeptMember m = new FlwDeptMember();
|
||||
m.setDeptId(dept.getId());
|
||||
m.setUserId(userId);
|
||||
m.setUserName(safeName(userId));
|
||||
m.setStatus(0);
|
||||
memberRepository.save(m);
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void memberRemove(Long schId, Long memberId) {
|
||||
FlwDeptMember m = memberRepository.findById(memberId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "成员行不存在:" + memberId));
|
||||
requireDeptById(schId, m.getDeptId());
|
||||
memberRepository.delete(m);
|
||||
}
|
||||
|
||||
// ==================== 一键拉取(初始化语义) ====================
|
||||
|
||||
/**
|
||||
* 拉取导入:按 {@link FlowMapAdminService#previewGroupDepts} 口径 upsert——
|
||||
* source_group_id 命中则刷新组名+成员集(删除不在拉取集内的旧成员),未命中新建+灌成员。
|
||||
*/
|
||||
@Transactional
|
||||
public GroupImportResultVo importGroups(Long schId) {
|
||||
List<FlowMapAdminService.GroupDeptVo> depts = mapAdminService.previewGroupDepts(schId);
|
||||
GroupImportResultVo result = new GroupImportResultVo();
|
||||
for (FlowMapAdminService.GroupDeptVo dept : depts) {
|
||||
Integer sourceGroupId = Integer.valueOf(dept.getDeptKey()); // 预览口径:deptKey=UserBase 组 id
|
||||
FlwDept row = deptRepository.findBySchIdAndSourceGroupId(schId, sourceGroupId).orElse(null);
|
||||
if (row == null) {
|
||||
row = new FlwDept();
|
||||
row.setSchId(schId);
|
||||
row.setSourceGroupId(sourceGroupId);
|
||||
row.setStatus(0);
|
||||
result.setCreated(result.getCreated() + 1);
|
||||
} else {
|
||||
result.setRefreshed(result.getRefreshed() + 1);
|
||||
}
|
||||
row.setDeptName(dept.getDeptName());
|
||||
row = deptRepository.save(row);
|
||||
// 成员集全量对齐(uk 幂等 + 删不在集内的旧成员)
|
||||
Set<Integer> pulled = new HashSet<>();
|
||||
for (FlowMapAdminService.MemberVo m : dept.getUsers()) {
|
||||
if (m.getUserId() == null) {
|
||||
continue;
|
||||
}
|
||||
pulled.add(m.getUserId());
|
||||
Optional<FlwDeptMember> existing = memberRepository.findByDeptIdAndUserId(row.getId(), m.getUserId());
|
||||
if (existing.isPresent()) {
|
||||
FlwDeptMember em = existing.get();
|
||||
em.setUserName(m.getUserName() == null ? "" : m.getUserName());
|
||||
em.setStatus(0);
|
||||
memberRepository.save(em);
|
||||
} else {
|
||||
FlwDeptMember nm = new FlwDeptMember();
|
||||
nm.setDeptId(row.getId());
|
||||
nm.setUserId(m.getUserId());
|
||||
nm.setUserName(m.getUserName() == null ? "" : m.getUserName());
|
||||
nm.setStatus(0);
|
||||
memberRepository.save(nm);
|
||||
result.setUsers(result.getUsers() + 1);
|
||||
}
|
||||
}
|
||||
for (FlwDeptMember old : memberRepository.findByDeptIdOrderByIdAsc(row.getId())) {
|
||||
if (!pulled.contains(old.getUserId())) {
|
||||
memberRepository.delete(old);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("[flow-center] 科室一键拉取:sch={} 拉取 {} 科室(新建 {} 刷新 {}),新增成员 {}",
|
||||
schId, depts.size(), result.getCreated(), result.getRefreshed(), result.getUsers());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 内部 ====================
|
||||
|
||||
private FlwDept requireDeptById(Long schId, Long id) {
|
||||
FlwDept dept = deptRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "科室不存在:" + id));
|
||||
if (!dept.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "科室不属于该学校");
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
private FlwDept requireDept(Long schId, String deptKey) {
|
||||
FlwDept dept = findDept(schId, deptKey);
|
||||
if (dept == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "科室不存在:deptKey=" + deptKey);
|
||||
}
|
||||
return dept;
|
||||
}
|
||||
|
||||
private FlwDept findDept(Long schId, String deptKey) {
|
||||
if (deptKey == null || deptKey.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return deptRepository.findBySchIdAndId(schId, Long.valueOf(deptKey.trim())).orElse(null);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireName(String deptName) {
|
||||
if (deptName == null || deptName.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "科室名称不能为空");
|
||||
}
|
||||
return deptName.trim();
|
||||
}
|
||||
|
||||
private String safeName(Integer userId) {
|
||||
try {
|
||||
return mapAdminService.lookupUser(userId).getName();
|
||||
} catch (Exception e) {
|
||||
return "#" + userId;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== VO ====================
|
||||
|
||||
/** 编辑器/管理页科室行(deptKey=自增 id 字符串,与 DeptOption 前端结构对齐) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class DeptVo {
|
||||
private String deptKey;
|
||||
private String deptName;
|
||||
private Integer status;
|
||||
private int count;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class GroupImportResultVo {
|
||||
private int created;
|
||||
private int refreshed;
|
||||
private int users;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwDeptMember;
|
||||
import com.yunhai.flowcenter.entity.FlwCc;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
@@ -10,7 +10,6 @@ import com.yunhai.flowcenter.entity.FlwRouteLog;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
@@ -53,7 +52,7 @@ public class FlowDiagramService {
|
||||
private static final String USERS_HINT_DEPT_VIEW = "指定办理人";
|
||||
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final FlowDeptAdminService deptAdminService;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlwCcRepository ccRepository;
|
||||
@@ -222,9 +221,9 @@ public class FlowDiagramService {
|
||||
vo.setAssigneeType(r.type);
|
||||
vo.setDeptKey(r.deptKey);
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(r.type)) {
|
||||
List<FlwAssigneeMap> maps = mapRepository.findEnabled(schId, r.deptKey);
|
||||
vo.setSubLabel(MODE_DEPT.equals(mode) ? "抄送科室 · " + maps.size() + "人"
|
||||
: "抄送 " + namesWithSuffix(memberNames(maps), new ArrayList<>()));
|
||||
List<FlwDeptMember> members = deptAdminService.resolveEnabled(schId, r.deptKey);
|
||||
vo.setSubLabel(MODE_DEPT.equals(mode) ? "抄送科室 · " + members.size() + "人"
|
||||
: "抄送 " + namesWithSuffix(memberNames(members), new ArrayList<>()));
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(r.type)) {
|
||||
vo.setSubLabel(MODE_DEPT.equals(mode) ? "抄送指定人员"
|
||||
: "抄送 " + namesWithSuffix(userNames(r.userIds), new ArrayList<>()));
|
||||
@@ -243,14 +242,14 @@ public class FlowDiagramService {
|
||||
vo.setAssigneeType(a.type);
|
||||
vo.setDeptKey(a.deptKey);
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
List<FlwAssigneeMap> maps = mapRepository.findEnabled(schId, a.deptKey);
|
||||
List<FlwDeptMember> members = deptAdminService.resolveEnabled(schId, a.deptKey);
|
||||
if (MODE_USER.equals(mode)) {
|
||||
vo.setSubLabel(namesWithSuffix(memberNames(maps), suffixes(n)));
|
||||
vo.setSubLabel(namesWithSuffix(memberNames(members), suffixes(n)));
|
||||
} else {
|
||||
// dept 视图与 auto 的 DEPT_KEY 自适应同文案:科室名 + 启用映射人数
|
||||
vo.setLabel(deptLabel(n, a, maps));
|
||||
// dept 视图与 auto 的 DEPT_KEY 自适应同文案:科室名 + 启用成员人数
|
||||
vo.setLabel(deptLabel(n, a, schId));
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.add("科室 · " + maps.size() + "人");
|
||||
parts.add("科室 · " + members.size() + "人");
|
||||
parts.addAll(suffixes(n));
|
||||
vo.setSubLabel(String.join(" · ", parts));
|
||||
}
|
||||
@@ -264,14 +263,14 @@ public class FlowDiagramService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 科室视图 label 兜底链:有映射行取 deptName(全空→deptKey);无映射行→节点名(再退 deptKey) */
|
||||
private String deptLabel(FlowDefModel.Node n, FlowDefModel.Assignee a, List<FlwAssigneeMap> maps) {
|
||||
/** 科室视图 label 兜底链:科室存在取 dept_name;科室不存在→节点名(再退 deptKey) */
|
||||
private String deptLabel(FlowDefModel.Node n, FlowDefModel.Assignee a, Long schId) {
|
||||
// 科室名取值与流程轨迹科室口径共用(DeptLabelSupport),只保留本视图特有的兜底链
|
||||
String deptName = DeptLabelSupport.mappedDeptName(maps);
|
||||
String deptName = DeptLabelSupport.deptName(deptAdminService, schId, a.deptKey);
|
||||
if (deptName != null) {
|
||||
return deptName;
|
||||
}
|
||||
return maps.isEmpty() ? firstNonBlank(n.name, a.deptKey) : firstNonBlank(a.deptKey, n.name);
|
||||
return firstNonBlank(n.name, a.deptKey);
|
||||
}
|
||||
|
||||
/** 人员名单(顿号连接)+ 会签/时限后缀(" · "分隔);名单与后缀均空则返回 null */
|
||||
@@ -308,9 +307,9 @@ public class FlowDiagramService {
|
||||
return parts;
|
||||
}
|
||||
|
||||
private List<String> memberNames(List<FlwAssigneeMap> maps) {
|
||||
private List<String> memberNames(List<FlwDeptMember> members) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (FlwAssigneeMap m : maps) {
|
||||
for (FlwDeptMember m : members) {
|
||||
names.add(blankToId(m.getUserName(), m.getUserId()));
|
||||
}
|
||||
return names;
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.yunhai.flowcenter.engine.FlowEvent;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
@@ -56,6 +57,12 @@ public class FlowInstanceService {
|
||||
public final Integer submitterId;
|
||||
public final String submitterName;
|
||||
public final String formDataJson;
|
||||
/** 调用方验签身份快照(yh.app.auth;本地/无签名场景为 null) */
|
||||
public final String applyAppKey;
|
||||
/** 调用方 app_code 快照(by-key 一次换得;UserBase 不可达为 null→消息回落发送方归属) */
|
||||
public final String applyAppCode;
|
||||
/** 发起时指派快照 JSON(oapi apply 校验后渲染;null=无指派) */
|
||||
public final String assignJson;
|
||||
}
|
||||
|
||||
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
|
||||
@@ -84,6 +91,9 @@ public class FlowInstanceService {
|
||||
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());
|
||||
@@ -97,6 +107,15 @@ public class FlowInstanceService {
|
||||
}
|
||||
|
||||
private void restart(FlwInstance instance, ApplyCommand cmd) {
|
||||
restart(instance, cmd, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* R4 重启推进(语义不变:定义刷新、快照覆盖、旧分支清空、status 回 10、version 自增、轨迹跨轮连续)。
|
||||
* 新增的两个参数只是<b>随行落库的请求标识与轮次</b>,不改任何节点算法(任务书 C.3 红线:
|
||||
* 不为修复幂等而重写流程节点算法)。旧 apply 路径传 null ⇒ 行为与历史逐字一致。
|
||||
*/
|
||||
private void restart(FlwInstance instance, ApplyCommand cmd, String requestToken, Integer round) {
|
||||
log.info("[flow-center] R4 重启推进:instance={} 原 status={} biz={}/{}",
|
||||
instance.getId(), instance.getStatus(), cmd.bizType, cmd.bizId);
|
||||
instance.setDefId(cmd.def.getId());
|
||||
@@ -106,8 +125,19 @@ public class FlowInstanceService {
|
||||
instance.setBizUrl(cmd.bizUrl);
|
||||
instance.setBizSnapshot(cmd.bizSnapshot);
|
||||
instance.setFormDataJson(cmd.formDataJson);
|
||||
instance.setAssignJson(cmd.assignJson);
|
||||
// 调用方身份快照跟随最新一次 apply 刷新(重启=新一轮代发起,调用方可能已变)
|
||||
instance.setApplyAppKey(cmd.applyAppKey);
|
||||
instance.setApplyAppCode(cmd.applyAppCode);
|
||||
instance.setSubmitterId(cmd.submitterId);
|
||||
instance.setSubmitterName(cmd.submitterName);
|
||||
// 新一轮才更新请求标识与轮次;同标识重投走不到这里(applyGuarded 已分支挡掉)
|
||||
if (requestToken != null) {
|
||||
instance.setApplyRequestToken(requestToken);
|
||||
}
|
||||
if (round != null) {
|
||||
instance.setApplyRound(round);
|
||||
}
|
||||
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
instance.setSubmitTime(LocalDateTime.now());
|
||||
instance.setFinishTime(null);
|
||||
@@ -254,4 +284,165 @@ public class FlowInstanceService {
|
||||
return duplicated;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 请求标识与轮次守卫(v2 §8,TASK_2 C.3) ====================
|
||||
|
||||
/**
|
||||
* 守卫参数。<b>刻意不做成 {@code ApplyCommand} 的新字段</b>:那个 13 参构造被基线集成测试
|
||||
* {@code FlowEngineIT} 逐位钉住,加参数会让整棵测试树编译失败(同 svc-hall 的 setter 注入先例)。
|
||||
* 旧 {@link #apply(ApplyCommand)} 路径行为逐字不变,只有 {@link #applyGuarded} 吃这个策略。
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public static class ApplyRetryPolicy {
|
||||
public final String requestToken;
|
||||
public final Integer attempt;
|
||||
public final boolean retryOnly;
|
||||
}
|
||||
|
||||
/** 守卫化结果:除 instanceId/duplicated 外,把"命中的是哪一种情形"落成稳定分支码 */
|
||||
@RequiredArgsConstructor
|
||||
public static class GuardedOutcome {
|
||||
public final Long instanceId;
|
||||
public final boolean duplicated;
|
||||
public final String branch;
|
||||
public final Integer applyRound;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带守卫的代发起(新 {@code /oapi/flow/apply/v2} 唯一入口)。
|
||||
*
|
||||
* <p>判定顺序(每一支都只回读现状,不改流程状态机一行算法):</p>
|
||||
* <ol>
|
||||
* <li><b>先核归属</b>:行上的调用应用/提交者/流程线与本次请求不符 ⇒ 拒绝,
|
||||
* <b>不能把别的应用相同 bizType 键当自己的结果</b>返回(C.3)。
|
||||
* 应用身份两侧都非空才判(本地无签名的历史行为 null,不因此把存量件判死);</li>
|
||||
* <li>同 {@code requestToken} 命中 ⇒ {@code SAME_REQUEST}:这是同一次命令的重投,
|
||||
* 即使行已终态也<b>不重启</b>——那正是"响应丢失后重发"与"用户重提"的分界;</li>
|
||||
* <li>10/20 命中 ⇒ {@code IDEMPOTENT}(既有语义不变);</li>
|
||||
* <li>30/40 命中:{@code retryOnly=true} ⇒ {@code REPLAY_ONLY} 不重启;
|
||||
* 请求轮次 ≤ 行轮次 ⇒ {@code STALE_ROUND} 不重启;
|
||||
* 否则才是新一轮授权重提 ⇒ {@code RESTARTED}(走既有 R4 重启推进);</li>
|
||||
* <li>无行 ⇒ 新建 + {@code CREATED}。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Transactional
|
||||
public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) {
|
||||
FlwInstance existing = instanceRepository
|
||||
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
|
||||
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;
|
||||
createInstance(cmd, token, round);
|
||||
return new GuardedOutcome(instanceOf(cmd), false, FlowApplyRetryModels.Branch.CREATED, round);
|
||||
}
|
||||
|
||||
private GuardedOutcome settleExisting(FlwInstance existing, ApplyCommand cmd, ApplyRetryPolicy policy) {
|
||||
requireSameOwner(existing, cmd);
|
||||
String token = policy == null ? null : trimToNull(policy.requestToken);
|
||||
// 行上轮次为 NULL(v2 之前建的存量件)按 1 处理:这样"没有更高轮次"就不能重启别人的旧件,
|
||||
// 新一轮授权重提(attempt≥2)才走得动 R4——存量行的守卫缺省是**关**而不是开。
|
||||
int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
|
||||
if (token != null && token.equals(existing.getApplyRequestToken())) {
|
||||
log.info("[flow-center] 同请求标识重投命中,只回读现状不重启:instance={} biz={}/{} status={}",
|
||||
existing.getId(), cmd.bizType, cmd.bizId, existing.getStatus());
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.SAME_REQUEST, existing.getApplyRound());
|
||||
}
|
||||
int status = existing.getStatus();
|
||||
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|
||||
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.IDEMPOTENT, existing.getApplyRound());
|
||||
}
|
||||
if (policy != null && policy.retryOnly) {
|
||||
// 恢复链的"查无才补发"落在已有行上:30/40 也绝不重启(C.2 点名)
|
||||
log.warn("[flow-center] retryOnly 命中终态办件,拒绝重启:instance={} status={}",
|
||||
existing.getId(), status);
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.REPLAY_ONLY, existing.getApplyRound());
|
||||
}
|
||||
int incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt;
|
||||
if (incomingRound <= currentRound) {
|
||||
log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}",
|
||||
existing.getId(), currentRound, incomingRound);
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
|
||||
}
|
||||
restart(existing, cmd, token, incomingRound);
|
||||
return new GuardedOutcome(existing.getId(), true,
|
||||
FlowApplyRetryModels.Branch.RESTARTED, incomingRound);
|
||||
}
|
||||
|
||||
/** 新建实例(与旧 apply 同一段代码形状:不复制第二份推进逻辑) */
|
||||
private void createInstance(ApplyCommand cmd, String token, Integer round) {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
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.setApplyRequestToken(token);
|
||||
instance.setApplyRound(round);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧 {@link #apply(ApplyCommand)} 的建件段复用(行为逐字不变,只是把 new 出来的那段抽出来)。
|
||||
* 这里不复用 createInstance 是因为旧路径不写 token/round(保持存量行 NULL,不伪装"有守卫")。
|
||||
*/
|
||||
private Long instanceOf(ApplyCommand cmd) {
|
||||
FlwInstance created = instanceRepository
|
||||
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
|
||||
return created == null ? null : created.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用
|
||||
* 相同 bizType 键当自己的结果")。两侧都非空才判——历史行没有 token/appKey 快照,
|
||||
* 不能因为"当年没记"就把存量件判成别人的。
|
||||
*/
|
||||
private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) {
|
||||
if (existing.getApplyAppKey() != null && cmd.applyAppKey != null
|
||||
&& !existing.getApplyAppKey().equals(cmd.applyAppKey)) {
|
||||
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 existingFlowGuid = defRepository.findById(existing.getDefId())
|
||||
.map(def -> def.getFlowGuid()).orElse(null);
|
||||
if (existingFlowGuid != null && cmd.def != null && cmd.def.getFlowGuid() != null
|
||||
&& !existingFlowGuid.equals(cmd.def.getFlowGuid())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"该业务键的办件绑定的流程线不同(行=" + existingFlowGuid + " 请求=" + cmd.def.getFlowGuid() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimToNull(String raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String value = raw.trim();
|
||||
return value.isEmpty() ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import com.yunhai.flowcenter.entity.FlwRouteLog;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
@@ -47,12 +46,13 @@ public class FlowQueryService {
|
||||
private final FlwCcRepository ccRepository;
|
||||
private final FlwRouteLogRepository routeLogRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final FlowDeptAdminService deptAdminService;
|
||||
private final FlowEngine flowEngine;
|
||||
private final BizUrlResolver bizUrlResolver;
|
||||
|
||||
public BasePageResult<FlowModels.InstanceItem> pageMy(Long schId, Integer submitterId,
|
||||
Integer status, Integer page, Integer size) {
|
||||
Page<FlwInstance> p = instanceRepository.pageMy(schId, submitterId, status,
|
||||
Integer status, String appKey, Integer page, Integer size) {
|
||||
Page<FlwInstance> p = instanceRepository.pageMy(schId, submitterId, status, emptyToNull(appKey),
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
return toResult(p.getTotalElements(), p.getContent().stream().map(this::toInstanceItem)
|
||||
.collect(Collectors.toList()), page, size);
|
||||
@@ -60,8 +60,8 @@ public class FlowQueryService {
|
||||
|
||||
/** 抄送我的(NOTIFY 落库行,sent_time 倒序;知会不占待办,独立于 todo/done 口径) */
|
||||
public BasePageResult<FlowModels.CcItem> pageCc(Long schId, Integer userId,
|
||||
Integer page, Integer size) {
|
||||
Page<FlwCc> p = ccRepository.findBySchIdAndUserIdOrderBySentTimeDesc(schId, userId,
|
||||
String appKey, Integer page, Integer size) {
|
||||
Page<FlwCc> p = ccRepository.pageCc(schId, userId, emptyToNull(appKey),
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
List<FlowModels.CcItem> items = p.getContent().stream()
|
||||
.map(this::toCcItem).collect(Collectors.toList());
|
||||
@@ -93,9 +93,9 @@ public class FlowQueryService {
|
||||
}
|
||||
}
|
||||
|
||||
public BasePageResult<FlowModels.TaskItem> pageTodo(Long schId, Integer assigneeId,
|
||||
public BasePageResult<FlowModels.TaskItem> pageTodo(Long schId, Integer assigneeId, String appKey,
|
||||
Integer page, Integer size) {
|
||||
Page<FlwTask> p = taskRepository.pageTodo(schId, assigneeId,
|
||||
Page<FlwTask> p = taskRepository.pageTodo(schId, assigneeId, emptyToNull(appKey),
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
List<FlowModels.TaskItem> items = p.getContent().stream()
|
||||
.map(this::toTaskItem).collect(Collectors.toList());
|
||||
@@ -104,10 +104,10 @@ public class FlowQueryService {
|
||||
}
|
||||
|
||||
/** 已办:一个办件一行(保留本人最后办理的那条),并回填累计办理次数 */
|
||||
public BasePageResult<FlowModels.TaskItem> pageDone(Long schId, Integer assigneeId,
|
||||
public BasePageResult<FlowModels.TaskItem> pageDone(Long schId, Integer assigneeId, String appKey,
|
||||
Integer page, Integer size) {
|
||||
List<Integer> handled = FlwEnums.TaskStatus.handledCodes();
|
||||
Page<FlwTask> p = taskRepository.pageDone(schId, assigneeId, handled,
|
||||
Page<FlwTask> p = taskRepository.pageDone(schId, assigneeId, handled, emptyToNull(appKey),
|
||||
PageRequest.of(normPage(page), normSize(size)));
|
||||
List<FlowModels.TaskItem> items = p.getContent().stream()
|
||||
.map(this::toTaskItem).collect(Collectors.toList());
|
||||
@@ -153,6 +153,8 @@ public class FlowQueryService {
|
||||
detail.setBizId(instance.getBizId());
|
||||
detail.setBizTitle(instance.getBizTitle());
|
||||
detail.setBizUrl(instance.getBizUrl());
|
||||
detail.setBizUrlAbs(bizUrlResolver.resolve(
|
||||
instance.getApplyAppCode(), instance.getApplyAppKey(), instance.getBizUrl()));
|
||||
detail.setBizSnapshot(instance.getBizSnapshot());
|
||||
detail.setFormDataJson(instance.getFormDataJson());
|
||||
detail.setStatus(instance.getStatus());
|
||||
@@ -305,7 +307,8 @@ public class FlowQueryService {
|
||||
rows.add(new TimedRow(r.getDecideTime(), 1, r.getId(), routeTraceRow(r)));
|
||||
}
|
||||
for (FlwCc c : ccs) {
|
||||
rows.add(new TimedRow(c.getSentTime(), 2, c.getId(), ccTraceRow(c)));
|
||||
rows.add(new TimedRow(c.getSentTime(), 2, c.getId(),
|
||||
ccTraceRow(c, model, instance, deptNames)));
|
||||
}
|
||||
rows.sort(Comparator.comparing((TimedRow r) -> r.time,
|
||||
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
@@ -356,8 +359,11 @@ public class FlowQueryService {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 抄送轨迹行(kind=CC):no taskId/opinion,接收人记在 assignee* 字段(前端复用人列) */
|
||||
private static Map<String, Object> ccTraceRow(FlwCc c) {
|
||||
/** 抄送轨迹行(kind=CC):no taskId/opinion,接收人记在 assignee* 字段(前端复用人列)。
|
||||
* 2026-10-12 补科室口径:抄送节点用 receivers,走 {@link #fillCcScope} 富化 assigneeType,
|
||||
* 前端 dept 档据此显科室名 / "抄送对象",不再回落接收人真实姓名。 */
|
||||
private Map<String, Object> ccTraceRow(FlwCc c, FlowDefModel model, FlwInstance instance,
|
||||
Map<String, String> deptNames) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("rowKey", "cc" + c.getId());
|
||||
row.put("ccId", c.getId());
|
||||
@@ -368,6 +374,7 @@ public class FlowQueryService {
|
||||
row.put("assigneeName", c.getUserName());
|
||||
row.put("status", null);
|
||||
row.put("time", str(c.getSentTime()));
|
||||
fillCcScope(row, model, c.getNodeId(), instance.getSchId(), deptNames);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -384,6 +391,9 @@ public class FlowQueryService {
|
||||
row.put("attempt", r.getAttempt());
|
||||
row.put("status", null);
|
||||
row.put("time", str(r.getDecideTime()));
|
||||
// 议题三②:调试弹窗「从路由日志导入」需要看失败摘要(>500 截断)
|
||||
row.put("rawResponse", r.getRawResponse() == null ? "" :
|
||||
(r.getRawResponse().length() > 500 ? r.getRawResponse().substring(0, 500) + "…" : r.getRawResponse()));
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -467,6 +477,25 @@ public class FlowQueryService {
|
||||
Long schId, Map<String, String> deptNames) {
|
||||
FlowDefModel.Node node = model == null ? null : model.node(nodeId);
|
||||
FlowDefModel.Assignee assignee = node == null ? null : node.assignee;
|
||||
applyAssigneeScope(row, assignee, schId, deptNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* 抄送行(kind=CC)科室口径富化(2026-10-12):NOTIFY 节点的接收人存在 {@code receivers}
|
||||
* 字段而非 {@code assignee},故不能直接复用 {@link #fillAssigneeScope}——否则取到的 assignee
|
||||
* 恒为 null,dept 档下抄送行只能回落接收人真实姓名(原先的泄露点)。
|
||||
* 节点在定义里找不到(重提后的老节点、已删节点)不填,前端统一显示"抄送对象"。
|
||||
*/
|
||||
private void fillCcScope(Map<String, Object> row, FlowDefModel model, String nodeId,
|
||||
Long schId, Map<String, String> deptNames) {
|
||||
FlowDefModel.Node node = model == null ? null : model.node(nodeId);
|
||||
FlowDefModel.Assignee receivers = node == null ? null : node.receivers;
|
||||
applyAssigneeScope(row, receivers, schId, deptNames);
|
||||
}
|
||||
|
||||
/** 两种口径的公共落值:assigneeType 必填;DEPT_KEY 另填 deptKey + 映射里的科室名 */
|
||||
private void applyAssigneeScope(Map<String, Object> row, FlowDefModel.Assignee assignee,
|
||||
Long schId, Map<String, String> deptNames) {
|
||||
if (assignee == null || assignee.type == null) {
|
||||
return;
|
||||
}
|
||||
@@ -476,7 +505,7 @@ public class FlowQueryService {
|
||||
}
|
||||
row.put("deptKey", assignee.deptKey);
|
||||
String deptName = deptNames.computeIfAbsent(assignee.deptKey,
|
||||
k -> DeptLabelSupport.mappedDeptName(mapRepository.findEnabled(schId, k)));
|
||||
k -> DeptLabelSupport.deptName(deptAdminService, schId, k));
|
||||
if (deptName != null) {
|
||||
row.put("deptName", deptName);
|
||||
}
|
||||
@@ -506,6 +535,7 @@ public class FlowQueryService {
|
||||
item.setBizType(instance.getBizType());
|
||||
item.setBizId(instance.getBizId());
|
||||
item.setInstanceStatus(instance.getStatus());
|
||||
item.setSubmitterName(instance.getSubmitterName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,6 +552,11 @@ public class FlowQueryService {
|
||||
return page == null || page < 1 ? 0 : page - 1;
|
||||
}
|
||||
|
||||
/** 空串按「不过滤」处理(前端清筛选可能传空值,避免误筛成空列表) */
|
||||
private static String emptyToNull(String s) {
|
||||
return s == null || s.trim().isEmpty() ? null : s.trim();
|
||||
}
|
||||
|
||||
private static int normSize(Integer size) {
|
||||
return size == null || size < 1 || size > 100 ? 10 : size;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwRouter;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -23,6 +25,7 @@ import java.util.List;
|
||||
* 条件路由管理(2026-09-24 §4.2/§4.4):flw_router 注册 CRUD(全局表,管理员维护)
|
||||
* + 挂起办件的重跑路由入口。发布校验引用本表(routerKey 白名单),停用注册会拦住新发布。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowRouterAdminService {
|
||||
@@ -30,6 +33,7 @@ public class FlowRouterAdminService {
|
||||
private final FlwRouterRepository routerRepository;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final com.yunhai.flowcenter.repository.FlwTaskRepository taskRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@@ -69,6 +73,75 @@ public class FlowRouterAdminService {
|
||||
return routerRepository.save(router);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自注册登记(2026-10-12 SERVICE 收口,由 yh-flow-callback starter 启动时调用)——<b>登记即持有</b>:
|
||||
* <ul>
|
||||
* <li>新键:建行,{@code path=judgePath}、{@code directHost} 非空(对端据此跳过服务目录三级解析
|
||||
* 直连),{@code serviceKey} 填归属 appKey(NOT NULL 列,管理端"来源"展示用),status=1;</li>
|
||||
* <li>已有行:{@code regAppKey} 非空且 ≠ 调用方 → 业务错拒绝(防跨应用覆盖);为空(管理端手工行)
|
||||
* → 首次自注册即认领。<b>status 不动</b>:停用状态不被自注册翻正,管理员保持控制权;</li>
|
||||
* <li>幂等:同 appKey 重复登记=更新地址,无 secret 轮换风险(与 callback 注册的差异)。</li>
|
||||
* </ul>
|
||||
* 管理端既有 CRUD({@link #save}/{@link #setStatus})不受影响——管理员仍可改一切。
|
||||
*/
|
||||
@Transactional
|
||||
public FlwRouter registerByApp(String appKey, OapiModels.RouterRegBody body) {
|
||||
if (isBlank(appKey)) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "判断服务登记需应用签名(appKey 为空)");
|
||||
}
|
||||
if (body == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "登记体不能为空");
|
||||
}
|
||||
String routerKey = requireLength(body.getRouterKey(), 64, "routerKey");
|
||||
String name = requireLength(body.getName(), 128, "name");
|
||||
String judgePath = body.getJudgePath() == null ? "" : body.getJudgePath().trim();
|
||||
if (!judgePath.startsWith("/")) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"judgePath 必须以 / 开头(如 /oapi/flow/route/judge)");
|
||||
}
|
||||
String directHost = body.getDirectHost() == null ? "" : body.getDirectHost().trim();
|
||||
if (!directHost.startsWith("http://") && !directHost.startsWith("https://")) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"directHost 必须是 http:// 或 https:// 开头的基址:" + directHost);
|
||||
}
|
||||
FlwRouter router = routerRepository.findByRouterKey(routerKey).orElse(null);
|
||||
if (router == null) {
|
||||
FlwRouter created = new FlwRouter();
|
||||
created.setRouterKey(routerKey);
|
||||
created.setName(name);
|
||||
created.setServiceKey(appKey);
|
||||
created.setPath(judgePath);
|
||||
created.setDirectHost(directHost);
|
||||
created.setRegAppKey(appKey);
|
||||
created.setStatus(1);
|
||||
log.info("[flow-center] 判断服务自注册新增:routerKey={} appKey={} host={} path={}",
|
||||
routerKey, appKey, directHost, judgePath);
|
||||
return routerRepository.save(created);
|
||||
}
|
||||
if (!isBlank(router.getRegAppKey()) && !router.getRegAppKey().equals(appKey)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"routerKey 已被应用 " + router.getRegAppKey() + " 登记:" + routerKey);
|
||||
}
|
||||
router.setName(name);
|
||||
router.setPath(judgePath);
|
||||
router.setDirectHost(directHost);
|
||||
router.setRegAppKey(appKey);
|
||||
log.info("[flow-center] 判断服务自注册更新:routerKey={} appKey={} host={} path={} status 保持不变={}",
|
||||
routerKey, appKey, directHost, judgePath, router.getStatus());
|
||||
return routerRepository.save(router);
|
||||
}
|
||||
|
||||
private static String requireLength(String raw, int max, String field) {
|
||||
String value = raw == null ? "" : raw.trim();
|
||||
if (value.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, field + " 必填");
|
||||
}
|
||||
if (value.length() > max) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, field + " 长度超 " + max + ":" + value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 启用/停用(停用拦新发布引用;存量在途办件重跑会失败挂起,注意时机) */
|
||||
@Transactional
|
||||
public FlwRouter setStatus(Long id, boolean enabled) {
|
||||
@@ -81,9 +154,23 @@ public class FlowRouterAdminService {
|
||||
/**
|
||||
* 重跑路由(挂起恢复唯一入口,§4.4):办件须在途且挂在 CONDITION 节点(currentNodes 含之、
|
||||
* 该节点无待办任务)。重新调判断,成功即续走推进;仍失败抛业务错(挂起不变,留痕已落新行)。
|
||||
* 默认按办件落库的 def 快照重跑(现行为逐字不变)。
|
||||
*/
|
||||
@Transactional
|
||||
public void reroute(Long schId, Long instanceId) {
|
||||
reroute(schId, instanceId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重跑路由(F5-4 僵尸逃生门):useLatestDef=true 时 def 改取该 flowGuid 当前已发布最高版本,
|
||||
* 办件 defId/defCode/defVersion 快照同步刷新(与 R4 重启同语义,只刷定义三列,不动业务快照/
|
||||
* 状态/时间),trace 落一行留痕(保留节点 id=reroute-def,nodeName 载明 v旧→v新)。
|
||||
* 锚定 128/129 僵尸案例:办件挂在 v1 的 CONDITION 节点、修好的表达式已发布为 v2,按 def 快照
|
||||
* 重跑永远打转——此开关让管理员一键切到 v2 续走。在途 CONDITION 节点在新定义不存在时
|
||||
* 按人类可读业务错拒绝(不猜测节点映射)。useLatestDef=false 现行为逐字不变。
|
||||
*/
|
||||
@Transactional
|
||||
public void reroute(Long schId, Long instanceId, boolean useLatestDef) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
|
||||
if (!instance.getSchId().equals(schId)) {
|
||||
@@ -96,15 +183,58 @@ public class FlowRouterAdminService {
|
||||
if (def == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失");
|
||||
}
|
||||
if (useLatestDef) {
|
||||
def = refreshToLatestPublishedDef(instance, def);
|
||||
}
|
||||
String conditionNode = findParkedCondition(instance, def);
|
||||
if (conditionNode == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, useLatestDef
|
||||
? "在途条件节点在最新已发布定义中不存在(节点已被改版移除/改型),无法按新定义重跑"
|
||||
: "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
|
||||
}
|
||||
flowEngine.rerouteCondition(instance, def, conditionNode);
|
||||
instanceRepository.save(instance);
|
||||
}
|
||||
|
||||
/** 取 flowGuid 当前已发布最高版本并刷新办件定义快照(仅三列;已是最新则原样返回不落留痕) */
|
||||
private FlwDef refreshToLatestPublishedDef(FlwInstance instance, FlwDef current) {
|
||||
FlwDef latest = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
instance.getSchId(), current.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElse(null);
|
||||
if (latest == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "该流程当前无已发布版本,无法按最新定义重跑");
|
||||
}
|
||||
if (latest.getId().equals(instance.getDefId())) {
|
||||
return latest;
|
||||
}
|
||||
instance.setDefId(latest.getId());
|
||||
instance.setDefCode(latest.getCode());
|
||||
instance.setDefVersion(latest.getVersion());
|
||||
appendRerouteDefTrace(instance, current.getVersion(), latest.getVersion());
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义切换留痕行(同 FlowInstanceService.appendTraceEvent 形态;彼方法私有且该类冻结,不共用):
|
||||
* status=6 + 保留节点 id,nodeName 载明版本迁移;不参与引擎任何判断(同 submit/withdraw 行)。
|
||||
*/
|
||||
private void appendRerouteDefTrace(FlwInstance instance, Integer fromVersion, Integer toVersion) {
|
||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||
com.yunhai.flowcenter.entity.FlwTask row = new com.yunhai.flowcenter.entity.FlwTask();
|
||||
row.setInstanceId(instance.getId());
|
||||
row.setSchId(instance.getSchId());
|
||||
row.setNodeId(FlwEnums.TaskStatus.NODE_REROUTE_DEF);
|
||||
row.setNodeName("按新版定义重跑(v" + fromVersion + "→v" + toVersion + ")");
|
||||
// assignee=0:管理端动作无自然办理人,落 0 不产生待办(同发起行脏数据兜底口径)
|
||||
row.setAssigneeId(0);
|
||||
row.setAssigneeName("管理端");
|
||||
row.setStatus(FlwEnums.TaskStatus.SUBMITTED.getCode());
|
||||
row.setArriveTime(now);
|
||||
row.setHandleTime(now);
|
||||
row.setUrgeCount(0);
|
||||
taskRepository.save(row);
|
||||
}
|
||||
|
||||
/** 在途节点里找 CONDITION(多个时取第一个;正常挂起只会有一个) */
|
||||
private String findParkedCondition(FlwInstance instance, FlwDef def) {
|
||||
String cur = instance.getCurrentNodes();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 表单中心目录客户端(v3/TASK_1):{@code GET {form-center}/oapi/form/forms},
|
||||
* 给流程编辑器右侧"关联表单"选择器供数。调用形态对齐 {@link FormCenterSchemaClient}
|
||||
* (命名目标 {@code form-center},身份=本应用 appKey,签名由 starter 拦截器注入)。
|
||||
*
|
||||
* <p>能出现在这里的表单<b>已经过中心侧的 read 授权</b>:没授权给本应用的表压根不在这个目录里,
|
||||
* 所以编辑器不需要"选了才知道没权限"。反过来,目录里给了 {@code version}=中心当前发布版,
|
||||
* 保存时按 {@code formUuid + formVersion} 成对落库,发布再按这组确切坐标核验一次
|
||||
* ({@link FormCenterSchemaClient#fetchSchema(Long, String, Integer)})。</p>
|
||||
*
|
||||
* <p>空 data 按"目录不可用"拒而不是回一个空列表——空列表在页面上长得像"这所学校没有可用表单",
|
||||
* 会把"授权没配"这件事藏起来。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FormCenterCatalogClient {
|
||||
|
||||
/** 服务目录 key(同 FormCenterSchemaClient,两级共用一个目标) */
|
||||
static final String TARGET = "form-center";
|
||||
static final String FORMS_PATH = "/oapi/form/forms";
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
|
||||
public FormCenterCatalogClient(YschOpenCall openCall) {
|
||||
this.openCall = openCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param schId CAS 会话派生的管理域(由 {@code FlowAdminGuard} 给,不接受前端传值)
|
||||
* @return {@code {items,total}};{@code total}=中心过滤后可读的总条数
|
||||
*/
|
||||
public CatalogPage listForms(Long schId, String appCode, String keyword, Integer page, Integer pageSize) {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
if (notBlank(appCode)) {
|
||||
query.put("appCode", appCode.trim());
|
||||
}
|
||||
if (notBlank(keyword)) {
|
||||
query.put("keyword", keyword.trim());
|
||||
}
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (pageSize != null) {
|
||||
query.put("pageSize", String.valueOf(pageSize));
|
||||
}
|
||||
try {
|
||||
CatalogPage data = openCall.get(TARGET, FORMS_PATH, query, CatalogPage.class);
|
||||
if (data == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单中心目录返回空,请稍后重试");
|
||||
}
|
||||
return data;
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[flow-center] 表单中心目录拉取失败 schId={} {}", schId, e.toString());
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单中心目录不可达(网络/授权未配置):" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean notBlank(String s) {
|
||||
return s != null && !s.trim().isEmpty();
|
||||
}
|
||||
|
||||
/** 目录页(形状=中心 GET /oapi/form/forms 的 data) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CatalogPage {
|
||||
private List<CatalogItem> items;
|
||||
private long total;
|
||||
}
|
||||
|
||||
/** 目录行:选中后按 formUuid + version 成对保存 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CatalogItem {
|
||||
private String formUuid;
|
||||
private String code;
|
||||
private String name;
|
||||
private String appCode;
|
||||
private Integer version;
|
||||
private List<String> audience;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.openapi.core.OpenApiException;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。<br>
|
||||
* GET {目录解析 form-center}/oapi/form/schema?schId=&uuid=&version= ——只取明确发布版本。
|
||||
* 保存、发布与启用共用精确坐标契约;无 UUID-only 回退。调用形态对齐 YschOpenCall:
|
||||
* 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。<br>
|
||||
* <b>只在管理期调用</b>(拉取失败=阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
|
||||
* audience 解析:<b>契约 v2 C5/G3 起是字符串数组</b>(breaking,T13 切换)——本客户端按数组读取,
|
||||
* 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FormCenterSchemaClient {
|
||||
|
||||
/** 服务目录 key(targets / services 覆盖优先,否则标准 appCode 网关 fallback) */
|
||||
static final String TARGET = "form-center";
|
||||
static final String SCHEMA_PATH = "/oapi/form/schema";
|
||||
/** 快照是发布期一次性动作,超时给足(对照 RouteJudgeClient 的 3s 运行期预算) */
|
||||
static final int TIMEOUT_MS = 10_000;
|
||||
static final int MAX_ATTEMPTS = 2;
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public FormCenterSchemaClient(YschOpenCall openCall, ObjectMapper objectMapper) {
|
||||
this.openCall = openCall;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** 快照不可用(网络/信封/形态异常);发布期转 BizException 阻断,可重试 */
|
||||
public static class SchemaUnavailableException extends RuntimeException {
|
||||
public SchemaUnavailableException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照<b>坐标与请求不符</b>(v3/TASK_1):请求 {@code uuid+v} 却拿回另一张表或另一个版本。
|
||||
*
|
||||
* <p>与 {@link SchemaUnavailableException} 分开,是因为重试对它没有意义——同一个请求重放同一个答案,
|
||||
* 真正要改的是关联(表单换校了、版本行被并了、中心某条分支回落到"当前发布版"了)。
|
||||
* 发布期一律阻断,且文案要点名"不符"而不是"拉取失败",否则运维会往网络方向查。</p>
|
||||
*/
|
||||
public static class SchemaContractViolationException extends RuntimeException {
|
||||
public SchemaContractViolationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** FC 将不存在、不可读、未发布的精确版本统一拒绝,不能靠文案区分资源存在性。 */
|
||||
public static class SchemaResourceUnavailableException extends RuntimeException {
|
||||
public SchemaResourceUnavailableException(Throwable cause) {
|
||||
super("表单或指定发布版本不存在或不可读", cause);
|
||||
}
|
||||
}
|
||||
|
||||
public static class SchemaAccessDeniedException extends RuntimeException {
|
||||
public SchemaAccessDeniedException(Throwable cause) {
|
||||
super("表单访问身份或授权被拒绝", cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布期快照的<b>确切版本</b>形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=},
|
||||
* 并把<b>返回坐标与请求坐标逐件核对</b>——不符即 {@link SchemaContractViolationException}(不重试)。
|
||||
*
|
||||
* fields 按分组扁平化,options 值域收进 enum;没有缺省当前版本的重载。
|
||||
*/
|
||||
public Map<String, Object> fetchSchema(Long schId, String formUuid, Integer version) {
|
||||
if (version == null || version < 1 || formUuid == null || formUuid.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("按确切版本取快照必须给 uuid 与正整数 version");
|
||||
}
|
||||
RuntimeException last = null;
|
||||
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("uuid", formUuid);
|
||||
query.put("version", String.valueOf(version));
|
||||
Map<String, Object> data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class);
|
||||
if (data == null) {
|
||||
throw new IllegalStateException("schema 返回空 data");
|
||||
}
|
||||
Map<String, Object> snapshot = toSnapshot(data);
|
||||
requireCoordinate(snapshot, formUuid, version);
|
||||
return snapshot;
|
||||
} catch (SchemaContractViolationException e) {
|
||||
throw e;
|
||||
} catch (DException e) {
|
||||
int code = e.getCode() == null ? -1 : e.getCode().getCode();
|
||||
if (code == ErrorCode.PARAM_ERROR.getCode()) {
|
||||
throw new SchemaResourceUnavailableException(e);
|
||||
}
|
||||
if (code == ErrorCode.FORBIDDEN.getCode() || code == ErrorCode.UNAUTHORIZED.getCode()) {
|
||||
throw new SchemaAccessDeniedException(e);
|
||||
}
|
||||
last = e;
|
||||
} catch (OpenApiException e) {
|
||||
if (e.getHttpStatus() == 404) {
|
||||
throw new SchemaResourceUnavailableException(e);
|
||||
}
|
||||
last = e;
|
||||
} catch (RuntimeException e) {
|
||||
last = e;
|
||||
log.warn("[flow-center] form-center schema 精确版本拉取失败(第 {}/{} 次):uuid={} v={} {}",
|
||||
attempt, MAX_ATTEMPTS, formUuid, version, e.toString());
|
||||
} catch (Exception e) {
|
||||
last = new IllegalStateException(e.toString(), e);
|
||||
log.warn("[flow-center] form-center schema 精确版本拉取异常(第 {}/{} 次):uuid={} v={} {}",
|
||||
attempt, MAX_ATTEMPTS, formUuid, version, e.toString());
|
||||
}
|
||||
}
|
||||
throw new SchemaUnavailableException(
|
||||
"关联表单 v" + version + " 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last);
|
||||
}
|
||||
|
||||
/** 返回的 formUuid/formVersion 必须与请求一致(快照嵌进 def 版本行后,这就是当时的契约坐标) */
|
||||
private static void requireCoordinate(Map<String, Object> snapshot, String formUuid, Integer version) {
|
||||
Object gotUuid = snapshot.get("formUuid");
|
||||
Object gotVersion = snapshot.get("formVersion");
|
||||
Integer parsedVersion;
|
||||
try {
|
||||
parsedVersion = gotVersion == null ? null : Integer.valueOf(String.valueOf(gotVersion));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new SchemaContractViolationException("表单中心返回的版本不是合法整数");
|
||||
}
|
||||
if (gotUuid == null || parsedVersion == null || parsedVersion < 1
|
||||
|| !String.valueOf(gotUuid).equals(formUuid)
|
||||
|| !parsedVersion.equals(version)) {
|
||||
throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 "
|
||||
+ formUuid + " v" + version + ",返回 " + gotUuid + " v" + gotVersion + ")");
|
||||
}
|
||||
}
|
||||
|
||||
/** SchemaView(data) → 快照节(fields 扁平化 + audience 数组归一) */
|
||||
private Map<String, Object> toSnapshot(Map<String, Object> data) {
|
||||
Map<String, Object> snap = new LinkedHashMap<>();
|
||||
snap.put("formUuid", data.get("formUuid"));
|
||||
snap.put("formVersion", data.get("version"));
|
||||
snap.put("audience", normalizeAudience(data.get("audience")));
|
||||
List<Map<String, Object>> fields = new ArrayList<>();
|
||||
JsonNode groups = data.get("groups") == null ? null
|
||||
: objectMapper.valueToTree(data.get("groups"));
|
||||
if (groups != null && groups.isArray()) {
|
||||
for (JsonNode group : groups) {
|
||||
JsonNode groupFields = group.get("fields");
|
||||
if (groupFields == null || !groupFields.isArray()) {
|
||||
continue;
|
||||
}
|
||||
for (JsonNode f : groupFields) {
|
||||
Map<String, Object> field = new LinkedHashMap<>();
|
||||
field.put("key", f.path("key").asText(null));
|
||||
field.put("type", f.path("type").asText(null));
|
||||
JsonNode required = f.get("required");
|
||||
field.put("required", required == null ? Boolean.FALSE : required.asBoolean(false));
|
||||
JsonNode options = f.get("options");
|
||||
if (options != null && options.isArray() && options.size() > 0) {
|
||||
List<String> enums = new ArrayList<>();
|
||||
for (JsonNode opt : options) {
|
||||
JsonNode v = opt.has("value") ? opt.get("value") : opt.get("label");
|
||||
if (v != null) {
|
||||
enums.add(v.asText());
|
||||
}
|
||||
}
|
||||
field.put("enum", enums);
|
||||
}
|
||||
fields.add(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
snap.put("fields", fields);
|
||||
return snap;
|
||||
}
|
||||
|
||||
/** audience 数组归一:数组照读;旧单值串包一层(迁移窗口容错);null → 空数组 */
|
||||
private List<String> normalizeAudience(Object audience) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (audience instanceof Iterable) {
|
||||
for (Object a : (Iterable<?>) audience) {
|
||||
if (a != null) {
|
||||
out.add(String.valueOf(a));
|
||||
}
|
||||
}
|
||||
} else if (audience != null) {
|
||||
out.add(String.valueOf(audience));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,12 @@ import com.yunhai.flowcenter.entity.FlwCc;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.engine.AssigneeRef;
|
||||
import com.yunhai.flowcenter.engine.AssigneeResolver;
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
@@ -22,6 +26,7 @@ import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -77,14 +82,35 @@ public class OapiFlowService {
|
||||
private final FlowInstanceService instanceService;
|
||||
private final FlowTaskService taskService;
|
||||
private final FlowEngine flowEngine;
|
||||
private final AssigneeResolver assigneeResolver;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final OpenApiUserClient userClient;
|
||||
private final com.yunhai.platform.openapi.base.OpenApiAppClient appClient;
|
||||
private final DiagramTokenService tokenService;
|
||||
|
||||
/** 本应用身份(回调探活补应用签名头;未装配时裸发,接收方 /oapi/* 会 401) */
|
||||
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
|
||||
appIdentityProvider;
|
||||
|
||||
/**
|
||||
* v2 发起的事务模板。<b>用 setter/字段注入而不是加进构造器</b>:本类构造被
|
||||
* {@code OapiFlowApplyDefVersionTest}/{@code OapiFlowEmbedTaskUrlTest} 逐位钉住,
|
||||
* 多一个参数就打不进来(同 svc-hall ServiceItemService 的 setter 先例)。
|
||||
* 未装配(老单测直接 new)时 {@link #applyV2} 回落到"直接调用",事务语义由调用方的
|
||||
* {@code @Transactional} 承担——那条路径只用于测试,不影响生产装配。
|
||||
*/
|
||||
private org.springframework.transaction.support.TransactionTemplate transactionTemplate;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
public void setTransactionTemplate(org.springframework.transaction.support.TransactionTemplate template) {
|
||||
this.transactionTemplate = template;
|
||||
}
|
||||
|
||||
private FlowInstanceService.GuardedOutcome inTransaction(
|
||||
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
|
||||
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
|
||||
}
|
||||
|
||||
/** 已发布流程列表(每条流程线取最高已发布版本) */
|
||||
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
|
||||
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
|
||||
@@ -101,9 +127,10 @@ public class OapiFlowService {
|
||||
.map(this::toSummary).orElse(null);
|
||||
}
|
||||
|
||||
/** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService) */
|
||||
/** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份,
|
||||
* 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */
|
||||
@Transactional
|
||||
public OapiModels.ApplyResultVo apply(Long schId, OapiModels.ApplyBody body) {
|
||||
public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) {
|
||||
if (schId == null || body.getFlowGuid() == null || body.getBizType() == null
|
||||
|| body.getBizId() == null || body.getSubmitterId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
@@ -114,12 +141,23 @@ public class OapiFlowService {
|
||||
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程不存在或未发布:" + body.getFlowGuid()));
|
||||
// S2.7 版本竞态(可选、opt-in):调用方回带预览时的定义版本,改版即人类可读拒绝,
|
||||
// 免得选好的 nodeId/候选人在新版本里对不上而报「指派节点不存在」。不传=现状逐字不变。
|
||||
if (body.getExpectedDefVersion() != null
|
||||
&& !body.getExpectedDefVersion().equals(def.getVersion())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
|
||||
+ ",当前已发布版本 " + def.getVersion() + ")");
|
||||
}
|
||||
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
|
||||
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||||
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
|
||||
schId, def,
|
||||
body.getBizType(), body.getBizId(), body.getBizTitle(),
|
||||
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
|
||||
body.getSubmitterId(), body.getSubmitterName(),
|
||||
asFormDataString(body.getFormData()));
|
||||
asFormDataString(body.getFormData()),
|
||||
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
|
||||
FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd);
|
||||
OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo();
|
||||
vo.setInstanceId(outcome.getInstanceId());
|
||||
@@ -127,6 +165,271 @@ public class OapiFlowService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带请求标识/轮次守卫的代发起({@code POST /oapi/flow/apply/v2},v2 §8,TASK_2 C.3)。
|
||||
*
|
||||
* <p>与 {@link #apply} 的三点不同,都是"幂等修复"要求而不是新功能:</p>
|
||||
* <ol>
|
||||
* <li><b>事务边界收窄到一次发起</b>:用 {@code TransactionTemplate} 而不是方法级
|
||||
* {@code @Transactional}——唯一键冲突必须在<b>事务外</b>重新读既有实例
|
||||
* (撞键后当前事务已被标记回滚,事务内再读不可靠),这与 svc-hall
|
||||
* {@code AnswerService#executeSubmit} 同一形态;</li>
|
||||
* <li><b>并发撞键后先核归属再返回</b>:读到的实例若属于另一应用/另一提交者/另一流程线,
|
||||
* 一律拒绝,<b>不把别人的 bizType+bizId 当自己的结果</b>;</li>
|
||||
* <li><b>回带稳定分支码</b>(CREATED/IDEMPOTENT/SAME_REQUEST/STALE_ROUND/REPLAY_ONLY/
|
||||
* RESTARTED/CONCURRENT_CREATED),消费方按码处置,不再猜"duplicated 是哪一种重复"。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>已发布定义校验、提交人本校校验与旧路径同一条,不放宽;流程节点算法一行未改。</p>
|
||||
*/
|
||||
public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey,
|
||||
FlowApplyRetryModels.ApplyRequestV2 body) {
|
||||
if (schId == null || body == null || body.getFlowGuid() == null || body.getBizType() == null
|
||||
|| body.getBizId() == null || body.getSubmitterId() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
|
||||
}
|
||||
requireSchoolMember(schId, body.getSubmitterId());
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程不存在或未发布:" + body.getFlowGuid()));
|
||||
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
|
||||
+ ",当前已发布版本 " + def.getVersion() + ")");
|
||||
}
|
||||
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
|
||||
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
|
||||
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
|
||||
schId, def,
|
||||
body.getBizType(), body.getBizId(), body.getBizTitle(),
|
||||
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
|
||||
body.getSubmitterId(), body.getSubmitterName(),
|
||||
asFormDataString(body.getFormData()),
|
||||
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
|
||||
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
|
||||
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome;
|
||||
try {
|
||||
outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy));
|
||||
} catch (DataIntegrityViolationException concurrent) {
|
||||
// 事务边界外重读:另一个并发请求已用同一业务键建好办件
|
||||
return fromConcurrent(schId, cmd, policy);
|
||||
}
|
||||
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) {
|
||||
vo.setStatus(current.getStatus());
|
||||
vo.setVersion(current.getVersion());
|
||||
} // 已被另一请求推进到新轮时,让消费方按实例查询恢复,不混装两轮证据。
|
||||
|
||||
});
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发撞键后的收口:重读既有实例 → <b>先核归属</b> → 同 token 记 {@code SAME_REQUEST},
|
||||
* 否则按幂等命中回读。归属不符就拒绝,绝不返回别人的办件 id。
|
||||
*/
|
||||
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
|
||||
FlowInstanceService.ApplyCommand cmd,
|
||||
FlowInstanceService.ApplyRetryPolicy policy) {
|
||||
FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId(
|
||||
schId, cmd.bizType, cmd.bizId).orElse(null);
|
||||
if (existing == null) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "办件创建冲突且读不到既有实例,请稍后重试");
|
||||
}
|
||||
if (existing.getApplyAppKey() != null && cmd.applyAppKey != null
|
||||
&& !existing.getApplyAppKey().equals(cmd.applyAppKey)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
|
||||
}
|
||||
if (existing.getSubmitterId() != null && cmd.submitterId != null
|
||||
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符");
|
||||
}
|
||||
String token = policy.requestToken == null ? null : policy.requestToken.trim();
|
||||
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
|
||||
vo.setInstanceId(existing.getId());
|
||||
vo.setDuplicated(true);
|
||||
vo.setApplyRound(existing.getApplyRound());
|
||||
vo.setStatus(existing.getStatus());
|
||||
vo.setVersion(existing.getVersion());
|
||||
vo.setBranch(token != null && token.equals(existing.getApplyRequestToken())
|
||||
? 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用方 app_key → app_code 一次转换(域间边界:验签域→路由域,只在 apply 做这一次)。
|
||||
* 查无/不可达降级 null——消息 jumpApp 空、归属回落发送方(现状行为),不阻断 apply;
|
||||
* jump_app 值域契约(=app_code)由本方法单点保证,替代历史上用 bizType 冒充的写法。
|
||||
*/
|
||||
private String resolveApplyAppCode(String appKey) {
|
||||
if (appKey == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
com.fasterxml.jackson.databind.JsonNode app = appClient.getAppByKey(appKey);
|
||||
String code = app == null ? null : app.path("appCode").asText(null);
|
||||
if (code == null || code.trim().isEmpty()) {
|
||||
log.warn("[flow-center] apply 调用方 app_code 解析为空(by-key 查无):appKey={},消息归属将回落发送方", appKey);
|
||||
return null;
|
||||
}
|
||||
return code.trim();
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] apply 调用方 app_code 解析失败(UserBase 不可达?):appKey={}:{}",
|
||||
appKey, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* apply 时指派严格校验(方案 §5.2 五条,不变量 I7)+ 快照渲染。
|
||||
* assigns 空 → null(宽松回退 D3)。任何不过 → BizException(PARAM_ERROR) 拒绝发起。
|
||||
*/
|
||||
private String renderAssignJson(Long schId, FlwDef def, List<OapiModels.AssignItem> assigns,
|
||||
Integer submitterId) {
|
||||
if (assigns == null || assigns.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>();
|
||||
Set<String> seenNodeIds = new LinkedHashSet<>();
|
||||
for (OapiModels.AssignItem item : assigns) {
|
||||
if (item.getNodeId() == null || seenNodeIds.contains(item.getNodeId())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"指派 nodeId 缺失或重复:" + item.getNodeId());
|
||||
}
|
||||
seenNodeIds.add(item.getNodeId());
|
||||
FlowDefModel.Node node = model.node(item.getNodeId());
|
||||
if (node == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "指派节点不存在:" + item.getNodeId());
|
||||
}
|
||||
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"节点未开启发起时指派:" + item.getNodeId());
|
||||
}
|
||||
if (item.getUserIds() == null || item.getUserIds().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"指派人员为空:" + item.getNodeId());
|
||||
}
|
||||
Set<Integer> uids = new LinkedHashSet<>(item.getUserIds());
|
||||
if (uids.contains(submitterId)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"不允许指派发起人本人:" + item.getNodeId());
|
||||
}
|
||||
// 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝
|
||||
List<AssigneeRef> candidates = assigneeResolver.resolve(schId, node);
|
||||
Set<Integer> candidateIds = new LinkedHashSet<>();
|
||||
for (AssigneeRef ref : candidates) {
|
||||
candidateIds.add(ref.getUserId());
|
||||
}
|
||||
for (Integer uid : uids) {
|
||||
if (!candidateIds.contains(uid)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"指派人员不在节点候选集内:node=" + item.getNodeId() + " userId=" + uid);
|
||||
}
|
||||
}
|
||||
boolean single = FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode);
|
||||
int n = uids.size();
|
||||
if (single && n != 1) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"单选节点须恰指派 1 人:" + item.getNodeId());
|
||||
}
|
||||
if (!single) {
|
||||
int min = node.assignPolicy.min == null ? 1 : node.assignPolicy.min;
|
||||
int max = node.assignPolicy.max == null ? candidates.size() : node.assignPolicy.max;
|
||||
if (n < min || n > max) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"指派人数越界[" + min + "~" + max + "]:" + item.getNodeId());
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Integer uid : uids) {
|
||||
String userName = assigneeResolver.requireUserName(uid);
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("userId", uid);
|
||||
row.put("userName", userName);
|
||||
rows.add(row);
|
||||
}
|
||||
snapshot.put(item.getNodeId(), rows);
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(snapshot);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "指派快照序列化失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起预览(指派候选,方案 §5.1):只返回开启指派节点;candidates 剔除 submitter;
|
||||
* 节点级解析异常降级空列表(I6)。
|
||||
*/
|
||||
public OapiModels.ApplyPreviewVo applyPreview(Long schId, String flowGuid, Integer submitterId) {
|
||||
if (schId == null || flowGuid == null || submitterId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId");
|
||||
}
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
|
||||
"流程不存在或未发布:" + flowGuid));
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo();
|
||||
vo.setFlowGuid(def.getFlowGuid());
|
||||
vo.setDefVersion(def.getVersion());
|
||||
List<OapiModels.PreviewNodeVo> nodes = new ArrayList<>();
|
||||
for (FlowDefModel.Node node : model.nodes) {
|
||||
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
|
||||
continue;
|
||||
}
|
||||
List<AssigneeRef> candidates;
|
||||
try {
|
||||
candidates = assigneeResolver.resolve(schId, node);
|
||||
} catch (BizException e) {
|
||||
log.warn("[flow-center] applyPreview 节点候选解析失败,降级空候选:flowGuid={} node={}:{}",
|
||||
flowGuid, node.id, e.getMessage());
|
||||
candidates = new ArrayList<>();
|
||||
}
|
||||
List<OapiModels.PreviewUserVo> users = new ArrayList<>();
|
||||
for (AssigneeRef ref : candidates) {
|
||||
if (submitterId.equals(ref.getUserId())) {
|
||||
continue;
|
||||
}
|
||||
OapiModels.PreviewUserVo u = new OapiModels.PreviewUserVo();
|
||||
u.setUserId(ref.getUserId());
|
||||
u.setUserName(ref.getUserName());
|
||||
users.add(u);
|
||||
}
|
||||
OapiModels.PreviewNodeVo nv = new OapiModels.PreviewNodeVo();
|
||||
nv.setNodeId(node.id);
|
||||
nv.setNodeName(node.name);
|
||||
nv.setCountersign(node.countersign == null ? "ALL" : node.countersign);
|
||||
nv.setCandidates(users);
|
||||
OapiModels.PreviewSelectionVo selection = new OapiModels.PreviewSelectionVo();
|
||||
selection.setMode(FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode)
|
||||
? FlowDefModel.ASSIGN_MODE_SINGLE : FlowDefModel.ASSIGN_MODE_MULTI);
|
||||
selection.setMin(node.assignPolicy.min == null ? 1 : node.assignPolicy.min);
|
||||
selection.setMax(node.assignPolicy.max == null ? users.size() : node.assignPolicy.max);
|
||||
selection.setRequired(node.assignPolicy.required == null
|
||||
? Boolean.FALSE : node.assignPolicy.required);
|
||||
nv.setSelection(selection);
|
||||
nodes.add(nv);
|
||||
}
|
||||
vo.setNodes(nodes);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */
|
||||
public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) {
|
||||
FlwInstance instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId)
|
||||
@@ -172,6 +475,79 @@ public class OapiFlowService {
|
||||
return toPageResult(p.getTotalElements(), items, page, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按业务单据查当前人待办(v2.1 应用内审批的业务详情卡片数据源):
|
||||
* 补齐 tasks/todo 缺的那一侧——todo 是"人 → 单据列表",本方法是"单据 → 我的待办",
|
||||
* 子应用据此判断"当前人是否该办件的待办人",不必再拉全量待办在前端过滤。
|
||||
* <p>治理口径:
|
||||
* <ul>
|
||||
* <li>只返回 {@code assigneeId=operatorId} 且 status=PENDING 的任务;他人待办与"办件不存在"
|
||||
* 一律出空数组(HTTP 200),出参不区分二者,避免用本端点探测别人的办件;</li>
|
||||
* <li>租户收敛在定位查询本身({@code findBySchIdAndBizTypeAndBizId}),bizType 不匹配即空;</li>
|
||||
* <li>{@code outlets} 只为当前人的任务计算,且出边 >1 才透出(与
|
||||
* {@code FlowQueryService.detail} 的办理面板口径一致——单出口无需选择,不给前端多余分支);</li>
|
||||
* <li>不透出他人办理人、意见或定义内部 id;taskId 是办理必需的运行时身份,保留。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public OapiModels.ByBizTasksVo tasksByBiz(Long schId, String bizType, String bizId, Integer operatorId) {
|
||||
if (schId == null || schId <= 0 || operatorId == null || operatorId <= 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/operatorId(均须为正)");
|
||||
}
|
||||
String cleanBizType = emptyToNull(bizType);
|
||||
String cleanBizId = emptyToNull(bizId);
|
||||
if (cleanBizType == null || cleanBizId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:bizType/bizId");
|
||||
}
|
||||
OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo();
|
||||
vo.setBizType(cleanBizType);
|
||||
vo.setBizId(cleanBizId);
|
||||
FlwInstance instance = instanceRepository
|
||||
.findBySchIdAndBizTypeAndBizId(schId, cleanBizType, cleanBizId).orElse(null);
|
||||
if (instance == null) {
|
||||
return vo;
|
||||
}
|
||||
vo.setInstanceId(instance.getId());
|
||||
vo.setStatus(instance.getStatus());
|
||||
vo.setCurrentNodes(instance.getCurrentNodes());
|
||||
List<FlwTask> mine = taskRepository
|
||||
.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode()).stream()
|
||||
.filter(t -> operatorId.equals(t.getAssigneeId()))
|
||||
.sorted(java.util.Comparator.comparing(FlwTask::getId))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
if (mine.isEmpty()) {
|
||||
return vo;
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
FlowDefModel model = def == null ? null : flowEngine.parseModel(def);
|
||||
for (FlwTask t : mine) {
|
||||
vo.getTasks().add(toByBizTaskVo(t, model));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private OapiModels.ByBizTaskVo toByBizTaskVo(FlwTask t, FlowDefModel model) {
|
||||
OapiModels.ByBizTaskVo vo = new OapiModels.ByBizTaskVo();
|
||||
vo.setTaskId(t.getId());
|
||||
vo.setNodeId(t.getNodeId());
|
||||
vo.setNodeName(t.getNodeName());
|
||||
vo.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString());
|
||||
if (model == null) {
|
||||
return vo;
|
||||
}
|
||||
List<FlowDefModel.Edge> outs = model.outEdges(t.getNodeId());
|
||||
if (outs.size() > 1) {
|
||||
for (FlowDefModel.Edge e : outs) {
|
||||
FlowDefModel.Node to = model.node(e.to);
|
||||
OapiModels.ByBizOutletVo outlet = new OapiModels.ByBizOutletVo();
|
||||
outlet.setName(e.name);
|
||||
outlet.setToNodeId(e.to);
|
||||
outlet.setToNodeName(to == null || to.name == null || to.name.isEmpty() ? e.to : to.name);
|
||||
vo.getOutlets().add(outlet);
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同意(oapi,应用内审批):operatorId 由调用方应用按其登录态传入,本端按任务办理人
|
||||
* 复核({@code task.assigneeId == operatorId},不满足→业务码拒绝,应用签名不替代"谁在办")。
|
||||
@@ -336,13 +712,39 @@ public class OapiFlowService {
|
||||
* 办件进度嵌入页换址(token 模式,与换图同构):同一 token 两处消费——嵌入页数据
|
||||
* /open/flow/embed/data 与流程图 /open/flow/diagram/img 均免签认领该 token。定位二选一:
|
||||
* instanceId(与办件直接复核)/ bizType+bizId(对账口径,办件不存在与 progress 同文案)。
|
||||
* 用户级可见性(本人/办理人/管理员)由子应用后端在调本端点前完成——oapi 层只认 schId。
|
||||
* 用户级可见性(本人/办理人/管理员/该办件抄送接收人)由子应用后端在调本端点前完成——oapi 层只认 schId。
|
||||
* 2026-10-17 F7 前置补丁后,中心用户面口径({@link FlowAccessGuard#requireInstanceVisible})已含抄送
|
||||
* 接收人;签发侧必须与之一致,否则"抄送我的"点开得、进度嵌入页换址却拒发(2026-10-17 记)。
|
||||
*/
|
||||
public OapiModels.EmbedUrlVo embedUrl(Long schId, Long instanceId, String bizType, String bizId, String mode) {
|
||||
return embedUrl(schId, instanceId, bizType, bizId, mode, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* mode=task(F9)直跳中心任务办理页:pageUrl = front-url + {@code #/task/{taskId}},
|
||||
* 无 token(CAS 会话鉴权页,办理权由该页加载时的用户面接口按 assigneeId 复核)——
|
||||
* 子应用"在自家页放办理入口"场景用;taskId 必填,缺则 PARAM_ERROR。
|
||||
*/
|
||||
public OapiModels.EmbedUrlVo embedUrl(Long schId, Long instanceId, String bizType, String bizId,
|
||||
String mode, Long taskId) {
|
||||
// 配置缺失优先于办件定位:fail-fast 纪律要的是"无论如何都不发打不开的地址"
|
||||
if (embedFrontUrl == null || embedFrontUrl.trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "嵌入页地址未配置:flow-center.embed.front-url");
|
||||
}
|
||||
String base = embedFrontUrl.trim();
|
||||
if (!base.endsWith("/")) {
|
||||
base = base + "/";
|
||||
}
|
||||
String normMode = mode == null || mode.trim().isEmpty() ? "auto" : mode.trim();
|
||||
if ("task".equals(normMode)) {
|
||||
if (taskId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "mode=task 需要 taskId(任务办理页定位)");
|
||||
}
|
||||
OapiModels.EmbedUrlVo out = new OapiModels.EmbedUrlVo();
|
||||
out.setPageUrl(base + "#/task/" + taskId);
|
||||
out.setExpiresIn(0);
|
||||
return out;
|
||||
}
|
||||
Long resolvedInstanceId;
|
||||
if (instanceId != null) {
|
||||
resolvedInstanceId = requireInstance(schId, instanceId).getId();
|
||||
@@ -357,12 +759,7 @@ public class OapiFlowService {
|
||||
resolvedInstanceId = vo.getInstanceId();
|
||||
}
|
||||
// mode 与 DiagramTokenService 内部缺省同值("auto"),URL 里显式带上仅为前端展示冗余
|
||||
String normMode = mode == null || mode.trim().isEmpty() ? "auto" : mode.trim();
|
||||
String token = tokenService.issue(schId, resolvedInstanceId, null, normMode);
|
||||
String base = embedFrontUrl.trim();
|
||||
if (!base.endsWith("/")) {
|
||||
base = base + "/";
|
||||
}
|
||||
OapiModels.EmbedUrlVo out = new OapiModels.EmbedUrlVo();
|
||||
out.setPageUrl(base + "#/embed/instance?token=" + token + "&mode=" + normMode);
|
||||
out.setExpiresIn(DiagramTokenService.TTL_MS / 1000);
|
||||
@@ -726,6 +1123,8 @@ public class OapiFlowService {
|
||||
public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) {
|
||||
OapiModels.InstanceVo vo = new OapiModels.InstanceVo();
|
||||
vo.setInstanceId(instance.getId());
|
||||
vo.setApplyRound(instance.getApplyRound());
|
||||
vo.setRequestToken(instance.getApplyRequestToken());
|
||||
vo.setStatus(instance.getStatus());
|
||||
vo.setVersion(instance.getVersion());
|
||||
vo.setBizTitle(instance.getBizTitle());
|
||||
|
||||
@@ -74,7 +74,8 @@ yh:
|
||||
# 绝不能写进本文件:yh.open.services.{key} 是目录第 2 级,平台注入的 base-host 只喂第 3 级兜底、
|
||||
# 压不过它 —— 写进 jar 会让部署实例去连容器内的 localhost:8060(2026-09-18 事故:
|
||||
# Connection refused → 所有 /api/* 报 code=6 内部服务错误)。
|
||||
base-host: http://localhost:8060
|
||||
# 统一内部网关门地址;本地 UserBase 直连使用显式 services.userbase 覆盖。
|
||||
base-host: ${YH_OPEN_BASE_HOST:http://open-gateway-service:10013}
|
||||
base-path: ""
|
||||
# 服务目录(子应用配置优化方案 §4.2):只在需要覆盖 PlatformService 枚举默认 ctx 时按 key 配置;
|
||||
# prod 形态(网关域名 + 默认 ctx)下四域零配置。本地联调所需的 userbase 覆盖见上方环境变量。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 2026-10-10 四议题·应用设置:流程对接应用白名单表
|
||||
-- ddl-auto=update 会自动建表;本脚本为生产备案(生产建议停自动 DDL 后手工执行)
|
||||
CREATE TABLE IF NOT EXISTS `flw_app` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`sch_id` BIGINT NOT NULL COMMENT '租户键',
|
||||
`app_key` VARCHAR(64) NOT NULL COMMENT '子应用 appKey(UserBase application_info.app_key 字符串形态)',
|
||||
`app_name` VARCHAR(128) NOT NULL COMMENT '应用名快照(学校池返回值,含学校别名;同步刷新)',
|
||||
`enabled` INT NOT NULL DEFAULT 1 COMMENT '1=对接流程(白名单);0=停用(defSave 校验拦截)',
|
||||
`sort_no` INT NULL COMMENT '展示排序(学校池 orderNumber 快照)',
|
||||
`create_time` DATETIME(6) NULL,
|
||||
`update_time` DATETIME(6) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_app_sch_key` (`sch_id`, `app_key`),
|
||||
KEY `idx_flw_app_sch_enabled` (`sch_id`, `enabled`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '流程对接应用白名单(应用设置)';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 2026-10-10 四议题·科室管理:科室组+成员模型(取代 flw_assignee_map 单用户映射行)
|
||||
-- ddl-auto=update 会自动建表;本脚本为生产备案。注意:deptKey=flw_dept.id 自增(拍板 D1 本地自增,
|
||||
-- 不做存量迁移——旧映射 deptKey=UserBase 组 id 的流程定义需在科室管理重建后重新配置)。
|
||||
CREATE TABLE IF NOT EXISTS `flw_dept` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`sch_id` BIGINT NOT NULL COMMENT '租户键',
|
||||
`dept_name` VARCHAR(128) NOT NULL COMMENT '科室名',
|
||||
`status` INT NOT NULL DEFAULT 0 COMMENT '0=启用 / 1=停用',
|
||||
`source_group_id` INT NULL COMMENT '来源 UserBase 组 id(一键拉取幂等;手工建组 NULL,uk 对 NULL 不生效天然多行)',
|
||||
`create_time` DATETIME(6) NULL,
|
||||
`update_time` DATETIME(6) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_dept_sch_srcgroup` (`sch_id`, `source_group_id`),
|
||||
KEY `idx_flw_dept_sch_status` (`sch_id`, `status`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '科室(应用设置-科室管理)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `flw_dept_member` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`dept_id` BIGINT NOT NULL COMMENT '所属科室 flw_dept.id',
|
||||
`user_id` INT NOT NULL,
|
||||
`user_name` VARCHAR(128) NOT NULL COMMENT '姓名快照(拉取/添加时落)',
|
||||
`status` INT NOT NULL DEFAULT 0 COMMENT '0=启用 / 1=停用',
|
||||
`create_time` DATETIME(6) NULL,
|
||||
`update_time` DATETIME(6) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_dept_member` (`dept_id`, `user_id`),
|
||||
KEY `idx_flw_dept_member_dept` (`dept_id`, `status`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '科室成员';
|
||||
@@ -0,0 +1,56 @@
|
||||
-- =====================================================================
|
||||
-- flow-center 四议题(2026-10-10):UserBase app_module 变更
|
||||
-- ① 新增 107「应用设置」(/admin/app-setting,应用管理 + 科室管理两个 tab)
|
||||
-- ② 下线 104「办理人映射」(/admin/maps 由 应用设置-科室管理 承载,Part E 上线时执行 ②段)
|
||||
-- 纪律:AI 不代跑,dev / prod 由人工执行;MySQL 5.7 兼容;幂等可重复执行。
|
||||
-- 前置:flow-center 前端(含 AppSetting.vue 路由)已发布,否则菜单点进去 404。
|
||||
-- =====================================================================
|
||||
|
||||
-- 步骤 0:校验身份链(app_module.app_id 存的是 application_info 主键,非 app_key)
|
||||
SELECT id AS app_id_pk, app_key, name
|
||||
FROM application_info
|
||||
WHERE id = 100011 OR app_key = 100019;
|
||||
-- 期望同一行(id=100011 且 app_key=100019);不同则把 @app_id 改成 app_key=100019 那行的 id。
|
||||
|
||||
SET @app_id = 100011;
|
||||
SET @operator = 0;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- ① 新增 107 应用设置(幂等:不存在才插;path 与 routes.ts 逐字一致)
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO app_module
|
||||
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time)
|
||||
SELECT @app_id, NULL, '应用设置', '107', '/admin/app-setting', NULL, 1, 107, @operator, NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM app_module m WHERE m.app_id = @app_id AND m.custom_id = '107'
|
||||
);
|
||||
|
||||
-- ①授权(角色路径,@role_id 换成实际选中的启用角色;见 flow-center接入UserBase菜单.sql §3.1 查询)
|
||||
SET @role_id = 0; -- ← 改这里
|
||||
INSERT INTO sys_app_role_module_rel (role_id, module_id, create_user, create_time)
|
||||
SELECT @role_id, m.id, @operator, NOW()
|
||||
FROM app_module m
|
||||
WHERE m.app_id = @app_id AND m.custom_id = '107'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_app_role_module_rel r
|
||||
WHERE r.role_id = @role_id AND r.module_id = m.id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- ② 下线 104 办理人映射(Part E 科室管理上线时执行;先删授权再删模块行)
|
||||
-- 如需保留回滚能力,可改为 UPDATE app_module SET sort_no = 9999 隐藏(菜单仍下发)。
|
||||
-- ---------------------------------------------------------------------
|
||||
-- DELETE r FROM sys_app_role_module_rel r
|
||||
-- JOIN app_module m ON m.id = r.module_id
|
||||
-- WHERE m.app_id = @app_id AND m.custom_id = '104';
|
||||
-- DELETE p FROM sys_group_module_perm p
|
||||
-- JOIN app_module m ON m.id = p.module_id
|
||||
-- WHERE m.app_id = @app_id AND m.custom_id = '104';
|
||||
-- DELETE m FROM app_module m
|
||||
-- WHERE m.app_id = @app_id AND m.custom_id = '104';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 验收
|
||||
-- ---------------------------------------------------------------------
|
||||
SELECT id, app_id, custom_id, name, path, level, sort_no
|
||||
FROM app_module
|
||||
WHERE app_id = @app_id
|
||||
ORDER BY CAST(custom_id AS UNSIGNED);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 2026-10-12 议题二:升版本改动说明(版本行即说明;独立版本日志表留 P2)
|
||||
-- ddl-auto=update 会自动加列;本脚本为生产备案(生产建议停自动 DDL 后手工执行)
|
||||
ALTER TABLE `flw_def`
|
||||
ADD COLUMN `change_log` VARCHAR(500) NULL COMMENT '升版本改动说明(可跳过;NULL=未填写)' AFTER `name`;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 2026-10-12 条件路由 SERVICE 收口:starter 启动自注册所需两列
|
||||
-- ddl-auto=update 会自动加列;本脚本为生产备案(生产建议停自动 DDL 后手工执行)
|
||||
-- direct_host 非空的行由 RouteJudgeClient 直连(跳过服务目录三级解析);存量手工行留 NULL 走原路
|
||||
ALTER TABLE `flw_router`
|
||||
ADD COLUMN `direct_host` VARCHAR(255) NULL COMMENT '自注册直连基址 scheme://authority[/ctx];NULL=按服务目录寻址(存量手工行)' AFTER `base_path`,
|
||||
ADD COLUMN `reg_app_key` VARCHAR(64) NULL COMMENT '登记应用 appKey(自注册归属,upsert 防跨应用覆盖);NULL=管理端手工行' AFTER `direct_host`;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- ============================================================
|
||||
-- 流程中心 · 代发起请求标识与轮次 DDL(MySQL 5.7 兼容复核版,TASK_2 C.3)
|
||||
-- 范围:《answer-delivery-v2.md》§8 = flw_instance 加两列 apply_request_token / apply_round
|
||||
-- 依据:任务书 TASK_2 C.2/C.3——"补充 FL 请求标识/当前轮次守卫时,SH 发送与 FL 校验同任务完成"
|
||||
-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_flowcenter,ddl-auto=update)由
|
||||
-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威。
|
||||
-- 这两列解决什么:biz uk(sch_id,biz_type,biz_id) 只说得出"这个业务键已经有办件了",
|
||||
-- 说不出"这次 apply 是同一次命令的重投,还是新一轮授权重提"。缺这句时,消费方
|
||||
-- 在响应丢失后按同业务键补发,命中 30/40 会被 R4 语义重启推进——等于把网络重试
|
||||
-- 当成用户重提,把已被拒绝/已撤回的办件又跑起来(任务书 C.2 点名禁止)。
|
||||
-- 存量口径(关键):**不回填、留 NULL**。apply_round 为 NULL 的行在新守卫里按"第 1 轮"处理,
|
||||
-- 于是"请求轮次 ≤ 行轮次 ⇒ 陈旧轮次不重启"对存量件天然成立——存量行的守卫缺省是
|
||||
-- <b>关</b>而不是开:想让老件走新一轮,必须由消费方显式带 attempt≥2(用户授权重提)。
|
||||
-- 回填成 0 会反过来让任何 attempt=1 的重投都能重启存量件,那是最容易埋雷的写法。
|
||||
-- 索引:不加 token 索引——守卫判据是"读到的那一行自己的列比对",没有按 token 反查的路径;
|
||||
-- 建索引只会给写入加成本,换不到任何读收益(评审时别问"为什么不索引")。
|
||||
-- 非幂等:执行前先 `SHOW COLUMNS FROM flw_instance LIKE 'apply_request_token'` 确认列不存在。
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `flw_instance`
|
||||
ADD COLUMN `apply_request_token` VARCHAR(64) DEFAULT NULL
|
||||
COMMENT '最近一次受理代发起的请求标识(消费方持久化命令携带);同值重投=同一命令,只回读现状不重启推进',
|
||||
ADD COLUMN `apply_round` INT DEFAULT NULL
|
||||
COMMENT '当前发起轮次(1 起、单调);NULL=本列上线前的存量行,守卫按第 1 轮处理(缺省不放开重启)';
|
||||
|
||||
-- 自验(执行后逐条核对):
|
||||
-- SHOW COLUMNS FROM flw_instance LIKE 'apply%'; -- 应只有 apply_app_key/apply_app_code/
|
||||
-- -- apply_request_token/apply_round 四列
|
||||
-- SELECT COUNT(*) FROM flw_instance WHERE apply_round IS NOT NULL; -- 刚执行完必须 0(不回填)
|
||||
-- SELECT COUNT(*) FROM flw_instance WHERE apply_round = 0; -- 必须 0(0 会打开存量重启窗口)
|
||||
@@ -0,0 +1,12 @@
|
||||
-- flw_def 增加 form_version(与 form_uuid 成对的"确切版本"坐标)
|
||||
-- 契约:form-center/service/docs/consumer-access-v3.md §4;依据 VERIFICATION V07 + 原骨架 T05 的确切版本读取部分
|
||||
-- 业务:发布期按 (form_uuid, form_version) 调 GET /oapi/form/schema?uuid=&version= 取快照,
|
||||
-- 中心返回的坐标与请求不符即<b>阻断发布</b>;已发布快照不改写,改关联=走新版本(既有升版分派)。
|
||||
-- 可空口径:存量行(C1 第一刀只有关联、没有版本)留 NULL,发布时仍按"当前发布版"旧口径取快照,
|
||||
-- 行为逐字不变;本次改造后新保存/新关联一律成对写入。NULL 不代表"任意版本",只代表"这条行是本列之前存的"。
|
||||
-- 取消关联=两列同时清空(单传一个在保存链路即拒,见 FlowDefAdminService#save)。
|
||||
-- 本地/测试 ddl-auto=update 会自动加列;生产执行本脚本。
|
||||
-- MySQL 5.7:无 ADD COLUMN IF NOT EXISTS(重复执行报 Duplicate column name,需在外部判重)。
|
||||
|
||||
ALTER TABLE `flw_def`
|
||||
ADD COLUMN `form_version` INT NULL COMMENT '关联表单的确切版本(与 form_uuid 成对;发布期按此核验快照坐标)' AFTER `form_uuid`;
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.yunhai.flowcenter.controller.api;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwRouteLogRepository;
|
||||
import com.yunhai.flowcenter.service.FlowAdminGuard;
|
||||
import com.yunhai.platform.utils.model.BasePageResult;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* F11-4 台账「路由挂起」可观测单测:hung 筛选透传 + 行级 routeHung 红标三态
|
||||
* (在途+挂起 / 在途未挂 / 已终态)。挂起判定 JPQL 口径(最大 attempt 行 outlet 仍 NULL)
|
||||
* 在 {@link FlwRouteLogRepository#findHungInstanceIds} 与 pageAdmin 的 exists 子查询里,
|
||||
* 语义断言由 160 人工验收(V 过滤)与真库回归兜住;本测锁定控制器编排与红标填充。
|
||||
*/
|
||||
class FlowAdminInstanceHungTest {
|
||||
|
||||
private final FlowAdminGuard adminGuard = mock(FlowAdminGuard.class);
|
||||
private final FlwInstanceRepository instanceRepository = mock(FlwInstanceRepository.class);
|
||||
private final FlwRouteLogRepository routeLogRepository = mock(FlwRouteLogRepository.class);
|
||||
|
||||
private final FlowAdminController controller = new FlowAdminController(
|
||||
adminGuard,
|
||||
mock(com.yunhai.flowcenter.service.FlowDefAdminService.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowMapAdminService.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowCallbackAdminService.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowRouterAdminService.class),
|
||||
instanceRepository,
|
||||
mock(com.yunhai.flowcenter.service.FlowDiagramService.class),
|
||||
mock(com.yunhai.flowcenter.service.DiagramImageService.class),
|
||||
mock(com.fasterxml.jackson.databind.ObjectMapper.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowAppAdminService.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
|
||||
routeLogRepository);
|
||||
|
||||
private static FlwInstance instance(long id, int status) {
|
||||
FlwInstance i = new FlwInstance();
|
||||
i.setId(id);
|
||||
i.setStatus(status);
|
||||
return i;
|
||||
}
|
||||
|
||||
@Test
|
||||
void hungFilterPassedThroughToRepository() {
|
||||
when(adminGuard.requireAdmin(any(), any())).thenReturn(990001L);
|
||||
when(instanceRepository.pageAdmin(eq(990001L), any(), any(), eq(Boolean.TRUE), any()))
|
||||
.thenReturn(new PageImpl<>(Arrays.asList(instance(1, 10))));
|
||||
when(routeLogRepository.findHungInstanceIds(any())).thenReturn(Arrays.asList());
|
||||
|
||||
controller.instancePage(new BoaUser(), null, null, Boolean.TRUE, 1, 10);
|
||||
|
||||
ArgumentCaptor<Boolean> hung = ArgumentCaptor.forClass(Boolean.class);
|
||||
verify(instanceRepository).pageAdmin(eq(990001L), isNull(), isNull(), hung.capture(), any());
|
||||
assertEquals(Boolean.TRUE, hung.getValue(), "hung=true 必须原样透传到分页查询");
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeHungBadgeThreeStates() {
|
||||
when(adminGuard.requireAdmin(any(), any())).thenReturn(990001L);
|
||||
// 三行:1=在途且在挂起集合;2=在途但不在集合;3=已终态(集合必然不含)
|
||||
when(instanceRepository.pageAdmin(eq(990001L), any(), any(), any(), any()))
|
||||
.thenReturn(new PageImpl<>(Arrays.asList(instance(1, 10), instance(2, 10), instance(3, 20))));
|
||||
when(routeLogRepository.findHungInstanceIds(any()))
|
||||
.thenReturn(Arrays.asList(1L));
|
||||
|
||||
BasePageResult<FlwInstance> result = controller.instancePage(new BoaUser(), null, null, null, 1, 10);
|
||||
|
||||
List<FlwInstance> rows = result.getContent();
|
||||
assertEquals(3, rows.size());
|
||||
assertTrue(rows.get(0).getRouteHung(), "在途且最新 attempt 路由行无出口 → 红标 true");
|
||||
assertFalse(rows.get(1).getRouteHung(), "在途但无挂起路由行 → false");
|
||||
assertFalse(rows.get(2).getRouteHung(), "已终态办件不参与挂起判定 → false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyPageSkipsHungLookup() {
|
||||
when(adminGuard.requireAdmin(any(), any())).thenReturn(990001L);
|
||||
when(instanceRepository.pageAdmin(eq(990001L), any(), any(), any(), any()))
|
||||
.thenReturn(new PageImpl<>(Arrays.asList()));
|
||||
|
||||
controller.instancePage(new BoaUser(), 10, null, null, 1, 10);
|
||||
|
||||
verify(routeLogRepository, never()).findHungInstanceIds(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwRouteLog;
|
||||
import com.yunhai.flowcenter.notify.UserMsgClient;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* CONDITION 判断编排单测(2026-10-10 四议题 Phase 4):EXPRESSION 模式分流——按出边顺序
|
||||
* 首个 true 命中、全 false/求值异常挂起(留痕 outlet=NULL + router_key=EXPRESSION + 告警)、
|
||||
* SERVICE 模式回归不受影响。挂起推进链路由 FlowEngineIT 覆盖。
|
||||
*/
|
||||
class ConditionRouteServiceExpressionTest {
|
||||
|
||||
private FlwRouterRepository routerRepository;
|
||||
private RouteLogStore routeLogStore;
|
||||
private RouteJudgeClient judgeClient;
|
||||
private ConditionRouteService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
routerRepository = mock(FlwRouterRepository.class);
|
||||
routeLogStore = mock(RouteLogStore.class);
|
||||
judgeClient = mock(RouteJudgeClient.class);
|
||||
UserMsgClient userMsgClient = mock(UserMsgClient.class);
|
||||
when(routeLogStore.append(any(FlwRouteLog.class))).thenAnswer(inv -> {
|
||||
FlwRouteLog row = inv.getArgument(0);
|
||||
row.setAttempt(1);
|
||||
return row;
|
||||
});
|
||||
service = new ConditionRouteService(routerRepository, routeLogStore, judgeClient,
|
||||
userMsgClient, new ObjectMapper());
|
||||
}
|
||||
|
||||
private FlwInstance instance(String formDataJson) {
|
||||
FlwInstance inst = new FlwInstance();
|
||||
inst.setId(7L);
|
||||
inst.setSchId(1L);
|
||||
inst.setVersion(3);
|
||||
inst.setBizType("leave");
|
||||
inst.setBizId("456");
|
||||
inst.setSubmitterId(990001);
|
||||
inst.setFormDataJson(formDataJson);
|
||||
return inst;
|
||||
}
|
||||
|
||||
/** CONDITION 表达式模型:出边顺序即求值优先级(出口一 → 出口二,互补对) */
|
||||
private FlowDefModel model() {
|
||||
return expressionModel("amount > 1000", "amount <= 1000");
|
||||
}
|
||||
|
||||
private FlowDefModel expressionModel(String cond1, String cond2) {
|
||||
return FlowDefModel.parse(new ObjectMapper(), "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":["
|
||||
+ "{\"from\":\"start\",\"to\":\"cond\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口一\",\"condition\":\"" + cond1 + "\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口二\",\"condition\":\"" + cond2 + "\"}]}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionAllFalse_suspendsWithFailureRow() {
|
||||
FlowDefModel model = expressionModel("amount > 1000", "amount > 2000");
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
|
||||
String outlet = service.decide(instance("{\"amount\":500}"), model, node);
|
||||
|
||||
assertNull(outlet);
|
||||
ArgumentCaptor<FlwRouteLog> captor = ArgumentCaptor.forClass(FlwRouteLog.class);
|
||||
verify(routeLogStore, times(1)).append(captor.capture());
|
||||
assertNull(captor.getValue().getOutlet());
|
||||
assertEquals("EXPRESSION", captor.getValue().getRouterKey());
|
||||
assertTrue(captor.getValue().getRawResponse().contains("表达式全部未命中"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMissingVariable_suspendsWithTypeSafeError() {
|
||||
// 表单未传 amount(拼写错/漏传同款):直接挂起,不留静默放行口子
|
||||
FlowDefModel model = model();
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
|
||||
String outlet = service.decide(instance("{\"other\":1}"), model, node);
|
||||
|
||||
assertNull(outlet);
|
||||
ArgumentCaptor<FlwRouteLog> captor = ArgumentCaptor.forClass(FlwRouteLog.class);
|
||||
verify(routeLogStore, times(1)).append(captor.capture());
|
||||
assertTrue(captor.getValue().getRawResponse().contains("表达式求值异常"));
|
||||
assertTrue(captor.getValue().getRawResponse().contains("amount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionSecondEdgeTaken_whenFirstFalse() {
|
||||
FlowDefModel model = model();
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
|
||||
String outlet = service.decide(instance("{\"amount\":500}"), model, node);
|
||||
|
||||
assertEquals("出口二", outlet);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionEvalError_suspends() {
|
||||
FlowDefModel model = FlowDefModel.parse(new ObjectMapper(), "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":["
|
||||
+ "{\"from\":\"start\",\"to\":\"cond\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口一\",\"condition\":\"amount - 'x'\"}]}");
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
|
||||
String outlet = service.decide(instance("{\"amount\":1}"), model, node);
|
||||
|
||||
assertNull(outlet);
|
||||
ArgumentCaptor<FlwRouteLog> captor = ArgumentCaptor.forClass(FlwRouteLog.class);
|
||||
verify(routeLogStore, times(1)).append(captor.capture());
|
||||
assertTrue(captor.getValue().getRawResponse().contains("表达式求值异常"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builtinVariables_availableInEnv() {
|
||||
Map<String, Object> env = ConditionRouteService.builtinEnv(instance(null),
|
||||
model().nodeById("cond"), new HashMap<>());
|
||||
assertEquals(1L, env.get("schId"));
|
||||
assertEquals(7L, env.get("instanceId"));
|
||||
assertEquals(3, env.get("instanceVersion"));
|
||||
assertEquals("leave", env.get("bizType"));
|
||||
assertEquals("456", env.get("bizId"));
|
||||
assertEquals("cond", env.get("nodeId"));
|
||||
assertEquals(990001, env.get("submitterId"));
|
||||
|
||||
Object hit = ExpressionEvaluator.evaluate("bizType == 'leave' && submitterId == 990001", env);
|
||||
assertTrue((Boolean) hit);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJudgeMode_stillServicePath_routerMissSuspends() {
|
||||
FlowDefModel model = FlowDefModel.parse(new ObjectMapper(), "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond\",\"type\":\"CONDITION\",\"name\":\"判断\",\"routerKey\":\"rk\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":["
|
||||
+ "{\"from\":\"start\",\"to\":\"cond\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口一\"},"
|
||||
+ "{\"from\":\"cond\",\"to\":\"end\",\"name\":\"出口二\"}]}");
|
||||
FlowDefModel.Node node = model.nodeById("cond");
|
||||
when(routerRepository.findByRouterKey("rk")).thenReturn(java.util.Optional.empty());
|
||||
|
||||
assertNull(service.decide(instance(null), model, node));
|
||||
ArgumentCaptor<FlwRouteLog> captor = ArgumentCaptor.forClass(FlwRouteLog.class);
|
||||
verify(routeLogStore, times(1)).append(captor.capture());
|
||||
assertEquals("rk", captor.getValue().getRouterKey());
|
||||
assertTrue(captor.getValue().getRawResponse().contains("未注册或已停用"));
|
||||
}
|
||||
}
|
||||
@@ -381,6 +381,78 @@ class DefValidatorTest {
|
||||
new HashSet<>(Collections.singletonList("rk1")));
|
||||
}
|
||||
|
||||
// ===== CONDITION EXPRESSION 模式(2026-10-10 四议题) =====
|
||||
|
||||
private String expressionDef(String judgeMode, String cond1, String cond2) {
|
||||
return "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"cond1\",\"type\":\"CONDITION\",\"name\":\"条件判断\""
|
||||
+ (judgeMode == null ? "" : ",\"judgeMode\":\"" + judgeMode + "\"") + "},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"cond1\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n1\",\"name\":\"甲线\",\"condition\":\"" + cond1 + "\"},"
|
||||
+ "{\"from\":\"cond1\",\"to\":\"n2\",\"name\":\"乙线\",\"condition\":\"" + cond2 + "\"},"
|
||||
+ "{\"from\":\"n1\",\"to\":\"end\"},{\"from\":\"n2\",\"to\":\"end\"}]}";
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_validConditions_passes() {
|
||||
DefValidator.validate(parse(expressionDef("EXPRESSION", "amount > 1000", "amount <= 1000")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_missingCondition_fails() {
|
||||
BizException e = assertThrows(BizException.class, () -> DefValidator.validate(
|
||||
parse(expressionDef("EXPRESSION", "amount > 1000", "")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("缺少条件表达式"));
|
||||
assertTrue(e.getMessage().contains("乙线"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_syntaxError_fails() {
|
||||
BizException e = assertThrows(BizException.class, () -> DefValidator.validate(
|
||||
parse(expressionDef("EXPRESSION", "amount >", "amount <= 1000")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("表达式不合法"));
|
||||
assertTrue(e.getMessage().contains("甲线"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_dangerousExpression_fails() {
|
||||
BizException e = assertThrows(BizException.class, () -> DefValidator.validate(
|
||||
parse(expressionDef("EXPRESSION", "new java.util.Date()", "amount <= 1000")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("表达式不合法"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_formVariables_passPublishTime() {
|
||||
// 表单变量(子应用运行时传)发布期不拦:缺失按 null 注入求值
|
||||
DefValidator.validate(parse(expressionDef("EXPRESSION", "form.level == 'A'", "true")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressionMode_skipsRouterKeyCheck() {
|
||||
// EXPRESSION 模式不配 routerKey(enabledRouterKeys=null 也不查注册表)
|
||||
DefValidator.validate(parse(expressionDef("EXPRESSION", "true", "false")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void judgeMode_illegalValue_fails() {
|
||||
BizException e = assertThrows(BizException.class, () -> DefValidator.validate(
|
||||
parse(expressionDef("WHATEVER", "true", "false")),
|
||||
new HashSet<>(Collections.singletonList("ybb")), null));
|
||||
assertTrue(e.getMessage().contains("判断方式取值非法"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void condition_missingRouterKey_fails() {
|
||||
String json = "{"
|
||||
@@ -455,6 +527,54 @@ class DefValidatorTest {
|
||||
assertTrue(e.getMessage().contains("出口必须命名"));
|
||||
}
|
||||
|
||||
// ===== 发起时指派 assignPolicy(2026-10-13) =====
|
||||
|
||||
@Test
|
||||
void assign_singleWithWrongCount_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"},"
|
||||
+ "\"assignPolicy\":{\"mode\":\"SINGLE\",\"min\":2}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("SINGLE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assign_minGreaterThanMax_fails() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"},"
|
||||
+ "\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":3,\"max\":2}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
|
||||
assertTrue(e.getMessage().contains("min 不得大于 max"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assign_validMulti_passes() {
|
||||
String json = "{"
|
||||
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"},"
|
||||
+ "\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":3,\"required\":true}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}";
|
||||
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
|
||||
// 无 assignPolicy(存量定义):向前兼容,照常通过
|
||||
String legacy = json.replace(",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":3,\"required\":true}", "");
|
||||
DefValidator.validate(parse(legacy), new HashSet<>(Collections.singletonList("ybb")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overviewSample_def() {
|
||||
// 概览 §5.1 缩样(并行+分支内串行):配齐 6 个 DEPT_KEY 映射应通过
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 表达式求值器单测(2026-10-10 四议题 Phase 4):沙箱纪律(禁类访问/反射/new)、
|
||||
* 缺失变量补 null、nil 属性语义、非布尔不命中。发布校验/运行时/调试端点共用本核心。
|
||||
*/
|
||||
class ExpressionEvaluatorTest {
|
||||
|
||||
@Test
|
||||
void basicComparison_trueAndFalse() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("amount", 2000);
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("amount > 1000", env));
|
||||
assertFalse((Boolean) ExpressionEvaluator.evaluate("amount < 1000", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTopLevelVariable_throws_typoFailsClosed() {
|
||||
// 宁挂起不误放行:缺失变量直接抛错(nil 视作最小值,<= 兜底出口会静默放行,不能补 null)
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
assertThrows(Exception.class, () -> ExpressionEvaluator.evaluate("missing > 1000", env));
|
||||
assertThrows(Exception.class, () -> ExpressionEvaluator.evaluate("missing <= 1000", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitNullValue_nilComparisons_documentedSemantics() {
|
||||
// env 显式 null 仍走 nil 语义:== nil 可判空;<= 对 nil 为 true(Aviator 视 nil 为最小值)
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("amount", null);
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("amount == nil", env));
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("amount <= 1000", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedMapPropertyMissing_returnsNil() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("form", new HashMap<String, Object>());
|
||||
Object r = ExpressionEvaluator.evaluate("form.amount == nil", env);
|
||||
assertTrue((Boolean) r);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonBooleanResult_notTreatedAsTrue() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("amount", 2000);
|
||||
Object r = ExpressionEvaluator.evaluate("amount + 1", env);
|
||||
assertEquals(2001L, ((Number) r).longValue());
|
||||
assertFalse(Boolean.TRUE.equals(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringFunction_allowed() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("title", "请假申请");
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("string.contains(title, '请假')", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sandbox_rejectsNewInstance() {
|
||||
assertThrows(Exception.class, ()
|
||||
-> ExpressionEvaluator.evaluate("new java.util.Date()", new HashMap<>()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sandbox_rejectsStaticMethodAccess() {
|
||||
assertThrows(Exception.class, ()
|
||||
-> ExpressionEvaluator.evaluate("System.currentTimeMillis() > 0", new HashMap<>()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sandbox_rejectsChainedReflection() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("title", "x");
|
||||
assertThrows(Exception.class, ()
|
||||
-> ExpressionEvaluator.evaluate("title.getClass().forName('java.lang.Runtime')", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sandbox_bareClassReference_isInertVariableLookup() {
|
||||
// 裸类名在属性语法糖下只是嵌套 map 查找(java→lang→Runtime),顶层变量 java 缺失被
|
||||
// 求值前核对拦下——不构成类访问;真正的类访问(new/静态方法/链式反射)由上面三个用例证明被拒
|
||||
assertThrows(IllegalStateException.class, ()
|
||||
-> ExpressionEvaluator.evaluate("java.lang.Runtime", new HashMap<>()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builtinVariableStyle_comparisonAcrossNumberTypes() {
|
||||
Map<String, Object> env = new HashMap<>();
|
||||
env.put("submitterId", 990001);
|
||||
assertTrue((Boolean) ExpressionEvaluator.evaluate("submitterId == 990001", env));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syntaxError_thrownAtCompile() {
|
||||
assertThrows(Exception.class, () -> ExpressionEvaluator.compile("amount >"));
|
||||
}
|
||||
}
|
||||
@@ -98,11 +98,17 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
|
||||
private FlowInstanceService.ApplyOutcome apply(String flowGuid, String bizId,
|
||||
Integer submitterId, String submitterName) {
|
||||
return apply(flowGuid, bizId, submitterId, submitterName, null);
|
||||
}
|
||||
|
||||
/** 发起时指派用例直灌快照入口:仍走 ApplyCommand 主链(契约校验在 OapiFlowContractIT 覆盖) */
|
||||
private FlowInstanceService.ApplyOutcome apply(String flowGuid, String bizId,
|
||||
Integer submitterId, String submitterName, String assignJson) {
|
||||
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
SCH, flowGuid, 1).orElseThrow(IllegalStateException::new);
|
||||
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
|
||||
SCH, def, "svc_item", bizId, "张三的测试办件", "http://biz/1", "{\"k\":\"v\"}",
|
||||
submitterId, submitterName, null);
|
||||
submitterId, submitterName, null, "100001", "notice", assignJson);
|
||||
return instanceService.apply(cmd);
|
||||
}
|
||||
|
||||
@@ -228,12 +234,12 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
assertEquals(2, trace.get(3).get("round"));
|
||||
// 科室口径富化(DEPT_KEY 节点):deptName 取映射里的科室名,前端"按科室"据此换掉人名
|
||||
assertEquals("DEPT_KEY", trace.get(3).get("assigneeType"));
|
||||
assertEquals("ybb", trace.get(3).get("deptKey"));
|
||||
assertEquals(dk("ybb"), trace.get(3).get("deptKey"));
|
||||
assertEquals("ybb科", trace.get(3).get("deptName"));
|
||||
|
||||
// ② 已办去重:同一办件一行,结果是最后一次(同意),次数=2
|
||||
com.yunhai.platform.utils.model.BasePageResult<com.yunhai.flowcenter.model.FlowModels.TaskItem>
|
||||
done = queryService.pageDone(SCH, 101, 1, 10);
|
||||
done = queryService.pageDone(SCH, 101, null, 1, 10);
|
||||
assertEquals(1, done.getContent().size(), "一个办件只能有一条已办记录");
|
||||
assertEquals(2, done.getContent().get(0).getDoneCount());
|
||||
assertEquals(FlwEnums.TaskStatus.APPROVED.getCode(), done.getContent().get(0).getStatus());
|
||||
@@ -507,14 +513,39 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
kinds.add((String) row.get("kind"));
|
||||
}
|
||||
assertEquals(java.util.Arrays.asList("SUBMIT", "CC", "CC", "APPROVE"), kinds);
|
||||
// 科室口径富化(2026-10-12):抄送行取节点的 receivers(不是 assignee,见 fillCcScope)——
|
||||
// 否则 dept 档下抄送行拿不到 assigneeType,前端只能回落接收人真实姓名(原先的泄露点)
|
||||
assertEquals("USERS", trace.get(1).get("assigneeType"));
|
||||
assertEquals("USERS", trace.get(2).get("assigneeType"));
|
||||
// 抄送我的:301 视角一行,回填办件信息
|
||||
com.yunhai.platform.utils.model.BasePageResult<com.yunhai.flowcenter.model.FlowModels.CcItem>
|
||||
ccPage = queryService.pageCc(SCH, 301, 1, 10);
|
||||
ccPage = queryService.pageCc(SCH, 301, null, 1, 10);
|
||||
assertEquals(1, ccPage.getContent().size());
|
||||
assertEquals(instanceId, ccPage.getContent().get(0).getInstanceId());
|
||||
assertEquals("张三的测试办件", ccPage.getContent().get(0).getBizTitle());
|
||||
}
|
||||
|
||||
/**
|
||||
* 抄送轨迹行的科室口径富化(2026-10-12):NOTIFY 节点的接收人存在 {@code receivers} 字段,
|
||||
* 与审批节点的 {@code assignee} 不同名——富化若只认 assignee 就恒为空,dept 档下抄送行
|
||||
* 拿不到科室名/类型,前端只能回落接收人真实姓名。本例锁死"科室抄送 → 科室名"这一路。
|
||||
*/
|
||||
@Test
|
||||
void ccTraceRows_carryDeptScope_forDeptView() {
|
||||
mapDept("km", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t16b", "抄送科室", notifyDeptDef("km"));
|
||||
Long instanceId = apply(def.getFlowGuid(), "biz-16b", 201, "张三").getInstanceId();
|
||||
|
||||
java.util.List<java.util.Map<String, Object>> trace =
|
||||
queryService.detail(SCH, instanceId, null).getTrace();
|
||||
java.util.Map<String, Object> cc = trace.stream()
|
||||
.filter(r -> "CC".equals(r.get("kind")))
|
||||
.findFirst().orElseThrow(() -> new AssertionError("轨迹缺 kind=CC 行"));
|
||||
assertEquals("DEPT_KEY", cc.get("assigneeType"));
|
||||
assertEquals(dk("km"), cc.get("deptKey"));
|
||||
assertEquals("km科", cc.get("deptName"));
|
||||
}
|
||||
|
||||
/** NOTIFY 在并行分支内:瞬时到达 JOIN 不占等待(审批支到齐即放行),落库与轨迹同主线口径 */
|
||||
@Test
|
||||
void notify_inParallelBranch_reachesJoin_withoutBlocking() {
|
||||
@@ -751,6 +782,158 @@ class FlowEngineIT extends FlowTestSupport {
|
||||
assertEquals(2, logs.get(1).getAttempt());
|
||||
}
|
||||
|
||||
// ===== 发起时指派(2026-10-13):快照收窄/宽松回退/退回存活/R4 覆盖 =====
|
||||
|
||||
/** 三人 USERS 或签节点(USERS 直配不经科室映射,指派收窄用例共用) */
|
||||
private String usersAnyDef(String nodeName) {
|
||||
return "{"
|
||||
+ "\"code\":\"t25\",\"name\":\"USERS或签测试流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"" + nodeName + "\","
|
||||
+ "\"assignee\":{\"type\":\"USERS\",\"userIds\":[101,102,103]},\"countersign\":\"ANY\"},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/** 或签 3 候选指派 1 人:只为被指派人建任务,该人办理即完成(收窄不改变 ANY 放行语义,I1) */
|
||||
@Test
|
||||
void assign_singleNarrows_anySign() {
|
||||
FlwDef def = saveAndPublishDef("t26", "指派单收窄", usersAnyDef("任一人审批"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-26", 201, "张三",
|
||||
"{\"n1\":[{\"userId\":101,\"userName\":\"甲\"}]}");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
|
||||
assertTrue(tasksOf(instanceId, 102).isEmpty(), "未被指派人不建任务");
|
||||
assertTrue(tasksOf(instanceId, 103).isEmpty(), "未被指派人不建任务");
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
/** 或签 3 候选指派 2 人:任一人办理即放行,另一条失效,办件完成 */
|
||||
@Test
|
||||
void assign_twoNarrows_anySign_othersInvalidated() {
|
||||
FlwDef def = saveAndPublishDef("t27", "指派双收窄", usersAnyDef("任一人审批"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-27", 201, "张三",
|
||||
"{\"n1\":[{\"userId\":101,\"userName\":\"甲\"},{\"userId\":102,\"userName\":\"乙\"}]}");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
assertEquals(2, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "我先批", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(),
|
||||
allTasks(instanceId).stream().filter(t -> t.getAssigneeId().equals(102))
|
||||
.findFirst().get().getStatus());
|
||||
}
|
||||
|
||||
/** 会签 3 候选指派 2 人:收窄=指派人全签(2 条待办),全部同意才完成 */
|
||||
@Test
|
||||
void assign_allNarrows_countersignAll() {
|
||||
mapDept("ky", 101, "甲");
|
||||
mapDept("ky", 102, "乙");
|
||||
mapDept("ky", 103, "丙");
|
||||
FlwDef def = saveAndPublishDef("t28", "指派会签", linearDef("ky", "联合审核"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-28", 201, "张三",
|
||||
"{\"n1\":[{\"userId\":101,\"userName\":\"甲\"},{\"userId\":102,\"userName\":\"乙\"}]}");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
List<FlwTask> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0);
|
||||
assertEquals(2, pending.size());
|
||||
// 第一人同意:会签未清零仍在途
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
// 第二人同意 → 完成
|
||||
taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
/** assignJson 缺失 / 快照条目为空数组:均回退全量候选(宽松回退 D3) */
|
||||
@Test
|
||||
void assign_missingOrEmpty_fallsBackToFull() {
|
||||
FlwDef def = saveAndPublishDef("t29", "无指派回退", usersAnyDef("任一人审批"));
|
||||
FlowInstanceService.ApplyOutcome a = apply(def.getFlowGuid(), "biz-29a", 201, "张三", null);
|
||||
assertEquals(3, taskRepository.findByInstanceIdAndStatus(a.getInstanceId(), 0).size());
|
||||
FlowInstanceService.ApplyOutcome b = apply(def.getFlowGuid(), "biz-29b", 201, "张三", "{\"n1\":[]}");
|
||||
assertEquals(3, taskRepository.findByInstanceIdAndStatus(b.getInstanceId(), 0).size());
|
||||
}
|
||||
|
||||
/** 脏 assignJson:log warn 后回退全量(引擎永不因缺指派拒绝或挂起) */
|
||||
@Test
|
||||
void assign_dirtyJson_fallsBack() {
|
||||
FlwDef def = saveAndPublishDef("t30", "脏快照回退", usersAnyDef("任一人审批"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-30", 201, "张三", "not-json");
|
||||
assertEquals(3, taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0).size());
|
||||
}
|
||||
|
||||
/** 退回重入:快照沿用,重入节点仍只发原指派人(I5) */
|
||||
@Test
|
||||
void assign_survivesReturnReentry() {
|
||||
mapDept("km", 103, "主");
|
||||
mapDept("km", 104, "副");
|
||||
mapDept("ka", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t31", "退回指派存活", mainlineParallelTailDef("km", "km"));
|
||||
FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-31", 201, "张三",
|
||||
"{\"n3\":[{\"userId\":103,\"userName\":\"主\"}]}");
|
||||
Long instanceId = outcome.getInstanceId();
|
||||
// 推进到 n3:n1 会签(主+副)→ 并行甲乙 → 出口复核只发给被指派的"主"
|
||||
taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "ok", 103);
|
||||
taskService.handle(SCH, tasksOf(instanceId, 104).get(0).getId(), true, "ok", 104);
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102);
|
||||
FlwTask tail = tasksOf(instanceId, 103).get(0);
|
||||
assertEquals("n3", tail.getNodeId());
|
||||
assertTrue(tasksOf(instanceId, 104).isEmpty(), "n3 只给被指派人建任务");
|
||||
// 退回至入口 n1 重走
|
||||
taskService.returnBack(SCH, tail.getId(), null, "重新审一遍", 103);
|
||||
assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes());
|
||||
taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "再入口", 103);
|
||||
taskService.handle(SCH, tasksOf(instanceId, 104).get(0).getId(), true, "ok", 104);
|
||||
taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101);
|
||||
taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102);
|
||||
// 重入 n3:快照沿用,仍只发原指派人
|
||||
FlwTask tailAgain = tasksOf(instanceId, 103).get(0);
|
||||
assertEquals("n3", tailAgain.getNodeId());
|
||||
assertTrue(tasksOf(instanceId, 104).isEmpty());
|
||||
taskService.handle(SCH, tailAgain.getId(), true, "复核通过", 103);
|
||||
assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(),
|
||||
instanceRepository.findById(instanceId).get().getStatus());
|
||||
}
|
||||
|
||||
/** R4 重启:带新指派覆盖快照;不带 assigns(null=清空)回全量(覆盖语义 I5) */
|
||||
@Test
|
||||
void assign_r4RestartOverwrites() {
|
||||
mapDept("ky", 101, "甲");
|
||||
mapDept("ky", 102, "乙");
|
||||
mapDept("ky", 103, "丙");
|
||||
FlwDef def = saveAndPublishDef("t32", "R4指派覆盖", linearDef("ky", "联合审核"));
|
||||
// 首轮:指派 101 → 单待办;拒绝到 30
|
||||
FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-32", 201, "张三",
|
||||
"{\"n1\":[{\"userId\":101,\"userName\":\"甲\"}]}");
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size());
|
||||
taskService.handle(SCH, tasksOf(first.getInstanceId(), 101).get(0).getId(), false, "不合格", 101);
|
||||
assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(),
|
||||
instanceRepository.findById(first.getInstanceId()).get().getStatus());
|
||||
// R4 重提带新指派 → 快照覆盖为 102 子集
|
||||
FlowInstanceService.ApplyOutcome second = apply(def.getFlowGuid(), "biz-32", 201, "张三",
|
||||
"{\"n1\":[{\"userId\":102,\"userName\":\"乙\"}]}");
|
||||
assertTrue(second.isDuplicated());
|
||||
assertEquals(first.getInstanceId(), second.getInstanceId());
|
||||
List<FlwTask> pending = taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0);
|
||||
assertEquals(1, pending.size());
|
||||
assertEquals(102, pending.get(0).getAssigneeId());
|
||||
// 再拒后重提不带指派(置空)→ 全量 3 条
|
||||
taskService.handle(SCH, pending.get(0).getId(), false, "仍不合格", 102);
|
||||
FlowInstanceService.ApplyOutcome third = apply(def.getFlowGuid(), "biz-32", 201, "张三", null);
|
||||
assertTrue(third.isDuplicated());
|
||||
assertEquals(3, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size());
|
||||
assertNull(instanceRepository.findById(first.getInstanceId()).get().getAssignJson());
|
||||
}
|
||||
|
||||
private List<FlwBranch> branchRows(Long instanceId) {
|
||||
return branchRepository.findByInstanceId(instanceId);
|
||||
}
|
||||
|
||||
@@ -19,14 +19,15 @@ import static org.mockito.ArgumentMatchers.anyMap;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 路由判断客户端单测(2026-09-24 §4.3):重试纪律(3s×3 退避重试,Mock 出网)、
|
||||
* flw_router.base_path 对目录前缀的覆盖、信封 data.outlet 解析。
|
||||
* 引擎行为(选路/挂起/重跑)由 FlowEngineIT 覆盖。
|
||||
* flw_router.base_path 对目录前缀的覆盖、direct_host 自注册行直连(跳过目录解析,2026-10-12 收口)、
|
||||
* 信封 data.outlet 解析。引擎行为(选路/挂起/重跑)由 FlowEngineIT 覆盖。
|
||||
*/
|
||||
class RouteJudgeClientTest {
|
||||
|
||||
@@ -53,7 +54,7 @@ class RouteJudgeClientTest {
|
||||
}
|
||||
|
||||
private RouteJudgeClient.JudgeBody body() {
|
||||
return new RouteJudgeClient.JudgeBody(1L, 2L, 3, "notice", "456",
|
||||
return new RouteJudgeClient.JudgeBody(1L, 2L, 3, "notice", "456", 990001,
|
||||
"node_r1", "判断", "rk", Collections.singletonList("甲线"), new HashMap<>());
|
||||
}
|
||||
|
||||
@@ -88,6 +89,55 @@ class RouteJudgeClientTest {
|
||||
assertEquals("/dirctx", spec.getValue().getBasePath(), "base_path 空=沿用目录条目 context-path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void directHost_非空时拆基址直连_不查服务目录() {
|
||||
FlwRouter r = router("");
|
||||
r.setDirectHost("http://open-gateway-service:10013/work-leave");
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("outlet", "甲线");
|
||||
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
|
||||
.thenReturn(data);
|
||||
|
||||
RouteJudgeClient.JudgeResult result = client.call(r, body());
|
||||
|
||||
assertEquals("甲线", result.outlet);
|
||||
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
|
||||
verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class));
|
||||
assertEquals("http://open-gateway-service:10013", spec.getValue().getBaseHost(), "direct_host 拆 authority");
|
||||
assertEquals("/work-leave", spec.getValue().getBasePath(), "direct_host 的 path 段即 context-path(签名剥离面)");
|
||||
assertEquals((Long) 3000L, spec.getValue().getReadTimeoutMs(), "直连仍用判断专用 3s 超时");
|
||||
verify(openCall, never()).resolveTarget(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void directHost_无path段_basePath为空() {
|
||||
FlwRouter r = router("");
|
||||
r.setDirectHost("http://work-leave:8080");
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("outlet", "甲线");
|
||||
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
|
||||
.thenReturn(data);
|
||||
|
||||
client.call(r, body());
|
||||
|
||||
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
|
||||
verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class));
|
||||
assertEquals("http://work-leave:8080", spec.getValue().getBaseHost());
|
||||
assertEquals("", spec.getValue().getBasePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void directHost为空_存量行仍走目录解析() {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("outlet", "甲线");
|
||||
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
|
||||
.thenReturn(data);
|
||||
|
||||
client.call(router(""), body());
|
||||
|
||||
verify(openCall).resolveTarget("notice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void transportFailure_retriesThreeTimes_thenThrows() {
|
||||
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
|
||||
|
||||
@@ -20,9 +20,11 @@ import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 待办通知 jumpPath/jumpApp 语义(自指地址优化 Phase 2):办件 bizUrl 非空=直达业务方详情页,
|
||||
* 页面归属 jumpApp=instance.bizType(接收方按它查基址);空/空白=回落流程中心办理页
|
||||
* /task/{taskId},页面属自己(jumpApp=null,接收方按 bizType 回落)。
|
||||
* 待办通知 jumpPath/jumpApp 语义(自指地址优化 Phase 2;2026-10-12 换源):
|
||||
* 办件 bizUrl 非空=直达业务方详情页,页面归属 jumpApp=instance.applyAppCode(apply 时由验签
|
||||
* 身份按 by-key 换得的 app_code——bizType 是消息分类,不是路由键,不再冒充);
|
||||
* applyAppCode 空(存量行/UserBase 不可达降级)→ jumpApp=null,接收方按发送方回落;
|
||||
* bizUrl 空/空白=回落流程中心办理页 /task/{taskId},页面属自己。
|
||||
*/
|
||||
class UserMsgNotifierBizUrlTest {
|
||||
|
||||
@@ -73,6 +75,8 @@ class UserMsgNotifierBizUrlTest {
|
||||
instance.setSchId(1L);
|
||||
instance.setBizTitle("张三的事项");
|
||||
instance.setBizType("notice");
|
||||
instance.setApplyAppKey("100001");
|
||||
instance.setApplyAppCode("notice");
|
||||
instance.setBizUrl("/list/detail?serid=27");
|
||||
when(taskRepository.findById(77L)).thenReturn(Optional.of(task));
|
||||
when(instanceRepository.findById(5L)).thenReturn(Optional.of(instance));
|
||||
@@ -83,6 +87,28 @@ class UserMsgNotifierBizUrlTest {
|
||||
assertEquals("notice", userMsg.lastJumpApp);
|
||||
}
|
||||
|
||||
/** 降级兼容:applyAppCode 空(存量行/UserBase 不可达)→ jumpApp=null,接收方按发送方回落 */
|
||||
@Test
|
||||
void applyAppCodeBlank_jumpAppNull_evenWithBizUrl() {
|
||||
FlwTask task = new FlwTask();
|
||||
task.setId(81L);
|
||||
task.setAssigneeId(101);
|
||||
task.setInstanceId(9L);
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(9L);
|
||||
instance.setSchId(1L);
|
||||
instance.setBizTitle("赵六的事项");
|
||||
instance.setBizType("svc_item");
|
||||
instance.setBizUrl("/hall/detail?id=3");
|
||||
when(taskRepository.findById(81L)).thenReturn(Optional.of(task));
|
||||
when(instanceRepository.findById(9L)).thenReturn(Optional.of(instance));
|
||||
|
||||
notifier.onFlowEvent(FlowEvent.of(FlowEvent.Type.TASK_CREATED, 9L, 81L, 101, "丁", null));
|
||||
|
||||
assertEquals("/hall/detail?id=3", userMsg.lastJumpPath);
|
||||
assertNull(userMsg.lastJumpApp);
|
||||
}
|
||||
|
||||
/** 存量兼容:业务方尚未升级仍传绝对 URL 时原样透传,jumpApp 照常按归属声明 */
|
||||
@Test
|
||||
void bizUrlLegacyAbsolute_passedThroughWithOwner() {
|
||||
@@ -95,6 +121,7 @@ class UserMsgNotifierBizUrlTest {
|
||||
instance.setSchId(1L);
|
||||
instance.setBizTitle("王五的事项");
|
||||
instance.setBizType("notice");
|
||||
instance.setApplyAppCode("notice");
|
||||
instance.setBizUrl("http://base.yunhaixingtong.com/notice/#/list/detail?serid=27");
|
||||
when(taskRepository.findById(79L)).thenReturn(Optional.of(task));
|
||||
when(instanceRepository.findById(7L)).thenReturn(Optional.of(instance));
|
||||
@@ -124,4 +151,75 @@ class UserMsgNotifierBizUrlTest {
|
||||
assertEquals("/task/78", userMsg.lastJumpPath);
|
||||
assertNull(userMsg.lastJumpApp);
|
||||
}
|
||||
|
||||
/**
|
||||
* F8 缺口①:结果消息(completed/rejected/withdrawn)不再硬编码归流程中心,
|
||||
* 与待办/抄送同策略——bizUrl 优先 + jumpApp=applyAppCode。
|
||||
*/
|
||||
@Test
|
||||
void resultMessage_followsBizUrlStrategy() {
|
||||
FlwInstance instance = instance(11L, "/leave/detail?id=88", "notice");
|
||||
when(instanceRepository.findById(11L)).thenReturn(Optional.of(instance));
|
||||
|
||||
notifier.onFlowEvent(FlowEvent.of(FlowEvent.Type.COMPLETED, 11L, null,
|
||||
java.util.Collections.singletonMap("result", "通过")));
|
||||
|
||||
assertEquals("/leave/detail?id=88", userMsg.lastJumpPath);
|
||||
assertEquals("notice", userMsg.lastJumpApp);
|
||||
}
|
||||
|
||||
/** 存量兼容:老办件无 bizUrl 时结果消息仍回落中心办件详情、归属留空 */
|
||||
@Test
|
||||
void resultMessageWithoutBizUrl_fallsBackToCenterInstancePage() {
|
||||
FlwInstance instance = instance(12L, " ", "notice");
|
||||
when(instanceRepository.findById(12L)).thenReturn(Optional.of(instance));
|
||||
|
||||
notifier.onFlowEvent(FlowEvent.of(FlowEvent.Type.REJECTED, 12L));
|
||||
|
||||
assertEquals("/instance/12", userMsg.lastJumpPath);
|
||||
assertNull(userMsg.lastJumpApp);
|
||||
}
|
||||
|
||||
/** F8 缺口①:退回消息同策略(四类消息归属口径从此一致) */
|
||||
@Test
|
||||
void returnedMessage_followsBizUrlStrategy() {
|
||||
FlwInstance instance = instance(13L, "/hall/detail?id=9", "svc-hall");
|
||||
when(instanceRepository.findById(13L)).thenReturn(Optional.of(instance));
|
||||
java.util.Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("targetUserIds", java.util.Arrays.asList(201, 202));
|
||||
data.put("fromNodeName", "科室初审");
|
||||
data.put("opinion", "请补材料");
|
||||
|
||||
notifier.onFlowEvent(FlowEvent.of(FlowEvent.Type.RETURNED, 13L, null, data));
|
||||
|
||||
assertEquals("/hall/detail?id=9", userMsg.lastJumpPath);
|
||||
assertEquals("svc-hall", userMsg.lastJumpApp);
|
||||
}
|
||||
|
||||
/** 抄送消息(原已同策略)纳入同一断言口径,防后续回归 */
|
||||
@Test
|
||||
void ccMessage_followsBizUrlStrategy() {
|
||||
FlwInstance instance = instance(14L, "/notice/detail?serid=3", "notice");
|
||||
when(instanceRepository.findById(14L)).thenReturn(Optional.of(instance));
|
||||
java.util.Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("ccUserId", 301);
|
||||
data.put("ccId", 55);
|
||||
data.put("nodeName", "抄送分管领导");
|
||||
|
||||
notifier.onFlowEvent(FlowEvent.of(FlowEvent.Type.CC_SENT, 14L, null, data));
|
||||
|
||||
assertEquals("/notice/detail?serid=3", userMsg.lastJumpPath);
|
||||
assertEquals("notice", userMsg.lastJumpApp);
|
||||
}
|
||||
|
||||
private FlwInstance instance(long id, String bizUrl, String applyAppCode) {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(id);
|
||||
instance.setSchId(1L);
|
||||
instance.setBizTitle("测试办件");
|
||||
instance.setSubmitterId(101);
|
||||
instance.setBizUrl(bizUrl);
|
||||
instance.setApplyAppCode(applyAppCode);
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.yunhai.flowcenter.oapi;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwRouter;
|
||||
import com.yunhai.flowcenter.service.DiagramTokenService;
|
||||
import com.yunhai.flowcenter.service.FlowRouterAdminService;
|
||||
import com.yunhai.flowcenter.service.OapiFlowService;
|
||||
import com.yunhai.flowcenter.support.FlowTestSupport;
|
||||
import com.yunhai.flowcenter.support.OapiSignedClient;
|
||||
@@ -50,6 +52,9 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
@Autowired
|
||||
private OapiFlowService flowService;
|
||||
|
||||
@Autowired
|
||||
private FlowRouterAdminService routerAdminService;
|
||||
|
||||
@Autowired
|
||||
private DiagramTokenService tokenService;
|
||||
|
||||
@@ -507,6 +512,203 @@ class OapiFlowContractIT extends FlowTestSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断服务自注册契约(2026-10-12 SERVICE 收口,yh-flow-callback starter 的落点):未验签 401;
|
||||
* 登记即持有(新增行带 directHost、regAppKey/serviceKey=签名 appKey);同键重复登记幂等更新
|
||||
* (status 不被翻正);校验项业务码拒绝。跨应用覆盖拒绝见 FlowRouterAdminServiceRegisterTest。
|
||||
*/
|
||||
@Test
|
||||
void routerReg_selfRegistration_contract() {
|
||||
String reg = "{\"routerKey\":\"svc-hall.demo-judge\",\"name\":\"演示判断\","
|
||||
+ "\"judgePath\":\"/oapi/flow/route/judge\","
|
||||
+ "\"directHost\":\"http://open-gateway-service:10013/svc-hall\"}";
|
||||
|
||||
// 未验签(不带签名头)→ 401,不落行(本路径同受 /oapi/* 强制验签保护)
|
||||
assertEquals(401, client.getRaw("/oapi/flow/router/reg").getStatusCodeValue());
|
||||
assertFalse(routerRepository.findByRouterKey("svc-hall.demo-judge").isPresent());
|
||||
|
||||
// 登记 → 行落库,归属=签名 appKey
|
||||
assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class)
|
||||
.getBody().get("code").asInt(), "自注册应成功");
|
||||
FlwRouter row = routerRepository.findByRouterKey("svc-hall.demo-judge").get();
|
||||
assertEquals(APP_KEY, row.getRegAppKey());
|
||||
assertEquals(APP_KEY, row.getServiceKey(), "serviceKey 填归属 appKey(管理端『来源』展示用)");
|
||||
assertEquals("http://open-gateway-service:10013/svc-hall", row.getDirectHost());
|
||||
assertEquals("/oapi/flow/route/judge", row.getPath());
|
||||
assertEquals(1, row.getStatus().intValue());
|
||||
Long id = row.getId();
|
||||
|
||||
// 管理员停用后自注册 → 仍成功但不翻正 status(控制权在管理员)
|
||||
routerAdminService.setStatus(id, false);
|
||||
assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class).getBody().get("code").asInt());
|
||||
assertEquals(0, routerRepository.findByRouterKey("svc-hall.demo-judge").get().getStatus().intValue(),
|
||||
"停用状态不被自注册翻正");
|
||||
|
||||
// 重复登记(改名+换址)→ 同一行更新,不新增
|
||||
String again = "{\"routerKey\":\"svc-hall.demo-judge\",\"name\":\"演示判断2\","
|
||||
+ "\"judgePath\":\"/oapi/flow/route/judge2\",\"directHost\":\"http://new-host:9000/svc-hall\"}";
|
||||
assertEquals(0, client.post("/oapi/flow/router/reg", again, JsonNode.class)
|
||||
.getBody().get("code").asInt());
|
||||
FlwRouter updated = routerRepository.findByRouterKey("svc-hall.demo-judge").get();
|
||||
assertEquals(id, updated.getId(), "幂等 upsert,不应产生第二行");
|
||||
assertEquals("演示判断2", updated.getName());
|
||||
assertEquals("http://new-host:9000/svc-hall", updated.getDirectHost());
|
||||
assertEquals("/oapi/flow/route/judge2", updated.getPath());
|
||||
|
||||
// 校验项拒绝
|
||||
assertNotEquals(0, client.post("/oapi/flow/router/reg",
|
||||
"{\"routerKey\":\"a.b\",\"name\":\"n\",\"judgePath\":\"oapi/x\",\"directHost\":\"http://h:1\"}",
|
||||
JsonNode.class).getBody().get("code").asInt(), "judgePath 缺前导 / 应拒绝");
|
||||
assertNotEquals(0, client.post("/oapi/flow/router/reg",
|
||||
"{\"routerKey\":\"a.b\",\"name\":\"n\",\"judgePath\":\"/oapi/x\",\"directHost\":\"h:1\"}",
|
||||
JsonNode.class).getBody().get("code").asInt(), "directHost 非 http(s) 基址应拒绝");
|
||||
assertNotEquals(0, client.post("/oapi/flow/router/reg",
|
||||
"{\"name\":\"n\",\"judgePath\":\"/oapi/x\",\"directHost\":\"http://h:1\"}",
|
||||
JsonNode.class).getBody().get("code").asInt(), "routerKey 缺失应拒绝");
|
||||
}
|
||||
|
||||
/**
|
||||
* 自注册行天然满足 SERVICE 发布校验(2026-10-12 验收项):DefValidator 只认 routerKey
|
||||
* 已注册且启用,与 serviceKey 形态无关(自注册行 serviceKey 填的是数值 appKey)。
|
||||
*/
|
||||
@Test
|
||||
void routerReg_selfRegisteredRow_passesServicePublishValidation() {
|
||||
mapDept("km", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
String reg = "{\"routerKey\":\"svc-hall.cond-judge\",\"name\":\"条件判断\","
|
||||
+ "\"judgePath\":\"/oapi/flow/route/judge\",\"directHost\":\"http://h:1/svc-hall\"}";
|
||||
assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class)
|
||||
.getBody().get("code").asInt());
|
||||
|
||||
FlwDef def = saveAndPublishDef("t17", "自注册引用", conditionDef("svc-hall.cond-judge", "km", "kb"));
|
||||
assertEquals(1, def.getVersion().intValue(), "引用自注册行的 SERVICE 定义应发布成功");
|
||||
}
|
||||
|
||||
// ===== 发起时指派契约(2026-10-13):apply/preview + apply assigns 严格校验 =====
|
||||
|
||||
/** 指派契约用例定义:START → n1(可配 assignPolicy)→ n2(不开指派)→ END,两节点同科室 */
|
||||
private String assignDef(String deptTag, String n1Policy) {
|
||||
return "{"
|
||||
+ "\"code\":\"t40\",\"name\":\"指派契约流程\",\"start\":\"start\","
|
||||
+ "\"nodes\":["
|
||||
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
|
||||
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"首审\","
|
||||
+ "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + dk(deptTag) + "\"},"
|
||||
+ "\"countersign\":\"ANY\"" + n1Policy + "},"
|
||||
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复审\","
|
||||
+ "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + dk(deptTag) + "\"}},"
|
||||
+ "{\"id\":\"end\",\"type\":\"END\"}"
|
||||
+ "],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"n2\"},"
|
||||
+ "{\"from\":\"n2\",\"to\":\"end\"}],"
|
||||
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
|
||||
}
|
||||
|
||||
/** 预览契约:只暴露开启指派节点;candidates 带姓名且剔除提交人;selection 缺省补齐;countersign 透传 */
|
||||
@Test
|
||||
void applyPreview_exposesOnlyDesignatableNodes() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("ybb", 201, "张三");
|
||||
FlwDef def = saveAndPublishDef("t41", "指派预览",
|
||||
assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1}"));
|
||||
ResponseEntity<JsonNode> resp = client.get("/oapi/flow/apply/preview?schId=1&flowGuid="
|
||||
+ def.getFlowGuid() + "&submitterId=201", JsonNode.class);
|
||||
assertEquals(200, resp.getStatusCodeValue());
|
||||
assertEquals(0, resp.getBody().get("code").asInt());
|
||||
JsonNode data = unwrap(resp.getBody());
|
||||
assertEquals(def.getFlowGuid(), data.get("flowGuid").asText());
|
||||
JsonNode nodes = data.get("nodes");
|
||||
assertEquals(1, nodes.size(), "只暴露开启指派的节点(n2 未开不透出)");
|
||||
JsonNode n1 = nodes.get(0);
|
||||
assertEquals("n1", n1.get("nodeId").asText());
|
||||
assertEquals("首审", n1.get("nodeName").asText());
|
||||
assertEquals("ANY", n1.get("countersign").asText(), "countersign 透传");
|
||||
JsonNode selection = n1.get("selection");
|
||||
assertEquals("MULTI", selection.get("mode").asText());
|
||||
assertEquals(1, selection.get("min").asInt());
|
||||
assertEquals(1, selection.get("max").asInt(), "max 缺省=剔除提交人后的候选数");
|
||||
assertFalse(selection.get("required").asBoolean(), "required 缺省补 false");
|
||||
JsonNode candidates = n1.get("candidates");
|
||||
assertEquals(1, candidates.size(), "候选剔除提交人本人");
|
||||
assertEquals(101, candidates.get(0).get("userId").asInt());
|
||||
assertEquals("甲", candidates.get(0).get("userName").asText());
|
||||
}
|
||||
|
||||
/** 预览降级(I6):开启节点挂无映射科室 → 该节点 candidates=[],接口照常 200 */
|
||||
@Test
|
||||
void applyPreview_deadDept_degradesToEmptyCandidates() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("t41b", "指派预览降级",
|
||||
assignDef("nobody", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":2}"));
|
||||
ResponseEntity<JsonNode> resp = client.get("/oapi/flow/apply/preview?schId=1&flowGuid="
|
||||
+ def.getFlowGuid() + "&submitterId=201", JsonNode.class);
|
||||
assertEquals(200, resp.getStatusCodeValue());
|
||||
assertEquals(0, resp.getBody().get("code").asInt());
|
||||
JsonNode n1 = unwrap(resp.getBody()).get("nodes").get(0);
|
||||
assertEquals("n1", n1.get("nodeId").asText());
|
||||
assertEquals(0, n1.get("candidates").size(), "解析异常节点降级空候选");
|
||||
}
|
||||
|
||||
/** 合法 assigns:200 + 快照落库(姓名走 requireUserName 严格口径)+ 只为被指派人建待办 */
|
||||
@Test
|
||||
void apply_withAssigns_ok() throws Exception {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("ybb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t42", "指派发起",
|
||||
assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":2}"));
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-as1\",\"bizTitle\":\"指派件\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\","
|
||||
+ "\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[101]}]}";
|
||||
ResponseEntity<JsonNode> resp = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
|
||||
assertEquals(0, resp.getBody().get("code").asInt());
|
||||
Long instanceId = unwrap(resp.getBody()).get("instanceId").asLong();
|
||||
JsonNode snap = objectMapper.readTree(
|
||||
instanceRepository.findById(instanceId).get().getAssignJson());
|
||||
assertEquals(101, snap.get("n1").get(0).get("userId").asInt());
|
||||
assertEquals("用户101", snap.get("n1").get(0).get("userName").asText(), "姓名=UserBase 严格口径");
|
||||
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
|
||||
assertEquals(101, taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getAssigneeId());
|
||||
}
|
||||
|
||||
/** 拒绝矩阵(I7/I3):六种非法 assigns 均业务码拒绝且办件未创建 */
|
||||
@Test
|
||||
void apply_assigns_rejected_matrix() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("ybb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("t43", "指派拒绝矩阵",
|
||||
assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":1}"));
|
||||
FlwDef singleDef = saveAndPublishDef("t44", "单选拒绝",
|
||||
assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"SINGLE\"}"));
|
||||
|
||||
assertAssignRejected(def.getFlowGuid(), "as-x1", ",\"assigns\":[{\"nodeId\":\"n2\",\"userIds\":[101]}]}",
|
||||
"未开启指派节点应拒绝");
|
||||
assertAssignRejected(def.getFlowGuid(), "as-x2", ",\"assigns\":[{\"nodeId\":\"n9\",\"userIds\":[101]}]}",
|
||||
"nodeId 不存在应拒绝");
|
||||
assertAssignRejected(def.getFlowGuid(), "as-x3", ",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[999]}]}",
|
||||
"越候选集应拒绝");
|
||||
assertAssignRejected(def.getFlowGuid(), "as-x4", ",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[201]}]}",
|
||||
"指派发起人本人应拒绝");
|
||||
assertAssignRejected(singleDef.getFlowGuid(), "as-x5",
|
||||
",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[101,102]}]}", "SINGLE 传 2 人应拒绝");
|
||||
assertAssignRejected(def.getFlowGuid(), "as-x6",
|
||||
",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[101,102]}]}", "MULTI 超 max 应拒绝");
|
||||
}
|
||||
|
||||
/** 断言一次带 assigns 的 apply 被业务码拒绝且办件未创建 */
|
||||
private void assertAssignRejected(String flowGuid, String bizId, String assignsSegment, String message) {
|
||||
String body = "{"
|
||||
+ "\"flowGuid\":\"" + flowGuid + "\","
|
||||
+ "\"bizType\":\"svc_item\",\"bizId\":\"" + bizId + "\",\"bizTitle\":\"矩阵\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"" + assignsSegment;
|
||||
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(),
|
||||
message + "(办件未创建)");
|
||||
}
|
||||
|
||||
/** 轮询等异步回调推送(AFTER_COMMIT + 推送池),最长约 5s */
|
||||
private boolean awaitPayloadContains(List<String> payloads, String needle) throws InterruptedException {
|
||||
for (int i = 0; i < 25; i++) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.yunhai.flowcenter.oapi;
|
||||
|
||||
import com.yunhai.flowcenter.controller.oapi.OapiFlowController;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.service.FlowQueryService;
|
||||
import com.yunhai.flowcenter.service.FlowRouterAdminService;
|
||||
import com.yunhai.flowcenter.service.OapiFlowService;
|
||||
import com.yunhai.platform.utils.model.BasePageResult;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
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.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* P3.5 新增 oapi 端点(/oapi/flow/tasks/done、/oapi/flow/cc)的控制器契约测试(standalone,无 DB)。
|
||||
* 核心断言=<b>过滤接缝</b>(P2-02 裁决):过滤键是验签身份 appKey(请求属性 yh.app.auth →
|
||||
* queryService 第三参,落 d.appKey 过滤),<b>不收调用方 bizType</b>——bizType 参数即使传了
|
||||
* 也不参与过滤(防裸透传/跨应用探查)。数据正确性由既有 pageDone/pageCc 的 IT 覆盖,
|
||||
* 本类只钉 HTTP 契约与接缝。
|
||||
*/
|
||||
@DisplayName("P3.5 oapi done/cc - 签名 appKey 过滤接缝契约")
|
||||
class OapiFlowP35DoneCcContractTest {
|
||||
|
||||
private FlowQueryService queryService;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
OapiFlowService flowService = mock(OapiFlowService.class);
|
||||
FlowRouterAdminService routerAdminService = mock(FlowRouterAdminService.class);
|
||||
queryService = mock(FlowQueryService.class);
|
||||
OapiFlowController controller = new OapiFlowController(flowService, routerAdminService, queryService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
when(queryService.pageDone(any(), any(), any(), any(), any()))
|
||||
.thenReturn(emptyPage());
|
||||
when(queryService.pageCc(any(), any(), any(), any(), any()))
|
||||
.thenReturn(new BasePageResult<>(Collections.emptyList(), 0, 1, 10, 0, false));
|
||||
}
|
||||
|
||||
private static BasePageResult<FlowModels.TaskItem> emptyPage() {
|
||||
return new BasePageResult<>(Collections.emptyList(), 0, 1, 10, 0, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("done:验签 appKey 作为过滤键下传(d.appKey 接缝),分页参数透传")
|
||||
void donePassesSigningAppKeyAsFilter() throws Exception {
|
||||
mockMvc.perform(get("/oapi/flow/tasks/done")
|
||||
.param("schId", "3").param("assigneeId", "10006")
|
||||
.param("page", "2").param("size", "10")
|
||||
.requestAttr("yh.app.auth", "100008"))
|
||||
.andExpect(status().isOk());
|
||||
verify(queryService).pageDone(eq(3L), eq(10006), eq("100008"), eq(2), eq(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("done:调用方带 bizType 也不参与过滤(防裸透传)")
|
||||
void doneIgnoresCallerBizType() throws Exception {
|
||||
mockMvc.perform(get("/oapi/flow/tasks/done")
|
||||
.param("schId", "3").param("assigneeId", "10006")
|
||||
.param("bizType", "svc-hall")
|
||||
.requestAttr("yh.app.auth", "100008"))
|
||||
.andExpect(status().isOk());
|
||||
// 第三参(过滤键)必须是签名 appKey,不是 bizType
|
||||
verify(queryService).pageDone(eq(3L), eq(10006), eq("100008"), Mockito.isNull(), Mockito.isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("done:缺 schId/assigneeId → 400,零查询")
|
||||
void doneMissingRequiredParams() throws Exception {
|
||||
mockMvc.perform(get("/oapi/flow/tasks/done").param("schId", "3"))
|
||||
.andExpect(status().is4xxClientError());
|
||||
verify(queryService, never()).pageDone(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cc:验签 appKey 过滤键 + userId 维度")
|
||||
void ccPassesSigningAppKeyAsFilter() throws Exception {
|
||||
mockMvc.perform(get("/oapi/flow/cc")
|
||||
.param("schId", "3").param("userId", "10006")
|
||||
.param("page", "1").param("size", "20")
|
||||
.requestAttr("yh.app.auth", "100008"))
|
||||
.andExpect(status().isOk());
|
||||
verify(queryService).pageCc(eq(3L), eq(10006), eq("100008"), eq(1), eq(20));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cc:缺 userId → 400,零查询")
|
||||
void ccMissingUserId() throws Exception {
|
||||
mockMvc.perform(get("/oapi/flow/cc").param("schId", "3"))
|
||||
.andExpect(status().is4xxClientError());
|
||||
verify(queryService, never()).pageCc(any(), any(), any(), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.yunhai.flowcenter.oapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.service.OapiFlowService;
|
||||
import com.yunhai.flowcenter.support.FlowTestSupport;
|
||||
import com.yunhai.flowcenter.support.OapiSignedClient;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* GET /oapi/flow/tasks/by-biz 契约(v2.1 应用内审批·业务详情卡片数据源)。
|
||||
* 核心是 0/1/N 三种计数语义与"不探测他人办件":办件不存在、错校、错 bizType、他人待办一律
|
||||
* HTTP 200 + tasks=[],出参不区分(区分即等于告诉调用方"这个单据上有个待办,只是不是你的")。
|
||||
* 注意:本类必须单类独跑(平台 IT 判据)。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
class OapiFlowTasksByBizTest extends FlowTestSupport {
|
||||
|
||||
private static final String APP_KEY = "100018";
|
||||
private static final String APP_SECRET = "svc-hall-dev-secret-100018";
|
||||
private static final String BIZ = "notice";
|
||||
|
||||
@LocalServerPort
|
||||
private int port;
|
||||
|
||||
@Autowired
|
||||
private OapiFlowService flowService;
|
||||
|
||||
private OapiSignedClient client;
|
||||
|
||||
@Autowired
|
||||
private javax.sql.DataSource dataSource;
|
||||
|
||||
@TestConfiguration
|
||||
static class StubConfig {
|
||||
@Bean
|
||||
@Primary
|
||||
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
|
||||
return new com.yunhai.flowcenter.stub.StubUserClient();
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// 在 cleanDb 之前验证真实连接,不能只相信环境变量已覆盖 profile。
|
||||
try (java.sql.Connection connection = dataSource.getConnection()) {
|
||||
String schema = connection.getCatalog();
|
||||
String host = java.net.URI.create(connection.getMetaData().getURL().substring(5)).getHost();
|
||||
System.out.println("CI_DB_TARGET=" + host + "/" + schema);
|
||||
assertTrue("127.0.0.1".equals(host) || "localhost".equals(host)
|
||||
|| (host != null && host.startsWith("mavenbuild-") && host.endsWith("-db")),
|
||||
"真库门禁只允许本机或本次 CI 容器");
|
||||
assertTrue("bsch_flowcenter_test".equals(schema) || "flowcenter_ci".equals(schema)
|
||||
|| (schema != null && schema.startsWith("flowcenter_ci_")), "拒绝清理非测试 schema");
|
||||
if (System.getenv("CI_TEST_DB_URL") != null) {
|
||||
assertTrue(schema.startsWith("flowcenter_ci"), "CI 必须实际连接独立 schema");
|
||||
}
|
||||
}
|
||||
cleanDb();
|
||||
client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
|
||||
}
|
||||
|
||||
/** 代发起一件并回带 instanceId(bizId 唯一,供 by-biz 定位) */
|
||||
private Long applyOne(FlwDef def, String bizId, String title) {
|
||||
ResponseEntity<JsonNode> resp = client.post("/oapi/flow/apply?schId=1", "{"
|
||||
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
|
||||
+ "\"bizType\":\"" + BIZ + "\",\"bizId\":\"" + bizId + "\",\"bizTitle\":\"" + title + "\","
|
||||
+ "\"submitterId\":201,\"submitterName\":\"张三\"}", JsonNode.class);
|
||||
assertEquals(0, resp.getBody().get("code").asInt(), "代发起应成功:" + resp.getBody());
|
||||
return resp.getBody().get("data").get("instanceId").asLong();
|
||||
}
|
||||
|
||||
private JsonNode byBiz(String bizId, int operatorId) {
|
||||
return byBiz(SCH, BIZ, bizId, operatorId);
|
||||
}
|
||||
|
||||
private JsonNode byBiz(long schId, String bizType, String bizId, int operatorId) {
|
||||
ResponseEntity<JsonNode> resp = client.get("/oapi/flow/tasks/by-biz?schId=" + schId
|
||||
+ "&bizType=" + bizType + "&bizId=" + bizId + "&operatorId=" + operatorId, JsonNode.class);
|
||||
assertEquals(200, resp.getStatusCodeValue(), "by-biz 恒 HTTP 200(空结果不报错)");
|
||||
assertEquals(0, resp.getBody().get("code").asInt(), "业务码须为成功:" + resp.getBody());
|
||||
return resp.getBody().get("data");
|
||||
}
|
||||
|
||||
/** 签名强制:无签名头 401(与其余 /oapi 端点同纪律) */
|
||||
@Test
|
||||
void requiresAppSignature_401() {
|
||||
assertEquals(401, client.getRaw(
|
||||
"/oapi/flow/tasks/by-biz?schId=1&bizType=notice&bizId=x&operatorId=101").getStatusCodeValue());
|
||||
}
|
||||
|
||||
/** 缺参/非正数 → 业务码拒绝(身份参数不允许"可选"降级成越权查询) */
|
||||
@Test
|
||||
void badParams_rejected() {
|
||||
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=1&bizType=notice&bizId=x",
|
||||
JsonNode.class).getBody(), "缺 operatorId 应拒绝");
|
||||
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=1&bizId=x&operatorId=101",
|
||||
JsonNode.class).getBody(), "缺 bizType 应拒绝");
|
||||
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=0&bizType=notice&bizId=x&operatorId=101",
|
||||
JsonNode.class).getBody(), "schId<=0 应拒绝");
|
||||
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=1&bizType=notice&bizId=x&operatorId=0",
|
||||
JsonNode.class).getBody(), "operatorId<=0 应拒绝");
|
||||
}
|
||||
|
||||
/** 0 条·无办件:instanceId/status/currentNodes 均 null,tasks 空数组(不是 null,前端不必判空指针) */
|
||||
@Test
|
||||
void noInstance_empty() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
saveAndPublishDef("bb1", "无办件", linearDef("ybb", "审核"));
|
||||
JsonNode data = byBiz("ghost-1", 101);
|
||||
assertTrue(data.get("instanceId").isNull(), "无办件 instanceId 须 null");
|
||||
assertTrue(data.get("status").isNull(), "无办件 status 须 null");
|
||||
assertTrue(data.get("currentNodes").isNull(), "无办件 currentNodes 须 null");
|
||||
assertEquals(0, data.get("tasks").size());
|
||||
assertEquals("notice", data.get("bizType").asText(), "入参 bizType 原样回显");
|
||||
assertEquals("ghost-1", data.get("bizId").asText(), "入参 bizId 原样回显");
|
||||
}
|
||||
|
||||
/** 0 条·办件存在但本人无待办(已办结):仍 200 + 空,且带出办件状态供卡片渲染终态 */
|
||||
@Test
|
||||
void finishedInstance_emptyWithStatus() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("bb2", "已办结", linearDef("ybb", "审核"));
|
||||
Long instanceId = applyOne(def, "done-1", "已办结通知");
|
||||
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
|
||||
assertEquals(0, client.post("/oapi/flow/task/" + taskId
|
||||
+ "/approve?schId=1&operatorId=101&operatorName=甲", "{}", JsonNode.class)
|
||||
.getBody().get("code").asInt(), "办理人同意应成功");
|
||||
JsonNode data = byBiz("done-1", 101);
|
||||
assertEquals(0, data.get("tasks").size(), "办结后本人无待办");
|
||||
assertEquals(20, data.get("status").asInt(), "终态可展示:回带办件状态");
|
||||
assertEquals(instanceId, data.get("instanceId").asLong());
|
||||
}
|
||||
|
||||
/** 1 条·单出口:outlets 空数组(与 FlowQueryService.detail 同口径,单出口不给前端多余分支) */
|
||||
@Test
|
||||
void oneTask_singleOutlet_noOutlets() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("bb3", "单出口", linearDef("ybb", "审核"));
|
||||
Long instanceId = applyOne(def, "one-1", "单出口通知");
|
||||
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
|
||||
JsonNode data = byBiz("one-1", 101);
|
||||
assertEquals(1, data.get("tasks").size());
|
||||
JsonNode task = data.get("tasks").get(0);
|
||||
assertEquals(taskId, task.get("taskId").asLong());
|
||||
assertEquals("n1", task.get("nodeId").asText());
|
||||
assertEquals("审核", task.get("nodeName").asText());
|
||||
assertNotNull(task.get("arriveTime").asText());
|
||||
assertEquals(0, task.get("outlets").size(), "单出口不透出 outlets");
|
||||
assertEquals(10, data.get("status").asInt(), "审批中");
|
||||
// currentNodes 是办件上的节点 **id** 逗号串(与 /oapi/flow/instances、progress 同一原文口径,
|
||||
// 本端点不做名字化——改名会让同名字段在两个 oapi 里语义分叉)。人读节点名取 tasks[].nodeName。
|
||||
assertEquals("n1", data.get("currentNodes").asText());
|
||||
}
|
||||
|
||||
/** 1 条·多出口:outlets 逐字给出 name/toNodeId/toNodeName,供卡片"通过"弹窗选出口 */
|
||||
@Test
|
||||
void oneTask_multiOutlet_exposesOutlets() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("bb4", "多出口", multiOutletDef("ybb", "kb"));
|
||||
Long instanceId = applyOne(def, "mo-1", "多出口通知");
|
||||
JsonNode data = byBiz("mo-1", 101);
|
||||
assertEquals(instanceId, data.get("instanceId").asLong(), "多出口件同样回带办件定位");
|
||||
JsonNode task = data.get("tasks").get(0);
|
||||
assertEquals("入口审核", task.get("nodeName").asText());
|
||||
JsonNode outlets = task.get("outlets");
|
||||
assertEquals(2, outlets.size(), "n1 出边 2 条须全部透出");
|
||||
assertEquals("转乙科", outlets.get(0).get("name").asText());
|
||||
assertEquals("n2", outlets.get(0).get("toNodeId").asText());
|
||||
assertEquals("复核", outlets.get(0).get("toNodeName").asText());
|
||||
assertEquals("通过", outlets.get(1).get("name").asText());
|
||||
assertEquals("end", outlets.get(1).get("toNodeId").asText());
|
||||
}
|
||||
|
||||
/** N 条:并行两支都指派同一人 → tasks 两条、按 taskId 升序;卡片据此引导列表/任务页,不猜选 */
|
||||
@Test
|
||||
void multiTask_returnsAllSorted() {
|
||||
mapDept("ka", 101, "甲");
|
||||
mapDept("kb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("bb5", "并行同人", parallelDef("ka", "kb"));
|
||||
Long instanceId = applyOne(def, "mt-1", "多待办通知");
|
||||
List<Long> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0)
|
||||
.stream().map(com.yunhai.flowcenter.entity.FlwTask::getId)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
assertEquals(2, pending.size(), "前置数据:本人两条并行待办");
|
||||
JsonNode data = byBiz("mt-1", 101);
|
||||
JsonNode tasks = data.get("tasks");
|
||||
assertEquals(2, tasks.size(), "N 条全返回,不合并、不猜选");
|
||||
long first = tasks.get(0).get("taskId").asLong();
|
||||
long second = tasks.get(1).get("taskId").asLong();
|
||||
assertTrue(first < second, "须按 taskId 升序:" + first + " -> " + second);
|
||||
}
|
||||
|
||||
/** 他人待办 → 空(关键防探测:并行两支分属两人时,非办理人查同一 bizId 得到 tasks=[]) */
|
||||
@Test
|
||||
void otherAssignee_empty() {
|
||||
mapDept("ka", 101, "甲");
|
||||
mapDept("kb", 102, "乙");
|
||||
FlwDef def = saveAndPublishDef("bb6", "并行他人", parallelDef("ka", "kb"));
|
||||
Long instanceId = applyOne(def, "ot-1", "他人待办通知");
|
||||
assertEquals(2, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(),
|
||||
"前置数据:两条待办分属 101/102");
|
||||
JsonNode asOutsider = byBiz("ot-1", 999);
|
||||
assertEquals(0, asOutsider.get("tasks").size(), "非办理人 → 空");
|
||||
assertNotNull(asOutsider.get("instanceId"), "但办件定位字段照常回带");
|
||||
assertEquals(1, byBiz("ot-1", 101).get("tasks").size(), "办理人本人查得到");
|
||||
// 出参不得泄露他人办理人
|
||||
assertFalse(byBiz("ot-1", 101).toString().contains("102"), "不得透出他人 assigneeId");
|
||||
assertFalse(byBiz("ot-1", 101).toString().contains("乙"), "不得透出他人姓名");
|
||||
}
|
||||
|
||||
/** 错学校 / 错 bizType → 空(租户与业务类型收敛在定位查询本身) */
|
||||
@Test
|
||||
void wrongSchoolOrBizType_empty() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("bb7", "跨租户", linearDef("ybb", "审核"));
|
||||
applyOne(def, "ws-1", "跨租户通知");
|
||||
JsonNode otherSchool = byBiz(2L, BIZ, "ws-1", 101);
|
||||
assertEquals(0, otherSchool.get("tasks").size(), "错学校 → 空");
|
||||
assertTrue(otherSchool.get("instanceId").isNull(), "错学校不得回带 instanceId");
|
||||
JsonNode otherType = byBiz(SCH, "svc_item", "ws-1", 101);
|
||||
assertEquals(0, otherType.get("tasks").size(), "错 bizType → 空");
|
||||
assertTrue(otherType.get("instanceId").isNull(), "错 bizType 不得回带 instanceId");
|
||||
}
|
||||
|
||||
/** 定义缺失(def 行被清理)→ 任务照常返回,outlets 降级空数组,不 500 */
|
||||
@Test
|
||||
void missingDef_degradesWithoutOutlets() {
|
||||
mapDept("ybb", 101, "甲");
|
||||
FlwDef def = saveAndPublishDef("bb8", "定义缺失", linearDef("ybb", "审核"));
|
||||
Long instanceId = applyOne(def, "md-1", "定义缺失通知");
|
||||
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
|
||||
defRepository.delete(def);
|
||||
JsonNode task = byBiz("md-1", 101).get("tasks").get(0);
|
||||
assertEquals(0, task.get("outlets").size(), "无定义时 outlet 计算降级空");
|
||||
assertEquals(taskId, task.get("taskId").asLong(), "任务本身照常返回");
|
||||
}
|
||||
|
||||
/** 服务层直调:空白 bizId 拒绝(URL 上 bizId= 空串不得退化成"查全部") */
|
||||
@Test
|
||||
void service_blankBizParams_rejected() {
|
||||
assertThrows(BizException.class, () -> flowService.tasksByBiz(1L, "notice", " ", 101));
|
||||
assertThrows(BizException.class, () -> flowService.tasksByBiz(1L, null, "1", 101));
|
||||
assertThrows(BizException.class, () -> flowService.tasksByBiz(null, "notice", "1", 101));
|
||||
}
|
||||
|
||||
private static void assertNotEqualsZero(JsonNode body, String message) {
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(Integer.valueOf(0),
|
||||
Integer.valueOf(body.get("code").asInt()), message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import com.yunhai.platform.openapi.core.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppCodeFormCenterRoutingTest {
|
||||
@Test
|
||||
void realSdkNeedsOnlyGatewayForCatalogAndExactSchema() throws Exception {
|
||||
List<String> paths = new ArrayList<>();
|
||||
HttpServer gateway = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
gateway.createContext("/", exchange -> {
|
||||
String path = exchange.getRequestURI().getPath(); paths.add(path);
|
||||
String data = path.endsWith("/schema")
|
||||
? "{\"formUuid\":\"test-form\",\"version\":3,\"groups\":[],\"audience\":[]}"
|
||||
: "{\"items\":[],\"total\":0}";
|
||||
byte[] bytes = ("{\"code\":0,\"data\":" + data + "}").getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, bytes.length);
|
||||
exchange.getResponseBody().write(bytes); exchange.close();
|
||||
});
|
||||
gateway.start();
|
||||
try {
|
||||
OpenApiProperties open = new OpenApiProperties();
|
||||
open.setBaseHost("http://127.0.0.1:" + gateway.getAddress().getPort());
|
||||
YschOpenCall call = new YschOpenCall(new YschCallProperties(), open, new ObjectMapper(), null);
|
||||
assertFalse(call.hasTarget("form-center"));
|
||||
assertEquals(0, new FormCenterCatalogClient(call).listForms(1L, null, null, 1, 20).getTotal());
|
||||
assertEquals(3, new FormCenterSchemaClient(call, new ObjectMapper())
|
||||
.fetchSchema(1L, "test-form", 3).get("formVersion"));
|
||||
assertEquals(java.util.Arrays.asList("/form-center/oapi/form/forms", "/form-center/oapi/form/schema"), paths);
|
||||
} finally {
|
||||
gateway.stop(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 布局单源 golden 用例(2026-10-03 三视图统一):PNG 与 DiagramVo.positions 共同消费这份几何。
|
||||
* 数值断言 ±1 容差(int 除法);关系断言(列距/居中/归零)与常量解耦,防调参误报。
|
||||
* 布局单源 golden 用例(2026-10-03 三视图统一;2026-10-09 竖排改写):PNG 与 DiagramVo.positions
|
||||
* 共同消费这份几何。数值断言 ±1 容差(int 除法);关系断言(行距/居中/归零)与常量解耦,防调参误报。
|
||||
*/
|
||||
class DiagramLayoutServiceTest {
|
||||
|
||||
@@ -41,12 +41,16 @@ class DiagramLayoutServiceTest {
|
||||
return service.layout(nodes, new ArrayList<>(Arrays.asList(edges)));
|
||||
}
|
||||
|
||||
private int cx(int[] b) {
|
||||
return b[0] + b[2] / 2;
|
||||
}
|
||||
|
||||
private int cy(int[] b) {
|
||||
return b[1] + b[3] / 2;
|
||||
}
|
||||
|
||||
@Test
|
||||
void linear_xPitchByColumn_andZeroMin() {
|
||||
void linear_rowPitchByLevel_centeredAndZeroMin() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("a", FlowDefModel.TYPE_APPROVAL),
|
||||
@@ -56,8 +60,16 @@ class DiagramLayoutServiceTest {
|
||||
for (FlowModels.NodeVo n : nodes) {
|
||||
assertNotNull(box.get(n.getId()), "缺坐标:" + n.getId());
|
||||
}
|
||||
// 同宽节点(审批→审批)x 差 = 列距 SLOT_W+GAP_X(±1 容差)
|
||||
assertEquals(DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X, box.get("b")[0] - box.get("a")[0], 1);
|
||||
// 纯串行:相邻层 y 差 = 行距 SLOT_H+GAP_Y(±1 容差;顶差仅同盒高成立,跨盒型断中心差),
|
||||
// 主链各层单列块水平居中 → 中心 x 相等
|
||||
assertEquals(DiagramLayoutService.SLOT_H + DiagramLayoutService.GAP_Y, box.get("b")[1] - box.get("a")[1], 1);
|
||||
assertEquals(DiagramLayoutService.SLOT_H + DiagramLayoutService.GAP_Y,
|
||||
cy(box.get("a")) - cy(box.get("start")), 1);
|
||||
assertEquals(DiagramLayoutService.SLOT_H + DiagramLayoutService.GAP_Y,
|
||||
cy(box.get("end")) - cy(box.get("b")), 1);
|
||||
assertEquals(cx(box.get("start")), cx(box.get("a")), 1);
|
||||
assertEquals(cx(box.get("a")), cx(box.get("b")), 1);
|
||||
assertEquals(cx(box.get("b")), cx(box.get("end")), 1);
|
||||
// 归零:全部坐标非负且最小 x/y 为 0
|
||||
int minX = Integer.MAX_VALUE;
|
||||
int minY = Integer.MAX_VALUE;
|
||||
@@ -71,7 +83,7 @@ class DiagramLayoutServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void conditionBranches_sameColumnBlockVerticallyCentered() {
|
||||
void conditionBranches_sameRowBlockHorizontallyCentered() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("c", FlowDefModel.TYPE_CONDITION),
|
||||
@@ -80,10 +92,13 @@ class DiagramLayoutServiceTest {
|
||||
node("end", FlowDefModel.TYPE_END)));
|
||||
Map<String, int[]> box = layout(nodes,
|
||||
edge("start", "c"), edge("c", "a"), edge("c", "b"), edge("a", "end"), edge("b", "end"));
|
||||
// 两分支同列不同行,列块垂直居中:两盒中心均值 ≈ 内容区中线(单行列的中心同值)
|
||||
assertEquals(box.get("a")[0], box.get("b")[0]);
|
||||
assertTrue(box.get("a")[1] != box.get("b")[1]);
|
||||
assertEquals(cy(box.get("c")), (cy(box.get("a")) + cy(box.get("b"))) / 2, 1);
|
||||
// 同层分支:顶 y 对齐、各占一列等间距(列距 SLOT_W+GAP_X)
|
||||
assertEquals(box.get("a")[1], box.get("b")[1]);
|
||||
assertTrue(box.get("a")[0] != box.get("b")[0]);
|
||||
assertEquals(DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X,
|
||||
Math.abs(box.get("b")[0] - box.get("a")[0]), 1);
|
||||
// 整层块水平居中:两分支中心均值 ≈ 单节点层的中心(条件菱形与内容区中线同轴)
|
||||
assertEquals(cx(box.get("c")), (cx(box.get("a")) + cx(box.get("b"))) / 2, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -98,13 +113,50 @@ class DiagramLayoutServiceTest {
|
||||
Map<String, int[]> box = layout(nodes,
|
||||
edge("start", "s"), edge("s", "a"), edge("s", "b"),
|
||||
edge("a", "j"), edge("b", "j"), edge("j", "end"));
|
||||
// split 与 join 同一水平中线,且即两分支的对称轴(centerParallelGroups 生效)
|
||||
assertEquals(cy(box.get("s")), cy(box.get("j")), 1);
|
||||
assertEquals(cy(box.get("s")), (cy(box.get("a")) + cy(box.get("b"))) / 2, 1);
|
||||
// 单并行组:分支头同行水平对齐,以 SPLIT 竖直中线左右对称展开;JOIN 水平中点吸附回 SPLIT 中线
|
||||
assertEquals(box.get("a")[1], box.get("b")[1]);
|
||||
assertEquals(cx(box.get("s")), (cx(box.get("a")) + cx(box.get("b"))) / 2, 1);
|
||||
assertEquals(cx(box.get("s")), cx(box.get("j")), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedParallel_groupsCentered_noNegativeY() {
|
||||
void wideBranches_sixOutlets_spreadHorizontallyWithoutOverlap() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("c", FlowDefModel.TYPE_CONDITION),
|
||||
node("a1", FlowDefModel.TYPE_APPROVAL),
|
||||
node("a2", FlowDefModel.TYPE_APPROVAL),
|
||||
node("a3", FlowDefModel.TYPE_APPROVAL),
|
||||
node("a4", FlowDefModel.TYPE_APPROVAL),
|
||||
node("a5", FlowDefModel.TYPE_APPROVAL),
|
||||
node("a6", FlowDefModel.TYPE_APPROVAL),
|
||||
node("end", FlowDefModel.TYPE_END)));
|
||||
Map<String, int[]> box = layout(nodes,
|
||||
edge("start", "c"),
|
||||
edge("c", "a1"), edge("c", "a2"), edge("c", "a3"),
|
||||
edge("c", "a4"), edge("c", "a5"), edge("c", "a6"),
|
||||
edge("a1", "end"), edge("a2", "end"), edge("a3", "end"),
|
||||
edge("a4", "end"), edge("a5", "end"), edge("a6", "end"));
|
||||
// 6 出口同层:顶 y 对齐、水平等间距展开、两两无重叠、整层块仍居中(首末中心均值=菱形中心)
|
||||
List<int[]> branches = new ArrayList<>();
|
||||
for (int i = 1; i <= 6; i++) {
|
||||
branches.add(box.get("a" + i));
|
||||
}
|
||||
branches.sort((x, y) -> Integer.compare(x[0], y[0]));
|
||||
for (int[] b : branches) {
|
||||
assertEquals(branches.get(0)[1], b[1]);
|
||||
}
|
||||
for (int i = 1; i < branches.size(); i++) {
|
||||
assertEquals(DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X,
|
||||
branches.get(i)[0] - branches.get(i - 1)[0], 1);
|
||||
assertTrue(branches.get(i)[0] >= branches.get(i - 1)[0] + branches.get(i - 1)[2],
|
||||
"分支重叠:" + (branches.get(i)[0] - branches.get(i - 1)[0]));
|
||||
}
|
||||
assertEquals(cx(box.get("c")), (cx(branches.get(0)) + cx(branches.get(5))) / 2, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedParallel_groupsCentered_noNegativeX() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("os", FlowDefModel.TYPE_PARALLEL_SPLIT),
|
||||
@@ -122,15 +174,69 @@ class DiagramLayoutServiceTest {
|
||||
edge("p", "ij"), edge("q", "ij"),
|
||||
edge("ij", "oj"), edge("c", "oj"),
|
||||
edge("oj", "end"));
|
||||
// 内层组:is 与 {p,q} 居中;外层组:os/oj 同中线即分支对称轴
|
||||
assertEquals(cy(box.get("is")), (cy(box.get("p")) + cy(box.get("q"))) / 2, 1);
|
||||
assertEquals(cy(box.get("os")), cy(box.get("oj")), 1);
|
||||
// 全图无 y<0(centerParallelGroups 末段抬回)
|
||||
// 内层组:is 与 {p,q} 居中;外层组:os/oj 同中线即分支对称轴(内层先处理不被外层破坏)
|
||||
assertEquals(cx(box.get("is")), (cx(box.get("p")) + cx(box.get("q"))) / 2, 1);
|
||||
assertEquals(cx(box.get("os")), cx(box.get("oj")), 1);
|
||||
// 全图无 x<0(centerParallelGroups 末段推回)
|
||||
for (int[] b : box.values()) {
|
||||
assertTrue(b[1] >= 0, "出现负 y:" + b[1]);
|
||||
assertTrue(b[0] >= 0, "出现负 x:" + b[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void conditionUnequalBranches_forkIntoColumns_joinStaysOnAxis() {
|
||||
// def-32 同款(2026-10-12 用户报错场景):校长室线=抄送→科室审批(两层),其他线=直达结束;
|
||||
// 修复前各层退化单列、跳层出口边直穿中间节点。修复后:分支成员独占一列,隐式汇合点(end)
|
||||
// 留在条件节点中线上,cond→end 直落边在自己的车道里(不与分支列水平相交)。
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("n1", FlowDefModel.TYPE_APPROVAL),
|
||||
node("cond", FlowDefModel.TYPE_CONDITION),
|
||||
node("cc1", FlowDefModel.TYPE_NOTIFY),
|
||||
node("n3", FlowDefModel.TYPE_APPROVAL),
|
||||
node("end", FlowDefModel.TYPE_END)));
|
||||
Map<String, int[]> box = layout(nodes,
|
||||
edge("start", "n1"), edge("n1", "cond"),
|
||||
edge("cond", "cc1"), edge("cond", "end"),
|
||||
edge("cc1", "n3"), edge("n3", "end"));
|
||||
assertEquals(cx(box.get("cc1")), cx(box.get("n3")), 1, "校长室线成员应同列");
|
||||
assertEquals(cx(box.get("cond")), cx(box.get("end")), 1, "隐式汇合点应留在条件节点中线");
|
||||
assertEquals((DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X) / 2,
|
||||
Math.abs(cx(box.get("cond")) - cx(box.get("cc1"))), 1, "空分支时非空分支占 ±列距/2 位(与等长分支对称口径一致)");
|
||||
// 直落边车道无遮挡:cc1/n3 的盒体不得横跨 cond→end 垂直线(x=条件节点中线)
|
||||
int axis = cx(box.get("cond"));
|
||||
for (String m : new String[]{"cc1", "n3"}) {
|
||||
int[] b = box.get(m);
|
||||
assertTrue(b[0] + b[2] <= axis || b[0] >= axis, m + " 盒体跨中线会被直落边穿过");
|
||||
}
|
||||
for (int[] b : box.values()) {
|
||||
assertTrue(b[0] >= 0 && b[1] >= 0, "归零后不得有负坐标");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void conditionBothBranchesNonEmpty_multiLevelSideBySide() {
|
||||
// 两分支均多层且长度不等:A 线两层、B 线一层——各自成列、汇合点回中线
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
node("start", FlowDefModel.TYPE_START),
|
||||
node("cond", FlowDefModel.TYPE_CONDITION),
|
||||
node("a1", FlowDefModel.TYPE_APPROVAL),
|
||||
node("a2", FlowDefModel.TYPE_APPROVAL),
|
||||
node("b1", FlowDefModel.TYPE_APPROVAL),
|
||||
node("end", FlowDefModel.TYPE_END)));
|
||||
Map<String, int[]> box = layout(nodes,
|
||||
edge("start", "cond"),
|
||||
edge("cond", "a1"), edge("cond", "b1"),
|
||||
edge("a1", "a2"), edge("a2", "end"), edge("b1", "end"));
|
||||
assertEquals(cx(box.get("a1")), cx(box.get("a2")), 1, "A 线成员同列");
|
||||
assertEquals(cx(box.get("cond")), cx(box.get("end")), 1, "汇合点在中线");
|
||||
// A/B 两列分居中线两侧(±列距/2 对称)
|
||||
assertTrue((cx(box.get("a1")) < cx(box.get("cond"))) != (cx(box.get("b1")) < cx(box.get("cond"))),
|
||||
"两分支应分居中线两侧");
|
||||
assertEquals(DiagramLayoutService.SLOT_W + DiagramLayoutService.GAP_X,
|
||||
Math.abs(cx(box.get("b1")) - cx(box.get("a1"))), 1, "两分支列距");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cycle_defensive_fallbackLevelStillPositionsAllNodes() {
|
||||
List<FlowModels.NodeVo> nodes = new ArrayList<>(Arrays.asList(
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
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.model.FlowApplyRetryModels;
|
||||
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 org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
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.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 代发起幂等守卫与恢复分支的单测(answer-delivery-v2 §8,TASK_2 C.2/C.3)。
|
||||
*
|
||||
* <p>要证的正是任务书点名的那一类事故:<b>消费方响应丢失后按同业务键补发,绝不能被中心
|
||||
* 当成"用户又重提了一次"</b>。旧形状下 biz uk 命中 30/40 就走 R4 重启推进,把已拒绝/已撤回
|
||||
* 的办件重新跑起来;新分支用 {@code requestToken}/{@code attempt}/{@code retryOnly}
|
||||
* 把"同一次命令的重投"与"新一轮授权重提"分成两个事实。</p>
|
||||
*
|
||||
* <p>同时钉住三条边界:</p>
|
||||
* <ul>
|
||||
* <li>并发唯一键冲突在<b>事务边界外</b>重读,且先核调用应用/提交者/流程线——
|
||||
* 别的应用相同 bizType 键<b>不作为自己的结果</b>返回;</li>
|
||||
* <li>存量行({@code apply_round} 为 NULL)按第 1 轮处理,<b>守卫缺省是关</b>:
|
||||
* 没带更高轮次就重启不了别人的老件;</li>
|
||||
* <li>既有 R4 重提语义与流程节点算法一行未改({@code RESTARTED} 分支才走 restart)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@DisplayName("代发起请求标识与轮次守卫 - FL 侧规则单测(TASK_2)")
|
||||
class FlApplyRecoveryTest {
|
||||
|
||||
private static final long SCH = 1L;
|
||||
private static final String BIZ_TYPE = "svc-hall";
|
||||
private static final String BIZ_ID = "42";
|
||||
private static final String FLOW_GUID = "fg-1";
|
||||
private static final String TOKEN_A = "tok-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String TOKEN_B = "tok-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private FlwInstanceRepository instanceRepository;
|
||||
private FlwDefRepository defRepository;
|
||||
private FlwTaskRepository taskRepository;
|
||||
private FlwBranchRepository branchRepository;
|
||||
private FlowEngine flowEngine;
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
private FlowInstanceService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
instanceRepository = mock(FlwInstanceRepository.class);
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
branchRepository = mock(FlwBranchRepository.class);
|
||||
flowEngine = mock(FlowEngine.class);
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
service = new FlowInstanceService(instanceRepository, defRepository, taskRepository,
|
||||
branchRepository, flowEngine, eventPublisher);
|
||||
|
||||
FlwDef def = def(1L, FLOW_GUID, 3);
|
||||
when(defRepository.findById(anyLong())).thenReturn(Optional.of(def));
|
||||
FlowDefModel model = FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}");
|
||||
when(flowEngine.parseModel(any())).thenReturn(model);
|
||||
when(instanceRepository.saveAndFlush(any())).thenAnswer(call -> {
|
||||
FlwInstance row = call.getArgument(0);
|
||||
row.setId(900L);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 同一次命令的重投 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进")
|
||||
void sameTokenOnRejectedInstanceDoesNotRestart() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
|
||||
|
||||
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
|
||||
assertThat(outcome.duplicated).isTrue();
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
verify(branchRepository, never()).deleteByInstance(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST")
|
||||
void sameTokenOnApprovingInstanceIsSameRequest() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("retryOnly 命中终态(40):恢复链补发也不重启(C.2 点名不把 30/40 当网络重试)")
|
||||
void retryOnlyNeverRestartsTerminal() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.WITHDRAWN.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, true));
|
||||
|
||||
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.REPLAY_ONLY);
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.WITHDRAWN.getCode());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("10/20 命中不同 token:仍是纯幂等返回(既有语义逐字不变)")
|
||||
void approvingOrCompletedStayIdempotent() {
|
||||
FlwInstance approving = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(approving));
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
|
||||
|
||||
FlwInstance completed = instance(900L, FlwEnums.InstanceStatus.COMPLETED.getCode(), TOKEN_A, 1, "100018");
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
// ==================== 轮次守卫 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进")
|
||||
void higherRoundRestartsPerExistingR4Semantics() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false));
|
||||
|
||||
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.RESTARTED);
|
||||
assertThat(outcome.applyRound).isEqualTo(2);
|
||||
assertThat(existing.getApplyRequestToken()).isEqualTo(TOKEN_B);
|
||||
assertThat(existing.getApplyRound()).isEqualTo(2);
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
verify(flowEngine).startAdvance(any(), any());
|
||||
verify(branchRepository).deleteByInstance(900L);
|
||||
verify(taskRepository).invalidatePendingByInstance(900L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启")
|
||||
void staleRoundIsRejectedForRestart() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(existing));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND);
|
||||
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
verify(flowEngine, never()).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("存量行(apply_round 为 NULL)按第 1 轮处理:attempt=1 重投重启不了它")
|
||||
void legacyRowWithoutRoundDefaultsToGuarded() {
|
||||
FlwInstance legacy = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(legacy));
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND);
|
||||
assertThat(legacy.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
|
||||
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.RESTARTED);
|
||||
}
|
||||
|
||||
// ==================== 归属复核 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果")
|
||||
void otherApplicationSameBizKeyIsRefused() {
|
||||
FlwInstance foreign = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100020");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.of(foreign));
|
||||
|
||||
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("另一调用应用");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交者不符 / 流程线不同:同样拒绝(守卫不是可协商的提示)")
|
||||
void submitterAndFlowLineMustMatch() {
|
||||
FlwInstance otherSubmitter = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
|
||||
TOKEN_A, 1, "100018");
|
||||
otherSubmitter.setSubmitterId(2002);
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.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.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.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)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("流程线");
|
||||
}
|
||||
|
||||
// ==================== 新建与并发 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)")
|
||||
void createsWhenAbsentAndStampsIdentity() {
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.<FlwInstance>empty());
|
||||
|
||||
FlowInstanceService.GuardedOutcome outcome =
|
||||
service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
|
||||
|
||||
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.CREATED);
|
||||
assertThat(outcome.duplicated).isFalse();
|
||||
ArgumentCaptor<FlwInstance> saved = ArgumentCaptor.forClass(FlwInstance.class);
|
||||
verify(instanceRepository).saveAndFlush(saved.capture());
|
||||
assertThat(saved.getValue().getApplyRequestToken()).isEqualTo(TOKEN_A);
|
||||
assertThat(saved.getValue().getApplyRound()).isEqualTo(1);
|
||||
assertThat(saved.getValue().getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
verify(flowEngine).startAdvance(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("并发唯一键撞键:applyV2 在事务外重读既有行并核归属,返回稳定分支码而不是 500")
|
||||
void concurrentUkCollisionIsResolvedOutsideTransaction() {
|
||||
// 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.thenReturn(Optional.<FlwInstance>empty())
|
||||
.thenReturn(Optional.of(existing));
|
||||
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
|
||||
.isEqualTo(FlowApplyRetryModels.Branch.CREATED);
|
||||
|
||||
// 第二次同 token:读到了并发对手刚建的行 → SAME_REQUEST(不重启、不重复建)
|
||||
FlowInstanceService.GuardedOutcome second =
|
||||
service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
|
||||
assertThat(second.branch).isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
|
||||
assertThat(second.instanceId).isEqualTo(900L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧 apply(cmd) 路径逐字不变:不写 token/轮次,30/40 仍按 R4 重启(存量兼容)")
|
||||
void legacyApplyPathIsUnchanged() {
|
||||
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
|
||||
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
|
||||
.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()).isNull();
|
||||
assertThat(existing.getApplyRound()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception {
|
||||
FlwInstance row = instance(900L, 30, TOKEN_A, 1, "100018");
|
||||
row.setVersion(3);
|
||||
when(instanceRepository.findById(900L)).thenReturn(Optional.of(row));
|
||||
com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of(
|
||||
com.yunhai.flowcenter.engine.FlowEvent.Type.REJECTED, 900L);
|
||||
new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event);
|
||||
row.setApplyRound(2); row.setApplyRequestToken(TOKEN_B); row.setVersion(4);
|
||||
new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event);
|
||||
assertThat(event.getRoundIdentity().get("applyRound")).isEqualTo(1);
|
||||
|
||||
com.yunhai.flowcenter.repository.FlwCallbackRegRepository regs = mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class);
|
||||
com.yunhai.flowcenter.callback.CallbackLogStore store = mock(com.yunhai.flowcenter.callback.CallbackLogStore.class);
|
||||
com.yunhai.flowcenter.entity.FlwCallbackReg reg = new com.yunhai.flowcenter.entity.FlwCallbackReg();
|
||||
reg.setId(7L);
|
||||
when(regs.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)).thenReturn(java.util.Collections.singletonList(reg));
|
||||
com.yunhai.flowcenter.callback.CallbackDispatcher dispatcher = new com.yunhai.flowcenter.callback.CallbackDispatcher(
|
||||
instanceRepository, defRepository, regs, mock(com.yunhai.flowcenter.repository.FlwCallbackLogRepository.class),
|
||||
store, mock(com.yunhai.flowcenter.callback.CallbackPushService.class), new com.yunhai.flowcenter.callback.CallbackProperties(), mapper);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(dispatcher, "pushExecutor", mock(java.util.concurrent.ExecutorService.class));
|
||||
dispatcher.onFlowEvent(event);
|
||||
ArgumentCaptor<com.yunhai.flowcenter.entity.FlwCallbackLog> log = ArgumentCaptor.forClass(com.yunhai.flowcenter.entity.FlwCallbackLog.class);
|
||||
verify(store).persist(log.capture());
|
||||
com.fasterxml.jackson.databind.JsonNode data = mapper.readTree(log.getValue().getPayloadJson()).get("data");
|
||||
assertThat(data.get("applyRound").asInt()).isEqualTo(1);
|
||||
assertThat(data.get("requestToken").asText()).isEqualTo(TOKEN_A);
|
||||
}
|
||||
|
||||
// ==================== 夹具 ====================
|
||||
|
||||
/** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */
|
||||
private FlowInstanceService.ApplyCommand cmd(String token) {
|
||||
return new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID,
|
||||
"张三的测试事项", "/svc-hall/apply-detail?id=42", "{}", 1001,
|
||||
"张三", "{}", "100018", "svc-hall", null);
|
||||
}
|
||||
|
||||
private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) {
|
||||
return new FlowInstanceService.ApplyRetryPolicy(token, attempt, retryOnly);
|
||||
}
|
||||
|
||||
private FlwDef def(Long id, String flowGuid, int version) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId(id);
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid(flowGuid);
|
||||
def.setCode("leave");
|
||||
def.setName("请假");
|
||||
def.setVersion(version);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setContentJson("{\"nodes\":[],\"edges\":[]}");
|
||||
return def;
|
||||
}
|
||||
|
||||
private FlwInstance instance(Long id, int status, String token, Integer round, String appKey) {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(id);
|
||||
instance.setSchId(SCH);
|
||||
instance.setDefId(1L);
|
||||
instance.setDefCode("leave");
|
||||
instance.setDefVersion(3);
|
||||
instance.setBizType(BIZ_TYPE);
|
||||
instance.setBizId(BIZ_ID);
|
||||
instance.setStatus(status);
|
||||
instance.setApplyAppKey(appKey);
|
||||
instance.setApplyRequestToken(token);
|
||||
instance.setApplyRound(round);
|
||||
instance.setSubmitterId(1001);
|
||||
instance.setSubmitterName("张三");
|
||||
instance.setVersion(0);
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 流程定义的表单中心绑定单测(v3/TASK_1)。
|
||||
*
|
||||
* <p>三组判据:</p>
|
||||
* <ol>
|
||||
* <li><b>成对</b>:{@code formUuid} 与 {@code formVersion} 同传或都不传;只传一个即拒;
|
||||
* 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);</li>
|
||||
* <li><b>发布取确切版本</b>:带版本的关联走 {@code schema?uuid=&version=};
|
||||
* <b>中心返回的坐标与请求不符即阻断发布</b>,而不是把错的快照嵌进 def 版本行;
|
||||
* 存量 UUID-only 行发布与启用同样阻断,必须重新绑定明确版本;</li>
|
||||
* <li><b>目录客户端的调用形态</b>:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
|
||||
* 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>发布与启用按同一错误码分类;表达式三查测试同时使用明确版本。</p>
|
||||
*/
|
||||
@DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)")
|
||||
class FlFormBindingTest {
|
||||
|
||||
private static final Long SCH = 27L;
|
||||
private static final String SIMPLE_JSON = "{\"nodes\":[],\"edges\":[]}";
|
||||
|
||||
/** 含 EXPRESSION 判断的合法定义:days/grade 是表单字段,schId 是内置变量(与发布契约测试同形) */
|
||||
private static final String FLOW_JSON = "{\"start\":\"s\",\"nodes\":["
|
||||
+ "{\"id\":\"s\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"c\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
|
||||
+ "{\"id\":\"a\",\"type\":\"APPROVAL\",\"name\":\"审1\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[1]}},"
|
||||
+ "{\"id\":\"e\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":["
|
||||
+ "{\"from\":\"s\",\"to\":\"c\"},"
|
||||
+ "{\"from\":\"c\",\"to\":\"a\",\"name\":\"准\",\"condition\":\"days > 3 && schId > 0\"},"
|
||||
+ "{\"from\":\"c\",\"to\":\"e\",\"name\":\"否\",\"condition\":\"grade == 'g1'\"},"
|
||||
+ "{\"from\":\"a\",\"to\":\"e\"}]}";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private com.yunhai.flowcenter.repository.FlwAppRepository appRepository;
|
||||
private FormCenterSchemaClient schemaClient;
|
||||
private FlowDefAdminService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
appRepository = mock(com.yunhai.flowcenter.repository.FlwAppRepository.class);
|
||||
schemaClient = mock(FormCenterSchemaClient.class);
|
||||
service = new FlowDefAdminService(defRepository,
|
||||
mock(FlwRouterRepository.class),
|
||||
mock(FlwCallbackRegRepository.class),
|
||||
appRepository,
|
||||
mock(FlwInstanceRepository.class),
|
||||
mock(FlowDeptAdminService.class),
|
||||
mock(FlowEngine.class),
|
||||
new ObjectMapper(),
|
||||
schemaClient);
|
||||
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
// 归属应用白名单:一律放行(拦截语义由既有专门用例覆盖,本类只管表单坐标)
|
||||
when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
|
||||
}
|
||||
|
||||
private static FlwDef draft(String formUuid, Integer formVersion) {
|
||||
FlwDef d = new FlwDef();
|
||||
d.setId(9L);
|
||||
d.setSchId(SCH);
|
||||
d.setFlowGuid("G");
|
||||
d.setCode("work-leave");
|
||||
d.setVersion(1);
|
||||
d.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
d.setAppKey("notice");
|
||||
d.setContentJson(FLOW_JSON);
|
||||
d.setFormUuid(formUuid);
|
||||
d.setFormVersion(formVersion);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 快照(formUuid=FU1 / v3),字段够表达式键查用 */
|
||||
private static Map<String, Object> snapshot(String uuid, int version) {
|
||||
Map<String, Object> snap = new LinkedHashMap<>();
|
||||
snap.put("formUuid", uuid);
|
||||
snap.put("formVersion", version);
|
||||
snap.put("audience", Arrays.asList("STUDENT"));
|
||||
snap.put("fields", Arrays.asList(field("days", "number"), field("grade", "select")));
|
||||
return snap;
|
||||
}
|
||||
|
||||
private static Map<String, Object> field(String key, String type) {
|
||||
Map<String, Object> f = new LinkedHashMap<>();
|
||||
f.put("key", key);
|
||||
f.put("type", type);
|
||||
f.put("required", Boolean.FALSE);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ==================== ① 成对规则 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("成对提交:uuid 与 version 一起落行")
|
||||
void pairIsSavedTogether() {
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
|
||||
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
|
||||
null, null, null, "FU1", 3);
|
||||
|
||||
assertEquals("FU1", saved.getFormUuid());
|
||||
assertEquals(Integer.valueOf(3), saved.getFormVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("只传 uuid 或只传 version:拒,且不落库")
|
||||
void halfCoordinateIsRejected() {
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
|
||||
|
||||
BizException uuidOnly = assertThrows(BizException.class, () -> service.save(SCH, 9L,
|
||||
"work-leave", "请假", SIMPLE_JSON, null, null, null, "FU1", null));
|
||||
assertTrue(uuidOnly.getMessage().contains("必须成对提交"), uuidOnly.getMessage());
|
||||
|
||||
BizException versionOnly = assertThrows(BizException.class, () -> service.save(SCH, 9L,
|
||||
"work-leave", "请假", SIMPLE_JSON, null, null, null, null, 4));
|
||||
assertTrue(versionOnly.getMessage().contains("必须成对提交"), versionOnly.getMessage());
|
||||
|
||||
// 半个坐标在保存分派之前就被拦下:这一行一次都没写库
|
||||
verify(defRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("取消关联:空串 uuid 同时清空两列(哪怕请求里还带着版本号)")
|
||||
void unlinkClearsBothColumns() {
|
||||
FlwDef existing = draft("FU1", 3);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(existing));
|
||||
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
|
||||
null, null, null, " ", 7);
|
||||
|
||||
assertSame(existing, saved);
|
||||
assertNull(saved.getFormUuid());
|
||||
assertNull(saved.getFormVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("都不传=保持原关联不变(兼容旧调用面,缺省语义不能变成取消)")
|
||||
void nullMeansKeep() {
|
||||
FlwDef existing = draft("FU1", 3);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(existing));
|
||||
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "改名", SIMPLE_JSON,
|
||||
null, null, null, null, null);
|
||||
|
||||
assertEquals("FU1", saved.getFormUuid());
|
||||
assertEquals(Integer.valueOf(3), saved.getFormVersion());
|
||||
}
|
||||
|
||||
// ==================== ② 发布期确切版本 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("带确切版本发布:按 uuid+version 取快照,并把版本坐标嵌进 def 行")
|
||||
void publishUsesExactVersion() {
|
||||
FlwDef def = draft("FU1", 3);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
|
||||
|
||||
FlwDef published = service.publish(SCH, 9L);
|
||||
|
||||
verify(schemaClient).fetchSchema(SCH, "FU1", 3);
|
||||
assertTrue(published.getContentJson().contains("\"formContract\""), published.getContentJson());
|
||||
assertTrue(published.getContentJson().contains("FU1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("坐标不符:发布阻断(错误文案点名核验失败,不是拉取失败)")
|
||||
void publishBlockedOnCoordinateMismatch() {
|
||||
FlwDef def = draft("FU1", 3);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3))
|
||||
.thenThrow(new FormCenterSchemaClient.SchemaContractViolationException(
|
||||
"表单中心返回的坐标与请求不符(请求 FU1 v3,返回 FU1 v4)"));
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||
|
||||
assertEquals(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION.getCode(), e.getErrorCode().getCode());
|
||||
assertTrue(e.getMessage().contains("核验失败"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage());
|
||||
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
@DisplayName("缺版本:发布和启用均阻断,不回落当前版本")
|
||||
void missingVersionBlocksPublishAndEnable(boolean enable) {
|
||||
FlwDef def = draft("FU1", null);
|
||||
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
|
||||
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
|
||||
|
||||
assertEquals(FormBindingErrorCode.VERSION_REQUIRED.getCode(), ex.getErrorCode().getCode());
|
||||
assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
|
||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
|
||||
verify(defRepository, never()).save(any());
|
||||
}
|
||||
|
||||
private FlwDef changeStatus(boolean enable) {
|
||||
return enable ? service.enable(SCH, 9L) : service.publish(SCH, 9L);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void invalidVersionBlocksPublishAndEnable(boolean enable) {
|
||||
FlwDef def = draft("FU1", 0);
|
||||
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
|
||||
assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
|
||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
|
||||
verify(defRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void orphanVersionBlocksPublishAndEnable(boolean enable) {
|
||||
FlwDef def = draft(null, 3);
|
||||
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
|
||||
assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
|
||||
verify(defRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void schemaFailuresHaveDistinctStableCodesAndDoNotWrite(boolean enable) {
|
||||
FlwDef def = draft("FU1", 3);
|
||||
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
RuntimeException[] failures = {
|
||||
new FormCenterSchemaClient.SchemaUnavailableException("timeout", null),
|
||||
new FormCenterSchemaClient.SchemaContractViolationException("wrong version"),
|
||||
new FormCenterSchemaClient.SchemaResourceUnavailableException(null),
|
||||
new FormCenterSchemaClient.SchemaAccessDeniedException(null)
|
||||
};
|
||||
FormBindingErrorCode[] codes = {FormBindingErrorCode.SCHEMA_UNAVAILABLE,
|
||||
FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION, FormBindingErrorCode.RESOURCE_UNAVAILABLE,
|
||||
FormBindingErrorCode.ACCESS_DENIED};
|
||||
for (int i = 0; i < failures.length; i++) {
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenThrow(failures[i]);
|
||||
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
|
||||
assertEquals(codes[i].getCode(), ex.getErrorCode().getCode());
|
||||
assertSame(failures[i], ex.getCause());
|
||||
assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
|
||||
assertEquals(FLOW_JSON, def.getContentJson());
|
||||
org.mockito.Mockito.reset(schemaClient);
|
||||
}
|
||||
verify(defRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableUsesExactVersionAndPreservesPublishedSnapshot() {
|
||||
FlwDef def = draft("FU1", 3);
|
||||
def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||
def.setContentJson(FLOW_JSON.substring(0, FLOW_JSON.length() - 1)
|
||||
+ ",\"formContract\":{\"formUuid\":\"FU1\",\"formVersion\":3,\"fields\":[]}}");
|
||||
String frozenJson = def.getContentJson();
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
|
||||
FlwDef enabled = service.enable(SCH, 9L);
|
||||
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), enabled.getStatus());
|
||||
assertEquals(frozenJson, enabled.getContentJson());
|
||||
verify(schemaClient).fetchSchema(SCH, "FU1", 3);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"oops", "3.5", "3.0", "2147483648", "", "true"})
|
||||
void malformedVersionAtHttpBoundaryHasStableCode(String value) {
|
||||
FlowAdminGuard guard = mock(FlowAdminGuard.class);
|
||||
when(guard.requireAdmin(any(), any())).thenReturn(SCH);
|
||||
com.yunhai.flowcenter.controller.api.FlowAdminController controller =
|
||||
new com.yunhai.flowcenter.controller.api.FlowAdminController(guard, service,
|
||||
null, null, null, null, null, null, new ObjectMapper(), null, null, null);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("formUuid", "FU1");
|
||||
body.put("formVersion", value);
|
||||
BizException ex = assertThrows(BizException.class, () -> controller.defSave(null, body));
|
||||
assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
|
||||
verify(defRepository, never()).save(any());
|
||||
org.mockito.Mockito.verifyNoInteractions(schemaClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未关联表单的流程:一次都不碰表单中心")
|
||||
void noBindingNoCall() {
|
||||
FlwDef def = draft(null, null);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
|
||||
service.publish(SCH, 9L);
|
||||
|
||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
// ==================== ③ 客户端形态 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("精确版本请求:query 带 version;返回坐标不符即 SchemaContractViolationException")
|
||||
void schemaClientSendsVersionAndChecksCoordinate() {
|
||||
YschOpenCall openCall = mock(YschOpenCall.class);
|
||||
FormCenterSchemaClient client = new FormCenterSchemaClient(openCall, new ObjectMapper());
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("formUuid", "FU1");
|
||||
data.put("version", 3);
|
||||
data.put("code", "leave-form");
|
||||
data.put("appCode", "svc-hall");
|
||||
data.put("audience", Collections.singletonList("STUDENT"));
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
when(openCall.get(eq("form-center"), eq("/oapi/form/schema"), any(), eq(Map.class))).thenReturn(data);
|
||||
|
||||
Map<String, Object> snap = client.fetchSchema(SCH, "FU1", 3);
|
||||
assertEquals("FU1", snap.get("formUuid"));
|
||||
assertEquals(3, snap.get("formVersion"));
|
||||
|
||||
verify(openCall).get(eq("form-center"), eq("/oapi/form/schema"), query.capture(), eq(Map.class));
|
||||
assertEquals("3", query.getValue().get("version"));
|
||||
assertEquals("FU1", query.getValue().get("uuid"));
|
||||
assertEquals(String.valueOf(SCH), query.getValue().get("schId"));
|
||||
|
||||
data.put("version", 4);
|
||||
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
|
||||
() -> client.fetchSchema(SCH, "FU1", 3));
|
||||
// 坐标不符不重试:两次调用=成功一次 + 不符一次;若按可重试口径处理会变成三次
|
||||
verify(openCall, org.mockito.Mockito.times(2))
|
||||
.get(eq("form-center"), eq("/oapi/form/schema"), any(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("精确版本形态不给 version:直接拒(缺版本=旧口径,不许混着调)")
|
||||
void schemaClientRequiresExplicitVersion() {
|
||||
FormCenterSchemaClient client = new FormCenterSchemaClient(mock(YschOpenCall.class), new ObjectMapper());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(SCH, "FU1", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("目录客户端:命名目标 form-center,query 带 CAS 校域与分页;空 data 报不可用")
|
||||
void catalogClientQueryShapeAndEmptyData() {
|
||||
YschOpenCall openCall = mock(YschOpenCall.class);
|
||||
FormCenterCatalogClient client = new FormCenterCatalogClient(openCall);
|
||||
FormCenterCatalogClient.CatalogPage page = new FormCenterCatalogClient.CatalogPage();
|
||||
FormCenterCatalogClient.CatalogItem item = new FormCenterCatalogClient.CatalogItem();
|
||||
item.setFormUuid("FU1");
|
||||
item.setVersion(3);
|
||||
item.setName("请假单");
|
||||
page.setItems(Arrays.asList(item));
|
||||
page.setTotal(1);
|
||||
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
|
||||
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
|
||||
eq(FormCenterCatalogClient.CatalogPage.class))).thenReturn(page);
|
||||
|
||||
FormCenterCatalogClient.CatalogPage got = client.listForms(SCH, "svc-hall", "请假", 2, 20);
|
||||
|
||||
assertEquals(1, got.getItems().size());
|
||||
assertEquals("FU1", got.getItems().get(0).getFormUuid());
|
||||
verify(openCall).get(eq("form-center"), eq("/oapi/form/forms"), query.capture(),
|
||||
eq(FormCenterCatalogClient.CatalogPage.class));
|
||||
assertEquals(String.valueOf(SCH), query.getValue().get("schId"));
|
||||
assertEquals("svc-hall", query.getValue().get("appCode"));
|
||||
assertEquals("请假", query.getValue().get("keyword"));
|
||||
assertEquals("2", query.getValue().get("page"));
|
||||
assertEquals("20", query.getValue().get("pageSize"));
|
||||
|
||||
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
|
||||
eq(FormCenterCatalogClient.CatalogPage.class))).thenReturn(null);
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> client.listForms(SCH, null, null, null, null));
|
||||
assertTrue(e.getMessage().contains("空"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("目录不可达(网络/未授权)转可诊断 BizException,不静默给空列表")
|
||||
void catalogUnreachableIsSurfaced() {
|
||||
YschOpenCall openCall = mock(YschOpenCall.class);
|
||||
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
|
||||
eq(FormCenterCatalogClient.CatalogPage.class))).thenThrow(new IllegalStateException("connect refused"));
|
||||
FormCenterCatalogClient client = new FormCenterCatalogClient(openCall);
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> client.listForms(SCH, null, null, 1, 20));
|
||||
|
||||
assertTrue(e.getMessage().contains("不可达"), e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
@@ -15,6 +16,7 @@ import java.util.Collections;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -26,15 +28,18 @@ import static org.mockito.Mockito.when;
|
||||
class FlowAccessGuardTest {
|
||||
|
||||
private FlwTaskRepository taskRepository;
|
||||
private FlwCcRepository ccRepository;
|
||||
private FlowAccessGuard guard;
|
||||
private FlwInstance instance;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
guard = new FlowAccessGuard(taskRepository);
|
||||
ccRepository = mock(FlwCcRepository.class);
|
||||
guard = new FlowAccessGuard(taskRepository, ccRepository);
|
||||
when(taskRepository.findByInstanceIdOrderByIdAsc(anyLong()))
|
||||
.thenReturn(Collections.emptyList());
|
||||
when(ccRepository.existsByInstanceIdAndUserId(anyLong(), anyInt())).thenReturn(false);
|
||||
|
||||
instance = new FlwInstance();
|
||||
ReflectionTestUtils.setField(instance, "id", 100L);
|
||||
@@ -94,4 +99,29 @@ class FlowAccessGuardTest {
|
||||
assertThrows(BizException.class,
|
||||
() -> guard.requireInstanceVisible(instance, user(37, 1, null)));
|
||||
}
|
||||
|
||||
/** F7 前置补丁:抄送接收人只读可见(工作台"抄送我的"行、抄送消息回落链接不再 403) */
|
||||
@Test
|
||||
void ccRecipientCanView() {
|
||||
when(ccRepository.existsByInstanceIdAndUserId(100L, 44)).thenReturn(true);
|
||||
|
||||
assertDoesNotThrow(() -> guard.requireInstanceVisible(instance, user(44, 2, 990001, 9)));
|
||||
}
|
||||
|
||||
/** 陌生人(非提交人/办理人/管理员/抄送人)仍 403——补丁不放大可见范围 */
|
||||
@Test
|
||||
void strangerSameSchoolIsStillDenied() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> guard.requireInstanceVisible(instance, user(45, 2, 990001, 9)));
|
||||
assertEquals("仅提交人/办理人/本校管理员可查看该办件", e.getMessage());
|
||||
}
|
||||
|
||||
/** 跨校抄送接收人不存在即拒;抄送行必须命中本办件(instanceId 维度,不按 schId 泛化) */
|
||||
@Test
|
||||
void ccLookupIsScopedToInstance() {
|
||||
when(ccRepository.existsByInstanceIdAndUserId(100L, 46)).thenReturn(false);
|
||||
|
||||
assertThrows(BizException.class,
|
||||
() -> guard.requireInstanceVisible(instance, user(46, 2, 990001, 9)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwApp;
|
||||
import com.yunhai.flowcenter.repository.FlwAppRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.platform.openapi.base.OpenApiAppClient;
|
||||
import com.yunhai.platform.utils.model.BoaApp;
|
||||
import com.yunhai.platform.utils.model.BoaAppType;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 应用设置·应用管理单测(2026-10-10):学校池 diff 同步(新增 enabled=1/改名刷新/池外保留)、
|
||||
* UserBase 不可达降级、开关与跨校守卫。口径拍板:D2 只做最优实现(不做存量 appKey 兜底)、
|
||||
* D3 clientType=null 不过滤端、D7 学校组单点池。
|
||||
*/
|
||||
class FlowAppAdminServiceTest {
|
||||
|
||||
private static final Long SCH = 990001L;
|
||||
|
||||
private FlwAppRepository appRepository;
|
||||
private OpenApiAppClient appClient;
|
||||
private FlowAppAdminService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
appRepository = mock(FlwAppRepository.class);
|
||||
appClient = mock(OpenApiAppClient.class);
|
||||
service = new FlowAppAdminService(appRepository, appClient, mock(FlwCallbackRegRepository.class));
|
||||
when(appRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
private BoaAppType type(BoaApp... apps) {
|
||||
BoaAppType t = new BoaAppType();
|
||||
t.setId(2);
|
||||
t.setName("办公管理");
|
||||
t.setApps(Arrays.asList(apps));
|
||||
return t;
|
||||
}
|
||||
|
||||
private BoaApp app(Integer appKey, String name, Integer orderNumber) {
|
||||
BoaApp a = new BoaApp();
|
||||
a.setAppKey(appKey);
|
||||
a.setName(name);
|
||||
a.setOrderNumber(orderNumber);
|
||||
return a;
|
||||
}
|
||||
|
||||
private FlwApp row(String appKey, String name, int enabled) {
|
||||
FlwApp r = new FlwApp();
|
||||
r.setId((long) appKey.hashCode());
|
||||
r.setSchId(SCH);
|
||||
r.setAppKey(appKey);
|
||||
r.setAppName(name);
|
||||
r.setEnabled(enabled);
|
||||
return r;
|
||||
}
|
||||
|
||||
@Test
|
||||
void sync_池内新应用_插入且默认启用() {
|
||||
when(appClient.getSchoolApp(990001, null)).thenReturn(Collections.singletonList(
|
||||
type(app(100002, "通知公告", 1))));
|
||||
when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH)).thenReturn(Collections.emptyList());
|
||||
|
||||
service.syncFromSchoolPool(SCH);
|
||||
|
||||
ArgumentCaptor<FlwApp> cap = ArgumentCaptor.forClass(FlwApp.class);
|
||||
verify(appRepository, times(1)).save(cap.capture());
|
||||
assertEquals("100002", cap.getValue().getAppKey());
|
||||
assertEquals("通知公告", cap.getValue().getAppName());
|
||||
assertEquals(1, cap.getValue().getEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sync_改名静默刷新_开关不动_池外行保留() {
|
||||
FlwApp renamed = row("100002", "通知公告", 1);
|
||||
FlwApp untouchedEnabled = row("100008", "请假", 0); // 池外且已停用:保留不动
|
||||
FlwApp untouchedName = row("100010", "旧名", 1); // 池外:名称也不动
|
||||
when(appClient.getSchoolApp(990001, null)).thenReturn(Collections.singletonList(
|
||||
type(app(100002, "通知公告(校内别名)", 9))));
|
||||
when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH))
|
||||
.thenReturn(Arrays.asList(renamed, untouchedEnabled, untouchedName));
|
||||
|
||||
service.syncFromSchoolPool(SCH);
|
||||
|
||||
verify(appRepository, times(1)).save(any(FlwApp.class));
|
||||
assertEquals("通知公告(校内别名)", renamed.getAppName());
|
||||
assertEquals(1, renamed.getEnabled());
|
||||
assertEquals("旧名", untouchedName.getAppName());
|
||||
assertEquals(0, untouchedEnabled.getEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_UserBase不可达_降级返回本地行() {
|
||||
when(appClient.getSchoolApp(any(), any())).thenThrow(new RuntimeException("connect timeout"));
|
||||
when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH))
|
||||
.thenReturn(Collections.singletonList(row("100002", "通知公告", 1)));
|
||||
|
||||
List<FlwApp> rows = service.list(SCH);
|
||||
|
||||
assertEquals(1, rows.size());
|
||||
assertEquals("100002", rows.get(0).getAppKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_正常链路_先同步再返回() {
|
||||
when(appClient.getSchoolApp(990001, null)).thenReturn(Collections.emptyList());
|
||||
when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH)).thenReturn(Collections.emptyList());
|
||||
assertEquals(0, service.list(SCH).size());
|
||||
verify(appClient, times(1)).getSchoolApp(990001, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void enable_切换与守卫() {
|
||||
FlwApp r = row("100002", "通知公告", 1);
|
||||
when(appRepository.findById(r.getId())).thenReturn(java.util.Optional.of(r));
|
||||
|
||||
service.enable(SCH, r.getId(), false);
|
||||
assertEquals(0, r.getEnabled());
|
||||
|
||||
service.enable(SCH, r.getId(), true);
|
||||
assertEquals(1, r.getEnabled());
|
||||
|
||||
FlwApp other = row("100002", "x", 1);
|
||||
other.setSchId(2L);
|
||||
when(appRepository.findById(other.getId())).thenReturn(java.util.Optional.of(other));
|
||||
assertThrows(com.yunhai.platform.apistd.BizException.class, () -> service.enable(SCH, other.getId(), true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sync_池为空_零写入() {
|
||||
when(appClient.getSchoolApp(990001, null)).thenReturn(Collections.emptyList());
|
||||
when(appRepository.findBySchIdOrderBySortNoAscIdAsc(SCH)).thenReturn(Collections.emptyList());
|
||||
|
||||
service.syncFromSchoolPool(SCH);
|
||||
|
||||
verify(appRepository, never()).save(any());
|
||||
assertTrue(service.list(SCH).isEmpty());
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
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.FlwAppRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* publish() 的 C1 契约测试(2026-10-20 第一刀:发布期快照 + 表达式键查①红牌)。<br>
|
||||
* 覆盖:① 关联表单(form_uuid 非空)发布成功=content_json 顶层嵌 formContract 且 status=1;
|
||||
* ② 表达式变量 ∉ 快照字段 ∪ 内置变量 → 红牌阻断(点名节点/出口/变量);③ form_uuid 为空(存量线)
|
||||
* 不拉快照、行为不变;④ 快照拉取失败=发布阻断(可重试语义)。
|
||||
*/
|
||||
class FlowDefAdminServicePublishContractTest {
|
||||
|
||||
private static final Long SCH = 27L;
|
||||
|
||||
/** 含 CONDITION(EXPRESSION) 节点的合法定义:days/grade 是表单字段、schId 是内置变量 */
|
||||
private static final String JSON = "{\"start\":\"s\",\"nodes\":["
|
||||
+ "{\"id\":\"s\",\"type\":\"START\"},"
|
||||
+ "{\"id\":\"c\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
|
||||
+ "{\"id\":\"a\",\"type\":\"APPROVAL\",\"name\":\"审1\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[1]}},"
|
||||
+ "{\"id\":\"e\",\"type\":\"END\"}],"
|
||||
+ "\"edges\":["
|
||||
+ "{\"from\":\"s\",\"to\":\"c\"},"
|
||||
+ "{\"from\":\"c\",\"to\":\"a\",\"name\":\"准\",\"condition\":\"days > 3 && schId > 0\"},"
|
||||
+ "{\"from\":\"c\",\"to\":\"e\",\"name\":\"否\",\"condition\":\"grade == 'g1'\"},"
|
||||
+ "{\"from\":\"a\",\"to\":\"e\"}]}";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private FormCenterSchemaClient schemaClient;
|
||||
private FlowDefAdminService service;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
schemaClient = mock(FormCenterSchemaClient.class);
|
||||
service = new FlowDefAdminService(defRepository,
|
||||
mock(FlwRouterRepository.class),
|
||||
mock(FlwCallbackRegRepository.class),
|
||||
mock(FlwAppRepository.class),
|
||||
mock(FlwInstanceRepository.class),
|
||||
mock(FlowDeptAdminService.class),
|
||||
mock(FlowEngine.class),
|
||||
mapper,
|
||||
schemaClient);
|
||||
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
private FlwDef draft(String formUuid) {
|
||||
FlwDef d = new FlwDef();
|
||||
d.setId(9L);
|
||||
d.setSchId(SCH);
|
||||
d.setFlowGuid("g1");
|
||||
d.setCode("work-leave");
|
||||
d.setVersion(1);
|
||||
d.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
d.setAppKey("notice");
|
||||
d.setContentJson(JSON);
|
||||
d.setFormUuid(formUuid);
|
||||
d.setFormVersion(formUuid == null ? null : 3);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 快照:days(number)/grade(select enum g1,g2)/reason(text) */
|
||||
private Map<String, Object> snapshot() {
|
||||
Map<String, Object> snap = new LinkedHashMap<>();
|
||||
snap.put("formUuid", "FU1");
|
||||
snap.put("formVersion", 3);
|
||||
snap.put("audience", Arrays.asList("STUDENT", "TEACHER"));
|
||||
snap.put("fields", Arrays.asList(
|
||||
field("days", "number", true, null),
|
||||
field("grade", "select", false, Arrays.asList("g1", "g2")),
|
||||
field("reason", "text", true, null)));
|
||||
return snap;
|
||||
}
|
||||
|
||||
private static Map<String, Object> field(String key, String type, boolean required, List<String> enums) {
|
||||
Map<String, Object> f = new LinkedHashMap<>();
|
||||
f.put("key", key);
|
||||
f.put("type", type);
|
||||
f.put("required", required);
|
||||
if (enums != null) {
|
||||
f.put("enum", enums);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception {
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
|
||||
|
||||
FlwDef published = service.publish(SCH, 9L);
|
||||
|
||||
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
|
||||
JsonNode contract = mapper.readTree(published.getContentJson()).get("formContract");
|
||||
assertNotNull(contract, "content_json 顶层必须嵌 formContract");
|
||||
assertEquals("FU1", contract.get("formUuid").asText());
|
||||
assertEquals(3, contract.get("formVersion").asInt());
|
||||
assertEquals(3, contract.get("fields").size(), "fields 扁平化嵌入");
|
||||
assertTrue(contract.get("audience").isArray(), "audience 按数组解析入快照");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownExpressionVariableIsRedCard() {
|
||||
FlwDef d = draft("FU1");
|
||||
// 出口条件改成引用快照外变量
|
||||
d.setContentJson(JSON.replace("grade == 'g1'", "level == 'high'"));
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
|
||||
|
||||
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||
assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("不在关联表单字段集内"), "键查①给可定位的问题行");
|
||||
verify(defRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void numericCompareOnTextFieldIsRedCardTypeBucket() {
|
||||
FlwDef d = draft("FU1");
|
||||
d.setContentJson(JSON.replace("grade == 'g1'", "reason > 3"));
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
|
||||
|
||||
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"),
|
||||
"text 字段做数值比较要点名(类型桶查②):" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringLiteralEqualsOnNumberFieldIsRedCardTypeBucket() {
|
||||
FlwDef d = draft("FU1");
|
||||
d.setContentJson(JSON.replace("grade == 'g1'", "days == 'three'"));
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
|
||||
|
||||
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"),
|
||||
"number 字段与字符串字面量等值要拦:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void enumLiteralOutOfDomainIsRedCard() {
|
||||
FlwDef d = draft("FU1");
|
||||
d.setContentJson(JSON.replace("grade == 'g1'", "grade == 'g9'"));
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
|
||||
|
||||
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||
assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"),
|
||||
"枚举字面量不在值域要点名(域查③):" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void numericFieldWithNumericLiteralPassesAllChecks() {
|
||||
// days(number) > 3 与内置 schId > 0:happy path(publishEmbeds…已覆盖主链),此处锁数值正向桶
|
||||
FlwDef d = draft("FU1");
|
||||
d.setContentJson(JSON.replace("grade == 'g1'", "days >= 2.5"));
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
|
||||
|
||||
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builtinVariablesPassKeyCheck() {
|
||||
// days 与 schId(内置)都合法 → 上面的 happy path 已覆盖;此处单独断言纯内置表达式
|
||||
FlwDef d = draft("FU1");
|
||||
d.setContentJson(JSON.replace("grade == 'g1'", "bizType == 'x'"));
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
|
||||
|
||||
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyDefWithoutFormUuidSkipsSnapshotEntirely() {
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft(null)));
|
||||
|
||||
FlwDef published = service.publish(SCH, 9L);
|
||||
|
||||
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
|
||||
assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动");
|
||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaUnavailableBlocksPublishAsRetryableBizError() {
|
||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
|
||||
when(schemaClient.fetchSchema(SCH, "FU1", 3))
|
||||
.thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null));
|
||||
|
||||
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||
assertEquals(com.yunhai.flowcenter.enums.FormBindingErrorCode.SCHEMA_UNAVAILABLE.getCode(),
|
||||
ex.getErrorCode().getCode());
|
||||
assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage());
|
||||
verify(defRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
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.FlwRouterRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* save() 分派语义(2026-10-10 唯一键改 (sch,flow_guid,version) 后):
|
||||
* 新建=永远新线 v1(编码只是标签);升版本=复制行 maxByGuid+1;唯一键冲突转友好 BizException。
|
||||
* 2026-10-12 拍板 appKey 必填:新建必须显式传;升版本/直改可继承源行,源行为空必须补填。
|
||||
* 注:@Nested 在 surefire 2.22 的 -Dtest 过滤下发现不了,保持拍平写法。
|
||||
*/
|
||||
class FlowDefAdminServiceSaveTest {
|
||||
|
||||
private static final Long SCH = 3L;
|
||||
private static final String JSON = "{\"nodes\":[],\"edges\":[]}";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private com.yunhai.flowcenter.repository.FlwAppRepository appRepository;
|
||||
private FlowDefAdminService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
appRepository = mock(com.yunhai.flowcenter.repository.FlwAppRepository.class);
|
||||
service = new FlowDefAdminService(defRepository,
|
||||
mock(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));
|
||||
// 归属应用白名单:非空 appKey 一律放行(校验拦截语义由专门用例覆盖)
|
||||
when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
|
||||
}
|
||||
|
||||
private FlwDef def(long id, String flowGuid, String code, int version, int status) {
|
||||
FlwDef d = new FlwDef();
|
||||
d.setId(id);
|
||||
d.setSchId(SCH);
|
||||
d.setFlowGuid(flowGuid);
|
||||
d.setCode(code);
|
||||
d.setVersion(version);
|
||||
d.setStatus(status);
|
||||
d.setContentJson(JSON);
|
||||
return d;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 新建_永远新开流程线_版本从1起() {
|
||||
ArgumentCaptor<FlwDef> cap = ArgumentCaptor.forClass(FlwDef.class);
|
||||
FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, null, "notice", null);
|
||||
verify(defRepository).save(cap.capture());
|
||||
assertEquals(cap.getValue(), saved);
|
||||
assertEquals(1, saved.getVersion());
|
||||
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), saved.getStatus());
|
||||
assertEquals("work-leave", saved.getCode());
|
||||
assertEquals(32, saved.getFlowGuid().length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 连续新建同编码_各得独立流程线() {
|
||||
FlwDef a = service.save(SCH, null, "work-leave", "产假", JSON, null, "notice", null);
|
||||
FlwDef b = service.save(SCH, null, "work-leave", "丧假", JSON, null, "notice", null);
|
||||
assertEquals(1, a.getVersion());
|
||||
assertEquals(1, b.getVersion());
|
||||
assertNotEquals(a.getFlowGuid(), b.getFlowGuid());
|
||||
}
|
||||
|
||||
@Test
|
||||
void baseGuid_在线上开新版本草稿_max加1且保持flowGuid() {
|
||||
FlwDef base = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
base.setAppKey("notice");
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G"))
|
||||
.thenReturn(Optional.of(base));
|
||||
when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(6);
|
||||
|
||||
FlwDef saved = service.save(SCH, null, "work-leave", "请假", JSON, "G", null, null);
|
||||
|
||||
assertEquals("G", saved.getFlowGuid());
|
||||
assertEquals(7, saved.getVersion());
|
||||
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), saved.getStatus());
|
||||
assertNull(saved.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 已发布行带id保存_复制升版本() {
|
||||
FlwDef published = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
published.setAppKey("notice");
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(published));
|
||||
when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4);
|
||||
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", JSON, null, null, null);
|
||||
|
||||
assertEquals("G", saved.getFlowGuid());
|
||||
assertEquals(5, saved.getVersion());
|
||||
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), saved.getStatus());
|
||||
assertNull(saved.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 草稿行带id保存_直改本行() {
|
||||
FlwDef draft = def(9L, "G", "work-leave", 5, FlwEnums.DefStatus.DRAFT.getCode());
|
||||
draft.setAppKey("notice");
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(draft));
|
||||
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "新名字", JSON, null, null, null);
|
||||
|
||||
assertSame(draft, saved);
|
||||
assertEquals("新名字", saved.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void baseGuid流程线不存在_点名报错() {
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "GX"))
|
||||
.thenReturn(Optional.empty());
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.save(SCH, null, "work-leave", "请假", JSON, "GX", null, null));
|
||||
assertTrue(e.getMessage().contains("baseGuid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 新建_归属应用直接落库_空白规整生效() {
|
||||
assertEquals("notice", service.save(SCH, null, "work-leave", "请假", JSON, null, " notice ", null).getAppKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 新建_appKey不传_必填被拦() {
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.save(SCH, null, "work-leave", "请假", JSON, null, " ", null));
|
||||
assertTrue(e.getMessage().contains("归属应用必填"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 升版本_appKey缺省继承源线_传非空则覆盖() {
|
||||
FlwDef base = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
base.setAppKey("notice");
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G")).thenReturn(Optional.of(base));
|
||||
when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4);
|
||||
|
||||
assertEquals("notice", service.save(SCH, null, "work-leave", "请假", JSON, "G", null, null).getAppKey());
|
||||
assertEquals("crm", service.save(SCH, null, "work-leave", "请假", JSON, "G", "crm", null).getAppKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
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("归属应用必填"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 草稿直改_传appKey覆盖_不传保留() {
|
||||
FlwDef draft = def(9L, "G", "work-leave", 5, FlwEnums.DefStatus.DRAFT.getCode());
|
||||
draft.setAppKey("notice");
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(draft));
|
||||
|
||||
assertEquals("notice", service.save(SCH, 9L, "work-leave", "请假", JSON, null, null, null).getAppKey());
|
||||
assertEquals("crm", service.save(SCH, 9L, "work-leave", "请假", JSON, null, "crm", null).getAppKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
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("归属应用必填"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 并发升版本撞唯一键_转友好提示而非内部服务错误() {
|
||||
FlwDef published = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
published.setAppKey("notice");
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(published));
|
||||
when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4);
|
||||
when(defRepository.save(any())).thenThrow(new DataIntegrityViolationException("dup"));
|
||||
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.save(SCH, 9L, "work-leave", "请假", JSON, null, null, null));
|
||||
assertTrue(e.getMessage().contains("保存冲突"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appKey不在启用白名单_保存被拦() {
|
||||
when(appRepository.isEnabledApp(SCH, "999999")).thenReturn(false);
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.save(SCH, null, "work-leave", "请假", JSON, null, "999999", null));
|
||||
assertTrue(e.getMessage().contains("应用设置"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appKey不传_白名单校验不触发() {
|
||||
// 升版本继承源线(cleanAppKey=null)不查白名单——继承值是既成事实,应用停用不卡升版本
|
||||
FlwDef published = def(9L, "G", "work-leave", 4, FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
published.setAppKey("notice");
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(published));
|
||||
when(defRepository.maxVersionByGuid(SCH, "G")).thenReturn(4);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, "G")).thenReturn(Optional.empty());
|
||||
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", JSON, null, null, null);
|
||||
org.mockito.Mockito.verify(appRepository, org.mockito.Mockito.never()).isEnabledApp(any(), any());
|
||||
assertEquals("notice", saved.getAppKey());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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.repository.FlwAppRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* updateMeta 原地编辑单测(2026-10-10 议题2,拍板 D4):
|
||||
* 三不变(version/flowGuid/status;contentJson 仅顶层 name)、appKey 白名单校验、
|
||||
* contentJson 无顶层 name 时补写、name 非空校验。
|
||||
*/
|
||||
class FlowDefAdminServiceUpdateMetaTest {
|
||||
|
||||
private static final Long SCH = 3L;
|
||||
|
||||
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(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);
|
||||
}
|
||||
|
||||
private FlwDef published(String contentJson) {
|
||||
FlwDef d = new FlwDef();
|
||||
d.setId(9L);
|
||||
d.setSchId(SCH);
|
||||
d.setFlowGuid("guid123");
|
||||
d.setCode("work-leave");
|
||||
d.setName("旧名");
|
||||
d.setAppKey("100002");
|
||||
d.setVersion(4);
|
||||
d.setStatus(1);
|
||||
d.setContentJson(contentJson);
|
||||
return d;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 三不变_contentJson仅顶层name() throws Exception {
|
||||
String json = "{\"code\":\"work-leave\",\"name\":\"旧名\",\"start\":\"start\","
|
||||
+ "\"nodes\":[{\"id\":\"start\",\"type\":\"START\"}],"
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"end\"}]}";
|
||||
FlwDef def = published(json);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
|
||||
FlwDef saved = service.updateMeta(SCH, 9L, "新名称", "100008");
|
||||
|
||||
assertEquals(4, saved.getVersion());
|
||||
assertEquals("guid123", saved.getFlowGuid());
|
||||
assertEquals(1, saved.getStatus());
|
||||
assertEquals("work-leave", saved.getCode());
|
||||
assertEquals("新名称", saved.getName());
|
||||
assertEquals("100008", saved.getAppKey());
|
||||
assertEquals("新名称", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText());
|
||||
// 结构零改动:节点/边/其余顶层字段原样保留
|
||||
assertEquals("work-leave", new ObjectMapper().readTree(saved.getContentJson()).path("code").asText());
|
||||
assertTrue(saved.getContentJson().contains("\"type\":\"START\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentJson无顶层name_补写() throws Exception {
|
||||
FlwDef def = published("{\"code\":\"c\",\"start\":\"start\",\"nodes\":[],\"edges\":[]}");
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
||||
|
||||
FlwDef saved = service.updateMeta(SCH, 9L, "补写名", null);
|
||||
|
||||
assertEquals("补写名", new ObjectMapper().readTree(saved.getContentJson()).path("name").asText());
|
||||
assertNull(saved.getAppKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void appKey白名单外_被拦() {
|
||||
when(appRepository.isEnabledApp(SCH, "888888")).thenReturn(false);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(published("{}")));
|
||||
|
||||
Exception e = assertThrows(com.yunhai.platform.apistd.BizException.class,
|
||||
() -> service.updateMeta(SCH, 9L, "n", "888888"));
|
||||
assertTrue(e.getMessage().contains("应用设置"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void name空_被拦() {
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(published("{}")));
|
||||
assertThrows(com.yunhai.platform.apistd.BizException.class,
|
||||
() -> service.updateMeta(SCH, 9L, " ", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 跨校_被拒() {
|
||||
FlwDef other = published("{}");
|
||||
other.setSchId(2L);
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(other));
|
||||
assertThrows(com.yunhai.platform.apistd.BizException.class,
|
||||
() -> service.updateMeta(SCH, 9L, "n", null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwDept;
|
||||
import com.yunhai.flowcenter.entity.FlwDeptMember;
|
||||
import com.yunhai.flowcenter.repository.FlwDeptMemberRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDeptRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 科室管理单测(2026-10-10 四议题·议题3,拍板 D1):CRUD 守卫、enabledDeptKeys 白名单口径、
|
||||
* 解析宽容口径(deptKey 不存在/科室停用返回空,示意图/轨迹不炸)、一键拉取幂等
|
||||
* (source_group_id 命中刷新组名+成员集对齐,删不在集内旧成员)。
|
||||
*/
|
||||
class FlowDeptAdminServiceTest {
|
||||
|
||||
private static final Long SCH = 990001L;
|
||||
|
||||
private FlwDeptRepository deptRepository;
|
||||
private FlwDeptMemberRepository memberRepository;
|
||||
private FlowMapAdminService mapAdminService;
|
||||
private FlowDeptAdminService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
deptRepository = mock(FlwDeptRepository.class);
|
||||
memberRepository = mock(FlwDeptMemberRepository.class);
|
||||
mapAdminService = mock(FlowMapAdminService.class);
|
||||
service = new FlowDeptAdminService(deptRepository, memberRepository, mapAdminService);
|
||||
when(deptRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
private FlwDept dept(long id, String name, int status, Integer sourceGroupId) {
|
||||
FlwDept d = new FlwDept();
|
||||
d.setId(id);
|
||||
d.setSchId(SCH);
|
||||
d.setDeptName(name);
|
||||
d.setStatus(status);
|
||||
d.setSourceGroupId(sourceGroupId);
|
||||
return d;
|
||||
}
|
||||
|
||||
private FlwDeptMember member(long deptId, int userId, String name) {
|
||||
FlwDeptMember m = new FlwDeptMember();
|
||||
m.setDeptId(deptId);
|
||||
m.setUserId(userId);
|
||||
m.setUserName(name);
|
||||
m.setStatus(0);
|
||||
return m;
|
||||
}
|
||||
|
||||
private FlowMapAdminService.GroupDeptVo groupVo(int groupId, String name, FlowMapAdminService.MemberVo... users) {
|
||||
FlowMapAdminService.GroupDeptVo vo = new FlowMapAdminService.GroupDeptVo();
|
||||
vo.setDeptKey(String.valueOf(groupId));
|
||||
vo.setDeptName(name);
|
||||
vo.setUsers(Arrays.asList(users));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private FlowMapAdminService.MemberVo userVo(int userId, String name) {
|
||||
return new FlowMapAdminService.MemberVo(userId, name);
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_启用户口count_停用科室恒0() {
|
||||
FlwDept on = dept(1L, "人事处", 0, null);
|
||||
FlwDept off = dept(2L, "教务处", 1, null);
|
||||
when(deptRepository.findBySchIdOrderByIdAsc(SCH)).thenReturn(Arrays.asList(on, off));
|
||||
when(memberRepository.countByDeptIdAndStatus(1L, 0)).thenReturn(3L);
|
||||
when(memberRepository.countByDeptIdAndStatus(2L, 0)).thenReturn(5L);
|
||||
|
||||
List<FlowDeptAdminService.DeptVo> rows = service.list(SCH);
|
||||
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals("1", rows.get(0).getDeptKey());
|
||||
assertEquals(3, rows.get(0).getCount());
|
||||
assertEquals(0, rows.get(1).getCount(), "停用科室 count 恒 0(编辑器提示'无启用人员')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enabledDeptKeys_仅含有启用成员的启用科室() {
|
||||
FlwDept with = dept(1L, "人事处", 0, null);
|
||||
FlwDept empty = dept(2L, "教务处", 0, null);
|
||||
FlwDept off = dept(3L, "保卫处", 1, null);
|
||||
when(deptRepository.findBySchIdAndStatusOrderByIdAsc(SCH, 0)).thenReturn(Arrays.asList(with, empty));
|
||||
when(memberRepository.countByDeptIdAndStatus(1L, 0)).thenReturn(2L);
|
||||
when(memberRepository.countByDeptIdAndStatus(2L, 0)).thenReturn(0L);
|
||||
|
||||
assertEquals(Collections.singleton("1"), service.enabledDeptKeys(SCH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveEnabled_宽容口径() {
|
||||
// deptKey 不存在(旧映射 deptKey=UserBase 组 id 切源后即此态)→ 空,不抛
|
||||
when(deptRepository.findBySchIdAndId(SCH, 123L)).thenReturn(Optional.empty());
|
||||
assertTrue(service.resolveEnabled(SCH, "123").isEmpty());
|
||||
// 非数字 deptKey → 空,不抛(脏定义防御)
|
||||
assertTrue(service.resolveEnabled(SCH, "abc").isEmpty());
|
||||
// 科室停用 → 空
|
||||
when(deptRepository.findBySchIdAndId(SCH, 2L)).thenReturn(Optional.of(dept(2L, "教务处", 1, null)));
|
||||
assertTrue(service.resolveEnabled(SCH, "2").isEmpty());
|
||||
// 启用科室 → 启用成员
|
||||
FlwDept on = dept(1L, "人事处", 0, null);
|
||||
when(deptRepository.findBySchIdAndId(SCH, 1L)).thenReturn(Optional.of(on));
|
||||
when(memberRepository.findByDeptIdAndStatusOrderByIdAsc(1L, 0))
|
||||
.thenReturn(Arrays.asList(member(1L, 11, "张三"), member(1L, 12, "李四")));
|
||||
assertEquals(2, service.resolveEnabled(SCH, "1").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deptName_切源唯一出口() {
|
||||
when(deptRepository.findBySchIdAndId(SCH, 1L)).thenReturn(Optional.of(dept(1L, "人事处", 0, null)));
|
||||
assertEquals("人事处", service.deptName(SCH, "1"));
|
||||
when(deptRepository.findBySchIdAndId(SCH, 9L)).thenReturn(Optional.empty());
|
||||
assertNull(service.deptName(SCH, "9"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_name必填() {
|
||||
assertEquals("新科室", service.create(SCH, " 新科室 ").getDeptName());
|
||||
assertThrows(com.yunhai.platform.apistd.BizException.class, () -> service.create(SCH, " "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_级联删成员_跨校拒() {
|
||||
FlwDept d = dept(1L, "人事处", 0, null);
|
||||
when(deptRepository.findById(1L)).thenReturn(Optional.of(d));
|
||||
service.delete(SCH, 1L);
|
||||
verify(memberRepository).deleteByDeptId(1L);
|
||||
verify(deptRepository).delete(d);
|
||||
|
||||
FlwDept other = dept(2L, "x", 0, null);
|
||||
other.setSchId(2L);
|
||||
when(deptRepository.findById(2L)).thenReturn(Optional.of(other));
|
||||
assertThrows(com.yunhai.platform.apistd.BizException.class, () -> service.delete(SCH, 2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberAdd_跳过已存在_失败降级井号() {
|
||||
FlwDept d = dept(1L, "人事处", 0, null);
|
||||
when(deptRepository.findById(1L)).thenReturn(Optional.of(d));
|
||||
when(memberRepository.findByDeptIdAndUserId(1L, 11)).thenReturn(Optional.of(member(1L, 11, "张三")));
|
||||
when(mapAdminService.lookupUser(12)).thenThrow(new RuntimeException("userbase down"));
|
||||
|
||||
int added = service.memberAdd(SCH, 1L, Arrays.asList(11, 12));
|
||||
|
||||
assertEquals(1, added);
|
||||
ArgumentCaptor<FlwDeptMember> cap = ArgumentCaptor.forClass(FlwDeptMember.class);
|
||||
verify(memberRepository, times(1)).save(cap.capture());
|
||||
assertEquals(12, cap.getValue().getUserId());
|
||||
assertEquals("#12", cap.getValue().getUserName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importGroups_首拉新建_重复拉取刷新并对齐成员集() {
|
||||
when(mapAdminService.previewGroupDepts(SCH)).thenReturn(Arrays.asList(
|
||||
groupVo(7001, "人事处", userVo(11, "张三"), userVo(12, "李四"))));
|
||||
// 首拉:无既有行 → 新建 + 灌成员
|
||||
FlwDept created = dept(101L, "人事处", 0, 7001);
|
||||
when(deptRepository.findBySchIdAndSourceGroupId(SCH, 7001)).thenReturn(Optional.empty());
|
||||
when(deptRepository.save(any())).thenReturn(created);
|
||||
when(memberRepository.findByDeptIdOrderByIdAsc(101L)).thenReturn(Collections.emptyList());
|
||||
|
||||
FlowDeptAdminService.GroupImportResultVo r1 = service.importGroups(SCH);
|
||||
assertEquals(1, r1.getCreated());
|
||||
assertEquals(2, r1.getUsers());
|
||||
verify(memberRepository, times(2)).save(any(FlwDeptMember.class));
|
||||
|
||||
// 重复拉取:命中 source_group_id → 刷新组名;成员集对齐(11 留、12 删、13 增)
|
||||
FlwDept existing = dept(101L, "人事处旧名", 0, 7001);
|
||||
when(deptRepository.findBySchIdAndSourceGroupId(SCH, 7001)).thenReturn(Optional.of(existing));
|
||||
when(mapAdminService.previewGroupDepts(SCH)).thenReturn(Arrays.asList(
|
||||
groupVo(7001, "人事处新名", userVo(11, "张三"), userVo(13, "王五"))));
|
||||
FlwDeptMember old12 = member(101L, 12, "李四");
|
||||
FlwDeptMember old11 = member(101L, 11, "张三");
|
||||
when(memberRepository.findByDeptIdOrderByIdAsc(101L)).thenReturn(Arrays.asList(old11, old12));
|
||||
when(memberRepository.findByDeptIdAndUserId(eq(101L), anyInt())).thenReturn(Optional.empty());
|
||||
when(memberRepository.findByDeptIdAndUserId(101L, 11)).thenReturn(Optional.of(old11));
|
||||
|
||||
FlowDeptAdminService.GroupImportResultVo r2 = service.importGroups(SCH);
|
||||
assertEquals(1, r2.getRefreshed());
|
||||
assertEquals(1, r2.getUsers());
|
||||
assertEquals("人事处新名", existing.getDeptName());
|
||||
verify(memberRepository).delete(old12);
|
||||
verify(memberRepository, never()).delete(old11);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package com.yunhai.flowcenter.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.controller.api.FlowAdminController;
|
||||
import com.yunhai.flowcenter.controller.api.FlowUserController;
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwDeptMember;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
@@ -11,7 +11,6 @@ import com.yunhai.flowcenter.engine.AssigneeResolver;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
@@ -85,7 +84,7 @@ class FlowDiagramServiceTest {
|
||||
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n2\"},{\"from\":\"n2\",\"to\":\"end\"}]}";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private FlwAssigneeMapRepository mapRepository;
|
||||
private com.yunhai.flowcenter.service.FlowDeptAdminService deptAdminService;
|
||||
private FlwTaskRepository taskRepository;
|
||||
private com.yunhai.flowcenter.repository.FlwCcRepository ccRepository;
|
||||
private OpenApiUserClient userClient;
|
||||
@@ -94,7 +93,7 @@ class FlowDiagramServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
mapRepository = mock(FlwAssigneeMapRepository.class);
|
||||
deptAdminService = mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class);
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
ccRepository = mock(com.yunhai.flowcenter.repository.FlwCcRepository.class);
|
||||
userClient = mock(OpenApiUserClient.class);
|
||||
@@ -107,7 +106,7 @@ class FlowDiagramServiceTest {
|
||||
taskRepository, mock(FlwBranchRepository.class), ccRepository,
|
||||
mock(AssigneeResolver.class), mock(com.yunhai.flowcenter.engine.ConditionRouteService.class),
|
||||
mock(ApplicationEventPublisher.class));
|
||||
service = new FlowDiagramService(defRepository, mapRepository,
|
||||
service = new FlowDiagramService(defRepository, deptAdminService,
|
||||
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
|
||||
taskRepository, ccRepository,
|
||||
mock(com.yunhai.flowcenter.repository.FlwRouteLogRepository.class),
|
||||
@@ -127,11 +126,9 @@ class FlowDiagramServiceTest {
|
||||
return def;
|
||||
}
|
||||
|
||||
private FlwAssigneeMap mapRow(String deptName, int userId, String userName) {
|
||||
FlwAssigneeMap m = new FlwAssigneeMap();
|
||||
m.setSchId(SCH);
|
||||
m.setDeptKey("rs");
|
||||
m.setDeptName(deptName);
|
||||
private FlwDeptMember memberRow(int userId, String userName) {
|
||||
FlwDeptMember m = new FlwDeptMember();
|
||||
m.setDeptId(7L);
|
||||
m.setUserId(userId);
|
||||
m.setUserName(userName);
|
||||
m.setStatus(0);
|
||||
@@ -153,8 +150,9 @@ class FlowDiagramServiceTest {
|
||||
|
||||
@Test
|
||||
void c1_deptView_deptKeyNode_labelDeptName_subLabelCountAndDeadline() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四")));
|
||||
when(deptAdminService.deptName(SCH, "rs")).thenReturn("人事处");
|
||||
when(deptAdminService.resolveEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
memberRow(11, "张三"), memberRow(12, "李四")));
|
||||
FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, "dept");
|
||||
FlowModels.NodeVo n1 = node(vo, "n1");
|
||||
assertEquals("人事处", n1.getLabel());
|
||||
@@ -170,30 +168,32 @@ class FlowDiagramServiceTest {
|
||||
|
||||
@Test
|
||||
void c1_deptView_fallbackChain_deptKeyThenNodeName() {
|
||||
// 映射行全无名 → deptKey 兜底
|
||||
when(mapRepository.findEnabled(SCH, "rs"))
|
||||
.thenReturn(Collections.singletonList(mapRow("", 11, "张三")));
|
||||
// 科室已被删(deptName=null)→ 节点名兜底(成员解析空,人数照实)
|
||||
when(deptAdminService.resolveEnabled(SCH, "rs"))
|
||||
.thenReturn(Collections.singletonList(memberRow(11, "张三")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("rs", n1.getLabel());
|
||||
assertEquals("科室 · 1人 · 时限48h", n1.getSubLabel());
|
||||
// 无映射行 → 节点名兜底(人数照实为 0)
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Collections.emptyList());
|
||||
n1 = node(service.build(def(DEPT_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("人事处审批", n1.getLabel());
|
||||
assertEquals("科室 · 1人 · 时限48h", n1.getSubLabel());
|
||||
// 科室存在但成员为空 → 科室名照常,人数 0
|
||||
when(deptAdminService.deptName(SCH, "rs")).thenReturn("人事处");
|
||||
when(deptAdminService.resolveEnabled(SCH, "rs")).thenReturn(Collections.emptyList());
|
||||
n1 = node(service.build(def(DEPT_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("人事处", n1.getLabel());
|
||||
assertEquals("科室 · 0人 · 时限48h", n1.getSubLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_anyCountersign_appendsSuffix() {
|
||||
when(mapRepository.findEnabled(SCH, "rs"))
|
||||
.thenReturn(Collections.singletonList(mapRow("人事处", 11, "张三")));
|
||||
when(deptAdminService.resolveEnabled(SCH, "rs"))
|
||||
.thenReturn(Collections.singletonList(memberRow(11, "张三")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(ANY_DEF_JSON), null, "dept"), "n1");
|
||||
assertEquals("科室 · 1人 · 任一人通过", n1.getSubLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
void c1_fixedLabels_andEdgesCarried() {
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
when(deptAdminService.resolveEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
when(deptAdminService.deptName(anyLong(), anyString())).thenReturn(null);
|
||||
FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, "dept");
|
||||
assertEquals("开始", node(vo, "start").getLabel());
|
||||
assertEquals("并行分发", node(vo, "sp1").getLabel());
|
||||
@@ -215,8 +215,9 @@ class FlowDiagramServiceTest {
|
||||
|
||||
@Test
|
||||
void c1_autoView_deptKeyNode_rendersDeptStyle() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四")));
|
||||
when(deptAdminService.deptName(SCH, "rs")).thenReturn("人事处");
|
||||
when(deptAdminService.resolveEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
memberRow(11, "张三"), memberRow(12, "李四")));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "auto"), "n1");
|
||||
assertEquals("人事处", n1.getLabel());
|
||||
assertEquals("科室 · 2人 · 时限48h", n1.getSubLabel());
|
||||
@@ -226,8 +227,9 @@ class FlowDiagramServiceTest {
|
||||
/** 2026-09-18:mode 缺省改为人员视图(原 auto 自适应)——老深链显式带 auto 仍按自适应出图 */
|
||||
@Test
|
||||
void c1_defaultMode_isUserView() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, "李四")));
|
||||
when(deptAdminService.deptName(SCH, "rs")).thenReturn("人事处");
|
||||
when(deptAdminService.resolveEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
memberRow(11, "张三"), memberRow(12, "李四")));
|
||||
FlowModels.DiagramVo vo = service.build(def(DEPT_DEF_JSON), null, null);
|
||||
assertEquals("user", vo.getMode());
|
||||
FlowModels.NodeVo n1 = node(vo, "n1");
|
||||
@@ -288,8 +290,9 @@ class FlowDiagramServiceTest {
|
||||
|
||||
@Test
|
||||
void c3_userView_deptKeyNode_listsMemberNames_blankNameDegradesToId() {
|
||||
when(mapRepository.findEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
mapRow("人事处", 11, "张三"), mapRow("人事处", 12, null)));
|
||||
when(deptAdminService.deptName(SCH, "rs")).thenReturn("人事处");
|
||||
when(deptAdminService.resolveEnabled(SCH, "rs")).thenReturn(Arrays.asList(
|
||||
memberRow(11, "张三"), memberRow(12, null)));
|
||||
FlowModels.NodeVo n1 = node(service.build(def(DEPT_DEF_JSON), null, "user"), "n1");
|
||||
// 人员视图:label=节点名,subLabel=映射成员名(空名降级 #id)+ 会签/时限后缀
|
||||
assertEquals("人事处审批", n1.getLabel());
|
||||
@@ -304,7 +307,8 @@ class FlowDiagramServiceTest {
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
SCH, "g1", FlwEnums.DefStatus.PUBLISHED.getCode()))
|
||||
.thenReturn(Optional.of(def));
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
when(deptAdminService.resolveEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
when(deptAdminService.deptName(anyLong(), anyString())).thenReturn(null);
|
||||
FlowModels.DiagramVo vo = service.byGuid(SCH, "g1", null);
|
||||
assertEquals("教师入职", vo.getName());
|
||||
assertEquals(Integer.valueOf(2), vo.getVersion());
|
||||
@@ -349,7 +353,8 @@ class FlowDiagramServiceTest {
|
||||
instance.setSchId(SCH);
|
||||
instance.setDefId(12L);
|
||||
when(defRepository.findById(12L)).thenReturn(Optional.of(def(DEPT_DEF_JSON)));
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
when(deptAdminService.resolveEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
when(deptAdminService.deptName(anyLong(), anyString())).thenReturn(null);
|
||||
FlowModels.DiagramVo vo = service.byInstance(instance, "dept");
|
||||
assertEquals(Long.valueOf(100L), vo.getInstanceId());
|
||||
assertEquals(Long.valueOf(12L), vo.getDefId());
|
||||
@@ -360,7 +365,8 @@ class FlowDiagramServiceTest {
|
||||
void c6_build_draftDef_ok() {
|
||||
FlwDef draft = def(DEPT_DEF_JSON);
|
||||
draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
when(mapRepository.findEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
when(deptAdminService.resolveEnabled(anyLong(), anyString())).thenReturn(Collections.emptyList());
|
||||
when(deptAdminService.deptName(anyLong(), anyString())).thenReturn(null);
|
||||
FlowModels.DiagramVo vo = service.build(draft, null, "auto");
|
||||
assertEquals(Integer.valueOf(0), vo.getStatus());
|
||||
assertEquals(5, vo.getNodes().size());
|
||||
@@ -383,8 +389,10 @@ class FlowDiagramServiceTest {
|
||||
taskService = mock(FlowTaskService.class);
|
||||
instanceService = mock(FlowInstanceService.class);
|
||||
diagramService = mock(FlowDiagramService.class);
|
||||
// 真守卫(同 FlowAccessGuardTest 口径):提交人/办理人/本校管理员
|
||||
FlowAccessGuard accessGuard = new FlowAccessGuard(taskRepository);
|
||||
// 真守卫(同 FlowAccessGuardTest 口径):提交人/办理人/本校管理员/抄送接收人
|
||||
// 抄送分支本组用例不覆盖,空 mock 即 false(陌生人仍 403 的断言不受影响)
|
||||
FlowAccessGuard accessGuard = new FlowAccessGuard(taskRepository,
|
||||
mock(com.yunhai.flowcenter.repository.FlwCcRepository.class));
|
||||
controller = new FlowUserController(queryService, taskService, instanceService,
|
||||
accessGuard, diagramService, mock(com.yunhai.flowcenter.service.DiagramImageService.class));
|
||||
}
|
||||
@@ -482,7 +490,11 @@ class FlowDiagramServiceTest {
|
||||
mock(FlowMapAdminService.class), mock(FlowCallbackAdminService.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowRouterAdminService.class),
|
||||
mock(FlwInstanceRepository.class), diagramService,
|
||||
mock(com.yunhai.flowcenter.service.DiagramImageService.class));
|
||||
mock(com.yunhai.flowcenter.service.DiagramImageService.class),
|
||||
new ObjectMapper(),
|
||||
mock(com.yunhai.flowcenter.service.FlowAppAdminService.class),
|
||||
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
|
||||
mock(com.yunhai.flowcenter.repository.FlwRouteLogRepository.class));
|
||||
}
|
||||
|
||||
private BoaUser user(int id, Integer type, Integer schId, Integer... roleIds) {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwRouter;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 判断服务自注册登记(2026-10-12 SERVICE 收口,/oapi/flow/router/reg 的服务层):
|
||||
* 登记即持有(新增行 serviceKey=归属 appKey、directHost 非空)、同 appKey 幂等更新且 status 不动、
|
||||
* 手工行首次自注册认领、跨应用覆盖拒绝、校验项与未验签(appKey 空)拒绝。
|
||||
*/
|
||||
class FlowRouterAdminServiceRegisterTest {
|
||||
|
||||
private static final String APP = "100021";
|
||||
|
||||
private FlwRouterRepository routerRepository;
|
||||
private FlowRouterAdminService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
routerRepository = mock(FlwRouterRepository.class);
|
||||
service = new FlowRouterAdminService(routerRepository,
|
||||
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
|
||||
mock(com.yunhai.flowcenter.repository.FlwDefRepository.class),
|
||||
mock(com.yunhai.flowcenter.repository.FlwTaskRepository.class),
|
||||
mock(com.yunhai.flowcenter.engine.FlowEngine.class),
|
||||
new com.fasterxml.jackson.databind.ObjectMapper());
|
||||
when(routerRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
}
|
||||
|
||||
private OapiModels.RouterRegBody body() {
|
||||
OapiModels.RouterRegBody b = new OapiModels.RouterRegBody();
|
||||
b.setRouterKey("work-leave.days-judge");
|
||||
b.setName("请假天数分级");
|
||||
b.setJudgePath("/oapi/flow/route/judge");
|
||||
b.setDirectHost("http://open-gateway-service:10013/work-leave");
|
||||
return b;
|
||||
}
|
||||
|
||||
private FlwRouter existing(String regAppKey, Integer status) {
|
||||
FlwRouter r = new FlwRouter();
|
||||
r.setId(7L);
|
||||
r.setRouterKey("work-leave.days-judge");
|
||||
r.setName("旧名");
|
||||
r.setServiceKey("work-leave");
|
||||
r.setPath("/oapi/legacy/judge");
|
||||
r.setBasePath("/work-leave");
|
||||
r.setRegAppKey(regAppKey);
|
||||
r.setStatus(status);
|
||||
return r;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 新键_建行填归属并带直连基址() {
|
||||
when(routerRepository.findByRouterKey(anyString())).thenReturn(Optional.empty());
|
||||
|
||||
FlwRouter saved = service.registerByApp(APP, body());
|
||||
|
||||
ArgumentCaptor<FlwRouter> captor = ArgumentCaptor.forClass(FlwRouter.class);
|
||||
verify(routerRepository).save(captor.capture());
|
||||
FlwRouter row = captor.getValue();
|
||||
assertEquals("work-leave.days-judge", row.getRouterKey());
|
||||
assertEquals("请假天数分级", row.getName());
|
||||
assertEquals("/oapi/flow/route/judge", row.getPath(), "path=judgePath");
|
||||
assertEquals("http://open-gateway-service:10013/work-leave", row.getDirectHost());
|
||||
assertEquals(APP, row.getServiceKey(), "NOT NULL 列填归属 appKey(展示来源用)");
|
||||
assertEquals(APP, row.getRegAppKey());
|
||||
assertEquals(1, row.getStatus().intValue());
|
||||
assertNull(row.getBasePath(), "自注册行不填 base_path(直连不再需要目录前缀)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 同appKey重复登记_幂等更新地址_status不动() {
|
||||
when(routerRepository.findByRouterKey("work-leave.days-judge"))
|
||||
.thenReturn(Optional.of(existing(APP, 0)));
|
||||
OapiModels.RouterRegBody b = body();
|
||||
b.setName("改名后的判断");
|
||||
b.setDirectHost("http://new-host:9000/work-leave");
|
||||
|
||||
service.registerByApp(APP, b);
|
||||
|
||||
ArgumentCaptor<FlwRouter> captor = ArgumentCaptor.forClass(FlwRouter.class);
|
||||
verify(routerRepository).save(captor.capture());
|
||||
FlwRouter row = captor.getValue();
|
||||
assertEquals("改名后的判断", row.getName());
|
||||
assertEquals("http://new-host:9000/work-leave", row.getDirectHost());
|
||||
assertEquals("/oapi/flow/route/judge", row.getPath());
|
||||
assertEquals(0, row.getStatus().intValue(), "停用状态不被自注册翻正");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 管理端手工行_首次自注册即认领() {
|
||||
when(routerRepository.findByRouterKey("work-leave.days-judge"))
|
||||
.thenReturn(Optional.of(existing(null, 1)));
|
||||
|
||||
service.registerByApp(APP, body());
|
||||
|
||||
ArgumentCaptor<FlwRouter> captor = ArgumentCaptor.forClass(FlwRouter.class);
|
||||
verify(routerRepository).save(captor.capture());
|
||||
assertEquals(APP, captor.getValue().getRegAppKey());
|
||||
assertEquals("http://open-gateway-service:10013/work-leave", captor.getValue().getDirectHost());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 跨应用覆盖_拒绝并保留原行() {
|
||||
when(routerRepository.findByRouterKey("work-leave.days-judge"))
|
||||
.thenReturn(Optional.of(existing("100099", 1)));
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> service.registerByApp(APP, body()));
|
||||
|
||||
assertEquals(ErrorCode.FORBIDDEN, e.getCode());
|
||||
assertTrue(e.getMessage().contains("100099"), "错误信息要能定位占用方:" + e.getMessage());
|
||||
verify(routerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未验签调用_appKey空_拒绝() {
|
||||
assertEquals(ErrorCode.UNAUTHORIZED,
|
||||
assertThrows(BizException.class, () -> service.registerByApp(null, body())).getCode());
|
||||
assertEquals(ErrorCode.UNAUTHORIZED,
|
||||
assertThrows(BizException.class, () -> service.registerByApp(" ", body())).getCode());
|
||||
verify(routerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 校验项逐个拒绝() {
|
||||
assertParam(reg -> reg.setRouterKey(" "));
|
||||
assertParam(reg -> reg.setRouterKey(repeat("k", 65)));
|
||||
assertParam(reg -> reg.setName(" "));
|
||||
assertParam(reg -> reg.setName(repeat("名", 129)));
|
||||
assertParam(reg -> reg.setJudgePath("oapi/flow/route/judge"));
|
||||
assertParam(reg -> reg.setDirectHost("open-gateway-service:10013/work-leave"));
|
||||
assertParam(reg -> reg.setDirectHost("ftp://host/work-leave"));
|
||||
// 校验全过:一条正向兜底,防"全部断言都靠拒绝"漏掉正常路径
|
||||
when(routerRepository.findByRouterKey(anyString())).thenReturn(Optional.empty());
|
||||
service.registerByApp(APP, body());
|
||||
verify(routerRepository).save(any());
|
||||
}
|
||||
|
||||
private void assertParam(java.util.function.Consumer<OapiModels.RouterRegBody> mutate) {
|
||||
OapiModels.RouterRegBody b = body();
|
||||
mutate.accept(b);
|
||||
BizException e = assertThrows(BizException.class, () -> service.registerByApp(APP, b));
|
||||
assertEquals(ErrorCode.PARAM_ERROR, e.getCode());
|
||||
}
|
||||
|
||||
private static String repeat(String s, int times) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < times; i++) {
|
||||
sb.append(s);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
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;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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;
|
||||
|
||||
/**
|
||||
* F5-4 僵尸逃生门(useLatestDef):v1 表达式必失败挂起 → 默认重跑仍按 v1 快照(行为逐字不变);
|
||||
* useLatestDef=true 改取当前已发布最高版本 v2 → 快照刷新 + trace 留痕 + 引擎按 v2 续走;
|
||||
* 在途 CONDITION 节点在新定义不存在时报人类可读业务错。锚定 128/129 僵尸案例。
|
||||
*/
|
||||
@DisplayName("reroute useLatestDef(F5-4 僵尸逃生门)")
|
||||
class FlowRouterAdminServiceRerouteDefTest {
|
||||
|
||||
private static final Long SCH = 990001L;
|
||||
|
||||
private FlwInstanceRepository instanceRepository;
|
||||
private FlwDefRepository defRepository;
|
||||
private FlwTaskRepository taskRepository;
|
||||
private com.yunhai.flowcenter.engine.FlowEngine flowEngine;
|
||||
private FlowRouterAdminService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
instanceRepository = mock(FlwInstanceRepository.class);
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
taskRepository = mock(FlwTaskRepository.class);
|
||||
flowEngine = mock(com.yunhai.flowcenter.engine.FlowEngine.class);
|
||||
service = new FlowRouterAdminService(mock(FlwRouterRepository.class), instanceRepository,
|
||||
defRepository, taskRepository, flowEngine, new ObjectMapper());
|
||||
}
|
||||
|
||||
/** v1:CONDITION 节点 n1(办件挂在此);v2:n1 已被移除的改版 */
|
||||
private static FlwDef def(long id, int version, String contentJson) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId(id);
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid("g-zombie");
|
||||
def.setCode("leave");
|
||||
def.setVersion(version);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setContentJson(contentJson);
|
||||
return def;
|
||||
}
|
||||
|
||||
private static final String V1 = "{\"nodes\":[{\"id\":\"n1\",\"type\":\"CONDITION\"}],"
|
||||
+ "\"edges\":[],\"start\":\"n1\"}";
|
||||
private static final String V2 = "{\"nodes\":[{\"id\":\"n1\",\"type\":\"APPROVAL\"}],"
|
||||
+ "\"edges\":[],\"start\":\"n1\"}";
|
||||
private static final String V2_WITH_COND = "{\"nodes\":[{\"id\":\"n1\",\"type\":\"CONDITION\"}],"
|
||||
+ "\"edges\":[{\"from\":\"n1\",\"to\":\"n2\",\"name\":\"go\"}],\"start\":\"n1\"}";
|
||||
|
||||
private FlwInstance parkedInstance(FlwDef def) {
|
||||
FlwInstance instance = new FlwInstance();
|
||||
instance.setId(500L);
|
||||
instance.setSchId(SCH);
|
||||
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
instance.setDefId(def.getId());
|
||||
instance.setDefCode(def.getCode());
|
||||
instance.setDefVersion(def.getVersion());
|
||||
instance.setCurrentNodes("n1");
|
||||
when(instanceRepository.findById(500L)).thenReturn(Optional.of(instance));
|
||||
when(defRepository.findById(def.getId())).thenReturn(Optional.of(def));
|
||||
return instance;
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultRerouteUsesDefSnapshotVerbatim() {
|
||||
FlwDef v1 = def(11L, 1, V1);
|
||||
FlwInstance instance = parkedInstance(v1);
|
||||
|
||||
service.reroute(SCH, 500L);
|
||||
|
||||
ArgumentCaptor<FlwDef> defCap = ArgumentCaptor.forClass(FlwDef.class);
|
||||
verify(flowEngine).rerouteCondition(any(), defCap.capture(), eq("n1"));
|
||||
assertEquals(11L, defCap.getValue().getId(), "默认重跑按落库 def 快照(v1)");
|
||||
assertEquals(11L, instance.getDefId(), "默认不刷新定义快照");
|
||||
verify(taskRepository, never()).save(any());
|
||||
verify(defRepository, never())
|
||||
.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void v1FailureKeepsInstanceHungUnderDefault() {
|
||||
FlwDef v1 = def(11L, 1, V1);
|
||||
parkedInstance(v1);
|
||||
// 引擎按 v1 重跑仍失败(判断服务还是挂)——业务错上抛,办件保持挂起
|
||||
org.mockito.Mockito.doThrow(new BizException(ErrorCode.PARAM_ERROR, "判断仍失败,办件继续挂起"))
|
||||
.when(flowEngine).rerouteCondition(any(), eq(v1), eq("n1"));
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 500L));
|
||||
assertTrue(e.getMessage().contains("判断仍失败"), "v1 必失败场景默认重跑仍失败挂起(逃生门入口即为此)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void useLatestDefSwitchesToV2AndRefreshesSnapshotWithTrace() {
|
||||
FlwDef v1 = def(11L, 1, V1);
|
||||
FlwDef v2 = def(12L, 2, V2_WITH_COND);
|
||||
FlwInstance instance = parkedInstance(v1);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
|
||||
.thenReturn(Optional.of(v2));
|
||||
|
||||
service.reroute(SCH, 500L, true);
|
||||
|
||||
ArgumentCaptor<FlwDef> defCap = ArgumentCaptor.forClass(FlwDef.class);
|
||||
verify(flowEngine).rerouteCondition(any(), defCap.capture(), eq("n1"));
|
||||
assertEquals(12L, defCap.getValue().getId(), "useLatestDef=true 引擎按 v2 续走");
|
||||
assertEquals(12L, instance.getDefId(), "定义快照三列同步刷新(R4 同语义)");
|
||||
assertEquals(Integer.valueOf(2), instance.getDefVersion());
|
||||
ArgumentCaptor<FlwTask> trace = ArgumentCaptor.forClass(FlwTask.class);
|
||||
verify(taskRepository).save(trace.capture());
|
||||
assertEquals(FlwEnums.TaskStatus.NODE_REROUTE_DEF, trace.getValue().getNodeId(),
|
||||
"trace 落保留节点 id 的留痕行");
|
||||
assertTrue(trace.getValue().getNodeName().contains("v1→v2"), "留痕行载明版本迁移");
|
||||
verify(instanceRepository).save(instance);
|
||||
}
|
||||
|
||||
@Test
|
||||
void useLatestDefWithSameVersionIsNoopRefresh() {
|
||||
FlwDef v1 = def(11L, 1, V1);
|
||||
FlwInstance instance = parkedInstance(v1);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
|
||||
.thenReturn(Optional.of(v1));
|
||||
|
||||
service.reroute(SCH, 500L, true);
|
||||
|
||||
verify(flowEngine).rerouteCondition(any(), eq(v1), eq("n1"));
|
||||
verify(taskRepository, never()).save(any());
|
||||
assertEquals(11L, instance.getDefId(), "已是最新版不落留痕行、不刷新");
|
||||
}
|
||||
|
||||
@Test
|
||||
void useLatestDefRejectsWhenNodeMissingInNewDef() {
|
||||
FlwDef v1 = def(11L, 1, V1);
|
||||
// v2 把 n1 改型为 APPROVAL:在途 CONDITION 节点在新定义不存在
|
||||
FlwDef v2 = def(12L, 2, V2);
|
||||
parkedInstance(v1);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
eq(SCH), eq("g-zombie"), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
|
||||
.thenReturn(Optional.of(v2));
|
||||
|
||||
BizException e = assertThrows(BizException.class, () -> service.reroute(SCH, 500L, true));
|
||||
assertTrue(e.getMessage().contains("最新已发布定义中不存在"), "节点在新定义缺失要报人类可读错,不是笼统的无需重跑");
|
||||
verify(flowEngine, never()).rerouteCondition(any(), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.openapi.core.OpenApiException;
|
||||
import com.yunhai.platform.openapi.core.OpenApiErrorCode;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link FormCenterSchemaClient} 单元测试(2026-10-20 C1 第一刀)。<br>
|
||||
* 覆盖:① SchemaView(data) → 快照节的 fields 扁平化(分组展开、enum 收 options 值域);
|
||||
* ② audience <b>数组解析</b>(契约 v2 C5/G3 breaking)+ 旧单值串容错;③ 失败重试 2 次后转
|
||||
* SchemaUnavailableException(发布阻断语义)。
|
||||
*/
|
||||
class FormCenterSchemaClientTest {
|
||||
|
||||
private YschOpenCall openCall;
|
||||
private FormCenterSchemaClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
openCall = mock(YschOpenCall.class);
|
||||
client = new FormCenterSchemaClient(openCall, new ObjectMapper());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> schemaData(String uuid, int version, List<String> audience) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("formUuid", uuid);
|
||||
data.put("version", version);
|
||||
data.put("code", "leave-form");
|
||||
data.put("appCode", "svc-hall");
|
||||
data.put("audience", audience);
|
||||
data.put("groups", Arrays.asList(
|
||||
map("key", "base", "label", "基本信息", "fields", Arrays.asList(
|
||||
map("key", "days", "label", "天数", "type", "number", "required", true),
|
||||
map("key", "grade", "label", "年级", "type", "select", "required", false,
|
||||
"options", Arrays.asList(map("label", "高一", "value", "g1"),
|
||||
map("label", "高二", "value", "g2"))))),
|
||||
map("key", "extra", "label", "补充", "fields", Arrays.asList(
|
||||
map("key", "reason", "label", "事由", "type", "text", "required", true)))));
|
||||
return data;
|
||||
}
|
||||
|
||||
private static Map<String, Object> map(Object... kv) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
for (int i = 0; i + 1 < kv.length; i += 2) {
|
||||
m.put((String) kv[i], kv[i + 1]);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void flattensGroupsIntoFieldsAndCollectsEnumFromOptions() {
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||
.thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER")));
|
||||
|
||||
Map<String, Object> snap = client.fetchSchema(27L, "U1", 3);
|
||||
|
||||
assertEquals("U1", snap.get("formUuid"));
|
||||
assertEquals(3, snap.get("formVersion"));
|
||||
assertEquals(Arrays.asList("STUDENT", "TEACHER"), snap.get("audience"));
|
||||
List<Map<String, Object>> fields = (List<Map<String, Object>>) snap.get("fields");
|
||||
assertEquals(3, fields.size(), "两个分组的字段要扁平化到一处");
|
||||
Map<String, Object> grade = fields.stream()
|
||||
.filter(f -> "grade".equals(f.get("key"))).findFirst().orElseThrow(AssertionError::new);
|
||||
assertEquals("select", grade.get("type"));
|
||||
assertEquals(Boolean.FALSE, grade.get("required"));
|
||||
assertEquals(Arrays.asList("g1", "g2"), grade.get("enum"), "options 值域收进 enum(域查③的输入)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacySingleStringAudienceIsWrappedAsArray() {
|
||||
Map<String, Object> data = schemaData("U1", 3, null);
|
||||
data.put("audience", "STUDENT");
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
|
||||
|
||||
Map<String, Object> snap = client.fetchSchema(27L, "U1", 3);
|
||||
|
||||
assertEquals(Arrays.asList("STUDENT"), snap.get("audience"),
|
||||
"C5 切换窗口的旧单值串按一元素数组容错");
|
||||
}
|
||||
|
||||
@Test
|
||||
void retriesTwiceThenThrowsUnavailable() {
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||
.thenThrow(new RuntimeException("connect refused"));
|
||||
|
||||
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
|
||||
() -> client.fetchSchema(27L, "U1", 3));
|
||||
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
|
||||
.get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void nullDataTreatedAsFailure() {
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null);
|
||||
|
||||
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
|
||||
() -> client.fetchSchema(27L, "U1", 3));
|
||||
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
|
||||
.get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@ValueSource(strings = {"0", "-1", "4", "3.5", "wrong", "2147483648"})
|
||||
void malformedOrWrongReturnedVersionIsContractFailureWithoutRetry(String returnedVersion) {
|
||||
Map<String, Object> data = schemaData("U1", 3, Arrays.asList("STUDENT"));
|
||||
data.put("version", returnedVersion);
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
|
||||
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
|
||||
() -> client.fetchSchema(27L, "U1", 3));
|
||||
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@ValueSource(strings = {"", "U2"})
|
||||
void missingOrWrongReturnedUuidIsContractFailureWithoutRetry(String returnedUuid) {
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||
.thenReturn(schemaData(returnedUuid, 3, Arrays.asList("STUDENT")));
|
||||
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
|
||||
() -> client.fetchSchema(27L, "U1", 3));
|
||||
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {0, -1})
|
||||
void illegalRequestedVersionMakesNoRemoteCall(int version) {
|
||||
assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(27L, "U1", version));
|
||||
org.mockito.Mockito.verifyNoInteractions(openCall);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resourceRejectionUsesEnvelopeCodeAndDoesNotRetry() {
|
||||
// SDK 实际携带 OpenApiErrorCode,不会转换成本地 ErrorCode 枚举;文案刻意无关键词。
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||
.thenThrow(new DException(new OpenApiErrorCode(2, "opaque")));
|
||||
assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class,
|
||||
() -> client.fetchSchema(27L, "U1", 3));
|
||||
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {3, 4})
|
||||
void authRejectionDoesNotBlindlyRetry(int code) {
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||
.thenThrow(new DException(new OpenApiErrorCode(code, "opaque")));
|
||||
assertThrows(FormCenterSchemaClient.SchemaAccessDeniedException.class,
|
||||
() -> client.fetchSchema(27L, "U1", 3));
|
||||
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void http404DoesNotBlindlyRetry() {
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||
.thenThrow(new OpenApiException("opaque", 404, ""));
|
||||
assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class,
|
||||
() -> client.fetchSchema(27L, "U1", 3));
|
||||
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalRemoteFailureRemainsRetryable() {
|
||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||
.thenThrow(new DException(new OpenApiErrorCode(6, "opaque")));
|
||||
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
|
||||
() -> client.fetchSchema(27L, "U1", 3));
|
||||
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
|
||||
.get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.AssigneeResolver;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiAppClient;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* S2.7 版本竞态(⭐):apply 可选回带预览时的定义版本,改版即人类可读拒绝。
|
||||
* 三态必测——不传(存量调用方,行为逐字不变)、传对(放行)、传旧(拒绝且不落任何引擎动作)。
|
||||
* 另钉住 preview 回带 defVersion(调用方拿得到的前提)。
|
||||
*/
|
||||
@DisplayName("apply 期望定义版本(版本竞态人类可读报错)")
|
||||
class OapiFlowApplyDefVersionTest {
|
||||
|
||||
private static final Long SCH_ID = 1L;
|
||||
private static final String FLOW_GUID = "g-assign";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private FlowInstanceService instanceService;
|
||||
private AssigneeResolver assigneeResolver;
|
||||
private OapiFlowService service;
|
||||
private FlwInstanceRepository instances;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
instanceService = mock(FlowInstanceService.class);
|
||||
assigneeResolver = mock(AssigneeResolver.class);
|
||||
instances = mock(FlwInstanceRepository.class);
|
||||
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), mock(OpenApiAppClient.class),
|
||||
mock(DiagramTokenService.class), mock(ObjectProvider.class));
|
||||
// 本校校验在单测里关闭(@Value 字段未注入=默认 false),引擎出参给一个既有办件
|
||||
when(instanceService.apply(any(FlowInstanceService.ApplyCommand.class)))
|
||||
.thenReturn(new FlowInstanceService.ApplyOutcome(500L, false));
|
||||
}
|
||||
|
||||
private static FlwDef publishedDef(int version) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId(9L);
|
||||
def.setSchId(SCH_ID);
|
||||
def.setFlowGuid(FLOW_GUID);
|
||||
def.setVersion(version);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setContentJson("{\"nodes\":[], \"edges\":[], \"start\":\"n1\"}");
|
||||
return def;
|
||||
}
|
||||
|
||||
private static OapiModels.ApplyBody applyBody(Integer expectedDefVersion) {
|
||||
OapiModels.ApplyBody body = new OapiModels.ApplyBody();
|
||||
body.setFlowGuid(FLOW_GUID);
|
||||
body.setBizType("notice");
|
||||
body.setBizId("77");
|
||||
body.setBizTitle("标题");
|
||||
body.setSubmitterId(21);
|
||||
body.setSubmitterName("甲");
|
||||
body.setExpectedDefVersion(expectedDefVersion);
|
||||
return body;
|
||||
}
|
||||
|
||||
private void stubDef(int version) {
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
eq(SCH_ID), eq(FLOW_GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
|
||||
.thenReturn(Optional.of(publishedDef(version)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutExpectedVersion_behavesExactlyAsBefore() {
|
||||
stubDef(3);
|
||||
|
||||
OapiModels.ApplyResultVo vo = service.apply(SCH_ID, "100009", applyBody(null));
|
||||
|
||||
assertEquals(Long.valueOf(500L), vo.getInstanceId());
|
||||
verify(instanceService).apply(any(FlowInstanceService.ApplyCommand.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchingVersion_passesThrough() {
|
||||
stubDef(3);
|
||||
|
||||
OapiModels.ApplyResultVo vo = service.apply(SCH_ID, "100009", applyBody(3));
|
||||
|
||||
assertEquals(Long.valueOf(500L), vo.getInstanceId());
|
||||
verify(instanceService).apply(any(FlowInstanceService.ApplyCommand.class));
|
||||
}
|
||||
|
||||
/** 改版后按旧预览结果提交:人类可读文案,且引擎与指派校验都不被触发(不污染幂等键) */
|
||||
@Test
|
||||
void staleExpectedVersion_rejectedWithReadableMessage_beforeAnyEngineAction() {
|
||||
stubDef(4);
|
||||
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.apply(SCH_ID, "100009", applyBody(3)));
|
||||
|
||||
assertTrue(e.getMessage().contains("流程已更新,请刷新后重新选择审批人"), e.getMessage());
|
||||
assertTrue(e.getMessage().contains("期望定义版本 3") && e.getMessage().contains("当前已发布版本 4"),
|
||||
e.getMessage());
|
||||
verify(instanceService, never()).apply(any(FlowInstanceService.ApplyCommand.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewCarriesDefVersionSoCallerCanEchoIt() {
|
||||
stubDef(7);
|
||||
when(assigneeResolver.resolve(anyLong(), any())).thenReturn(java.util.Collections.emptyList());
|
||||
|
||||
OapiModels.ApplyPreviewVo vo = service.applyPreview(SCH_ID, FLOW_GUID, 21);
|
||||
|
||||
assertEquals(Integer.valueOf(7), vo.getDefVersion());
|
||||
assertEquals(FLOW_GUID, vo.getFlowGuid());
|
||||
assertTrue(vo.getNodes() == null || vo.getNodes().isEmpty());
|
||||
}
|
||||
|
||||
/** 存量兼容:无指派节点的流程 preview 仍返回空节点集(defVersion 不影响既有消费方) */
|
||||
@Test
|
||||
void previewKeepsEmptyNodesForFlowWithoutAssignPolicy() {
|
||||
stubDef(1);
|
||||
|
||||
OapiModels.ApplyPreviewVo vo = service.applyPreview(SCH_ID, FLOW_GUID, 21);
|
||||
|
||||
assertEquals(Integer.valueOf(1), vo.getDefVersion());
|
||||
assertTrue(vo.getNodes() == null || vo.getNodes().isEmpty());
|
||||
}
|
||||
@Test
|
||||
void v2ResponseCarriesRealRejectedStatusAndStableBranch() {
|
||||
stubDef(3);
|
||||
com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2 body = new com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2();
|
||||
body.setFlowGuid(FLOW_GUID); body.setBizType("svc-hall"); body.setBizId("42"); body.setSubmitterId(21);
|
||||
body.setRequestToken("token-1"); body.setAttempt(1);
|
||||
when(instanceService.applyGuarded(any(), any())).thenReturn(
|
||||
new FlowInstanceService.GuardedOutcome(500L, true, "SAME_REQUEST", 1));
|
||||
com.yunhai.flowcenter.entity.FlwInstance row = new com.yunhai.flowcenter.entity.FlwInstance();
|
||||
row.setId(500L); row.setStatus(30); row.setVersion(4); row.setApplyRound(1);
|
||||
when(instances.findById(500L)).thenReturn(Optional.of(row));
|
||||
com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyResultV2 result = service.applyV2(SCH_ID, "100009", body);
|
||||
assertEquals("SAME_REQUEST", result.getBranch()); assertEquals(Integer.valueOf(1), result.getApplyRound());
|
||||
assertEquals(Integer.valueOf(30), result.getStatus()); assertEquals(Integer.valueOf(4), result.getVersion());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.AssigneeResolver;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiAppClient;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* F9 mode=task 直跳任务办理页(P6.2):pageUrl = front-url + #/task/{taskId}(无 token);
|
||||
* taskId 缺失 PARAM_ERROR;front-url 未配置沿用 fail-fast 纪律(先于一切定位)。
|
||||
*/
|
||||
@DisplayName("embed/url mode=task(F9 任务页直跳)")
|
||||
class OapiFlowEmbedTaskUrlTest {
|
||||
|
||||
private OapiFlowService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new OapiFlowService(Mockito.mock(FlwDefRepository.class),
|
||||
Mockito.mock(FlwInstanceRepository.class), Mockito.mock(FlwTaskRepository.class),
|
||||
Mockito.mock(FlwCcRepository.class), Mockito.mock(FlwCallbackRegRepository.class),
|
||||
Mockito.mock(FlowInstanceService.class), Mockito.mock(FlowTaskService.class),
|
||||
Mockito.mock(FlowEngine.class), Mockito.mock(AssigneeResolver.class),
|
||||
new ObjectMapper(), Mockito.mock(OpenApiUserClient.class),
|
||||
Mockito.mock(OpenApiAppClient.class), Mockito.mock(DiagramTokenService.class),
|
||||
Mockito.mock(ObjectProvider.class));
|
||||
}
|
||||
|
||||
private void frontUrl(String value) {
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "embedFrontUrl", value);
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskModeIssuesCenterTaskPageUrlWithoutToken() {
|
||||
frontUrl("http://fc.local/flow-center");
|
||||
OapiModels.EmbedUrlVo vo = service.embedUrl(1L, null, null, null, "task", 88L);
|
||||
assertEquals("http://fc.local/flow-center/#/task/88", vo.getPageUrl(),
|
||||
"pageUrl = front-url(补尾斜杠)+ #/task/{taskId}");
|
||||
assertEquals(0L, vo.getExpiresIn(), "无 token 模式无有效期,expiresIn=0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskModeWithoutTaskIdRejected() {
|
||||
frontUrl("http://fc.local/flow-center");
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.embedUrl(1L, null, null, null, "task", null));
|
||||
assertEquals(ErrorCode.PARAM_ERROR, e.getErrorCode(), "缺 taskId 是参数错误");
|
||||
assertTrue(e.getMessage().contains("taskId"), "报错须点名缺失参数");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFrontUrlStillFailsFastEvenForTaskMode() {
|
||||
frontUrl(" ");
|
||||
BizException e = assertThrows(BizException.class,
|
||||
() -> service.embedUrl(1L, null, null, null, "task", 88L));
|
||||
assertEquals(ErrorCode.INTERNAL_SERVER_ERROR, e.getErrorCode(),
|
||||
"配置缺失优先于 mode 分支:宁可拒绝也不发打不开的地址");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.yunhai.flowcenter.stub;
|
||||
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.openapi.base.model.ChannelBindByPhoneRequest;
|
||||
import com.yunhai.platform.openapi.base.model.ChannelBindRequest;
|
||||
import com.yunhai.platform.openapi.base.model.SysUserGroupDeleteItem;
|
||||
import com.yunhai.platform.openapi.base.model.SysUserGroupStateRequest;
|
||||
import com.yunhai.platform.openapi.base.model.SysUserMoveRequest;
|
||||
@@ -139,24 +137,4 @@ public class StubUserClient implements OpenApiUserClient {
|
||||
public void batchChangeUserStatus(List<Integer> userIds, boolean disabled) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser getChannelBoundUser(String channel, String openId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser bindChannel(ChannelBindRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoaUser bindChannelByPhone(ChannelBindByPhoneRequest request) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unbindChannel(String channel, String openId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user