Compare commits
61
Commits
695e20ab52
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f2883c1af | ||
|
|
d72128672f | ||
|
|
b369527fe1 | ||
|
|
8a1294aef2 | ||
|
|
302216a07c | ||
|
|
8d4a0bdba8 | ||
|
|
500a9a2fcb | ||
|
|
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 | ||
|
|
d28b5d1161 | ||
|
|
1d2f4a0cb2 | ||
|
|
d6898ea674 | ||
|
|
09c3c0c38c | ||
|
|
cacdd4ee14 | ||
|
|
2384da7822 | ||
|
|
2f8daa12a5 | ||
|
|
31476ef707 | ||
|
|
d2bef20e13 | ||
|
|
f2126cdf32 | ||
|
|
d2395c20ce |
@@ -16,6 +16,10 @@ on:
|
||||
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,8 @@ 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
|
||||
cp -a /tmp/src/. .
|
||||
rm -rf /tmp/src
|
||||
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
|
||||
@@ -55,20 +64,47 @@ 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"
|
||||
trap 'docker rm -f "$BUILD_CONTAINER" "$DB_CONTAINER" >/dev/null 2>&1 || true' EXIT
|
||||
export MYSQL_ROOT_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
|
||||
export MYSQL_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
|
||||
docker create --name "$DB_CONTAINER" --network infra-infra-net \
|
||||
-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"
|
||||
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: ${{ inputs.tests_only != 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
|
||||
@@ -79,6 +115,7 @@ jobs:
|
||||
|
||||
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
|
||||
- name: 通知 projman 建档(部署由运维手动触发)
|
||||
if: ${{ inputs.tests_only != 'true' }}
|
||||
run: |
|
||||
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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 来绕过缺表或缺列。
|
||||
@@ -0,0 +1,449 @@
|
||||
|
||||
/*!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_reg` (`event_id`,`reg_id`),
|
||||
KEY `idx_flw_log_scan` (`status`,`next_retry_time`),
|
||||
KEY `idx_flw_log_inst` (`instance_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!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_bin DEFAULT NULL,
|
||||
`biz_snapshot` longtext COLLATE utf8mb4_general_ci,
|
||||
`biz_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`biz_type` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL,
|
||||
`biz_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`current_nodes` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`def_code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
|
||||
`def_id` bigint NOT NULL,
|
||||
`def_version` int NOT NULL,
|
||||
`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,
|
||||
`apply_payload_hash` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`event_seq_counter` int DEFAULT NULL,
|
||||
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`legacy_slot` int DEFAULT 1,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_instance_legacy` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`,`legacy_slot`),
|
||||
UNIQUE KEY `uk_flw_instance_round` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`,`apply_round`),
|
||||
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 */;
|
||||
|
||||
|
||||
-- Codex C audit: isolated CI initialization only, no business-row DML.
|
||||
-- 2026-10-25 T03:流程线级停发标志(整线停新发起,不影响在途;与版本级 status 正交)
|
||||
-- ddl-auto=update 会自动加列(NOT NULL DEFAULT 0);本脚本为生产备案
|
||||
ALTER TABLE `flw_def`
|
||||
ADD COLUMN `line_offline` INT NOT NULL DEFAULT 0 COMMENT '线级停发:1=整线停新发起/预览(在途不受影响)' AFTER `version`;
|
||||
|
||||
-- 已有生产索引不含 line_offline;新发起解析走 (sch_id, flow_guid, status, version) 既有索引后过滤,暂不加列索引
|
||||
|
||||
-- 2026-10-25 T05:状态事件事实(同事务落库)+ 待推进意图(办理事务内持久化)
|
||||
-- 注:event_seq 为尽力单调(REQUIRES_NEW 推进拓扑下极端并发可能同值),非唯一键;严格全序待专用发号器
|
||||
-- ddl-auto=update 会自动建表;本脚本为生产备案
|
||||
CREATE TABLE IF NOT EXISTS `flw_state_event` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`event_id` VARCHAR(32) NOT NULL COMMENT '稳定事件身份(全订阅方/重试复用)',
|
||||
`sch_id` BIGINT NOT NULL,
|
||||
`instance_id` BIGINT NOT NULL,
|
||||
`apply_round` INT NULL COMMENT '发布时冻结的申报轮次',
|
||||
`event_seq` INT NOT NULL COMMENT '实例内单调序号',
|
||||
`event_type` VARCHAR(32) NOT NULL,
|
||||
`task_id` BIGINT NULL,
|
||||
`operator_id` INT NULL,
|
||||
`payload_json` LONGTEXT NOT NULL COMMENT '发生时载荷快照',
|
||||
`dispatched` INT NOT NULL DEFAULT 0 COMMENT '0=待分发(崩溃窗口恢复扫描);1=已分发',
|
||||
`create_by` VARCHAR(255) NULL, `create_time` DATETIME(6) NULL,
|
||||
`update_by` VARCHAR(255) NULL, `update_time` DATETIME(6) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_state_event_id` (`event_id`),
|
||||
KEY `idx_flw_state_event_instance` (`instance_id`),
|
||||
KEY `idx_flw_state_event_dispatch` (`dispatched`, `create_time`),
|
||||
KEY `idx_flw_state_event_seq` (`instance_id`, `event_seq`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '流程状态事件事实(T05 同事务 outbox)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `flw_advance_intent` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`instance_id` BIGINT NOT NULL,
|
||||
`node_id` VARCHAR(64) NOT NULL,
|
||||
`edge_name` VARCHAR(128) NULL,
|
||||
`assignee_id` INT NULL,
|
||||
`assignee_name` VARCHAR(64) NULL,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/DONE/EXHAUSTED',
|
||||
`attempts` INT NOT NULL DEFAULT 0,
|
||||
`next_retry_at` DATETIME(6) NULL,
|
||||
`last_error` VARCHAR(255) NULL,
|
||||
`create_by` VARCHAR(255) NULL, `create_time` DATETIME(6) NULL,
|
||||
`update_by` VARCHAR(255) NULL, `update_time` DATETIME(6) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_flw_advance_intent_due` (`status`, `next_retry_at`),
|
||||
KEY `idx_flw_advance_intent_instance` (`instance_id`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '待推进意图(T05 办理事务内持久化+事务外 worker)';
|
||||
|
||||
|
||||
-- 2026-10-25 T01:应用使用授权(学校×流程线×稳定应用主体)
|
||||
-- ddl-auto=update 会自动建表;本脚本为生产备案(生产建议停自动 DDL 后手工执行)
|
||||
-- 撤权语义:enabled=0 只拦新发起;已受理命令回执恢复与在途实例不回读本表。
|
||||
CREATE TABLE IF NOT EXISTS `flw_usage_grant` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`sch_id` BIGINT NOT NULL COMMENT '租户键',
|
||||
`flow_guid` VARCHAR(64) NOT NULL COMMENT '流程线稳定身份(跨版本不变)',
|
||||
`app_id` INT NOT NULL COMMENT '稳定应用主体主键(UserBase application_info.id)',
|
||||
`app_key_snapshot` VARCHAR(64) NULL COMMENT '授权时 appKey 快照(审计回溯;判权不读)',
|
||||
`app_name_snapshot` VARCHAR(128) NULL COMMENT '授权时应用名快照',
|
||||
`enabled` INT NOT NULL DEFAULT 1 COMMENT '1=许可生效;0=撤销(只拦新发起)',
|
||||
`remark` VARCHAR(255) NULL COMMENT '备注(人工审计线索)',
|
||||
`create_by` VARCHAR(64) NULL,
|
||||
`create_time` DATETIME(6) NULL,
|
||||
`update_by` VARCHAR(64) NULL,
|
||||
`update_time` DATETIME(6) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flw_usage_grant` (`sch_id`, `flow_guid`, `app_id`),
|
||||
KEY `idx_flw_usage_grant_sch_flow` (`sch_id`, `flow_guid`),
|
||||
KEY `idx_flw_usage_grant_app` (`app_id`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '应用使用授权(T01 三权分立:使用许可≠定义管理权≠flw_app 白名单)';
|
||||
|
||||
ALTER TABLE flw_usage_grant ADD COLUMN source_principal_id VARCHAR(36) NULL;
|
||||
ALTER TABLE flw_callback_reg ADD COLUMN source_principal_id VARCHAR(36) NULL;
|
||||
CREATE TABLE flw_command_source (
|
||||
id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY, app_id int NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
CREATE TABLE flw_biz_coordinate (
|
||||
id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, sch_id bigint NOT NULL,
|
||||
source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
biz_type varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
biz_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
last_round int NOT NULL DEFAULT 0, last_instance_id bigint NULL, active_instance_id bigint NULL,
|
||||
create_by varchar(255) NULL, create_time datetime NULL, update_by varchar(255) NULL, update_time datetime NULL,
|
||||
UNIQUE KEY uk_flw_coord(sch_id,source_principal_id,biz_type,biz_id)
|
||||
) ENGINE=InnoDB;
|
||||
CREATE TABLE flw_apply_command (
|
||||
id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, sch_id bigint NOT NULL,
|
||||
source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
request_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
canonical_hash varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
canonical_version int NOT NULL,
|
||||
biz_type varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
biz_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
flow_guid varchar(32) NOT NULL, state varchar(16) NOT NULL, instance_id bigint NULL,
|
||||
apply_round int NULL, def_id bigint NULL, def_version int NULL, reject_code varchar(64) NULL, reject_detail varchar(500) NULL,
|
||||
create_by varchar(255) NULL, create_time datetime NULL, update_by varchar(255) NULL, update_time datetime NULL,
|
||||
UNIQUE KEY uk_flw_command_request(sch_id,source_principal_id,request_id),
|
||||
KEY idx_flw_command_instance(instance_id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
ALTER TABLE flw_advance_intent
|
||||
ADD COLUMN activation_task_id BIGINT NULL,
|
||||
ADD COLUMN activation_task_ids LONGTEXT NULL,
|
||||
ADD COLUMN handled_task_ids LONGTEXT NULL,
|
||||
ADD COLUMN trigger_task_id BIGINT NULL,
|
||||
ADD COLUMN activation_def_id BIGINT NULL,
|
||||
ADD COLUMN activation_def_version INT NULL,
|
||||
ADD COLUMN activation_apply_round INT NULL,
|
||||
ADD COLUMN revision BIGINT NOT NULL DEFAULT 0,
|
||||
ADD UNIQUE KEY uk_flw_advance_activation (activation_task_id);
|
||||
|
||||
@@ -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,26 @@
|
||||
#!/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"
|
||||
# 只钉“缺表必须让真实库用例报 error、其余断言不得失败”这一契约,不钉具体用例总数:
|
||||
# 门禁类会随被测类新增用例而变化(2026-10-27 已由 92 增至 101),写死总数会把正常演进变成红灯。
|
||||
summary=$(sed -n 's/.*Tests run: \([0-9]*\), Failures: \([0-9]*\), Errors: \([0-9]*\), Skipped: \([0-9]*\).*/\1 \2 \3 \4/p' "$log" | tail -1)
|
||||
[ -n "$summary" ] || { echo 'Negative gate produced no Surefire summary' >&2; exit 1; }
|
||||
set -- $summary
|
||||
echo "NEGATIVE_SUMMARY tests=$1 failures=$2 errors=$3 skipped=$4"
|
||||
[ "$1" -gt 0 ] || { echo 'No tests ran' >&2; exit 1; }
|
||||
[ "$2" -eq 0 ] || { echo 'Empty schema caused assertion failures, not schema errors' >&2; exit 1; }
|
||||
[ "$3" -gt 0 ] || { echo 'Empty schema did not produce any schema error' >&2; exit 1; }
|
||||
[ -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,105 @@
|
||||
# FL 后端核心修复执行回执 — Codex
|
||||
|
||||
执行日期:2026-10-27(Asia/Shanghai)。范围依据:《FL后端核心修复任务书_Codex_无人值守版.md》。
|
||||
|
||||
结论:**已核实**线级守卫的取反缺陷已作最小修复,测试改为真实调用生产 Service;定向测试 43/43 通过。**待核实(环境阻塞)**真实数据库提交后刷新回读、HTTP 联调及真实 AFTER_COMMIT 行为。本回执不代表整个 FL 终审通过。
|
||||
|
||||
## 1. 基线与范围
|
||||
|
||||
- 仓库:`D:/SoftProject/yunhai/flow-center/service`。
|
||||
- **已核实**分支 `main`,HEAD `a10f2e054c2422ae2dfde82926ae567ad9096093`。这是本地仓库基线,不是本次核实过的线上运行版本。
|
||||
- **已核实**开工时 `git status --short` 共 104 项;`FlowDefAdminService.java` 已有修改,`FlDefLineOfflineTest.java` 已存在但未跟踪。均按已有 WIP 保护。
|
||||
- 开工状态和已跟踪差异见 [baseline-status.txt](evidence/core-fix-20261027/baseline-status.txt)、[baseline-tracked.patch](evidence/core-fix-20261027/baseline-tracked.patch);两个目标文件的原始快照也保存在同目录。
|
||||
- 已读上级 `D:/SoftProject/yunhai/AGENTS.md`、`docs/云海AI代理探索与性能纪律.md`、`docs/云海项目导航索引.md`。入口所指 `docs/云海平台架构画像_资产与文档基线盘点.md` 在指定路径不存在,未据此推断环境事实。
|
||||
- 任务书所列线上验收结论按**历史材料记载**引用:新实例 152–157 的回调 36/36 对账成功;本轮未重新访问生产或重跑矩阵,也未取得那些报告的独立正文。
|
||||
|
||||
## 2. 本轮代码差异
|
||||
|
||||
仅包含三份 Java 文件;以开工 WIP 为基线的独立差异见 [task-only.patch](evidence/core-fix-20261027/task-only.patch)。该补丁不是相对 HEAD 的完整发布补丁;应用前应保留原 WIP。
|
||||
|
||||
| 文件 | 本轮修改与原因 |
|
||||
|---|---|
|
||||
| `src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java:302` | 在存在性查询前补 `!`,改为缺失时拒绝。保留 null/空白拒绝、trim 和 `(schId, flowGuid)` 查询范围。生产代码仅改这一处判断。 |
|
||||
| `src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java` | 删除复制业务逻辑的 `FlwDefAdminServiceSpy`,用已有构造辅助方法创建真实 `FlowDefAdminService`;补齐停发/恢复非法 GUID、空值、空白、错误学校、trim、发布内容及在途协作隔离断言。 |
|
||||
| `src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java` | 增加新实例路径定向测试:真实创建 Service、引擎、任务 Service、激活意图 Service 和监听器串接;repository、人员解析和发布器为 mock。 |
|
||||
|
||||
未改 pom、生产配置、前端、FC、Notice、UserBase、网关或 SQL;未新增回填机制,未关闭 D09,未改状态机或事件契约。已有这些路径的差异属于开工 WIP。
|
||||
|
||||
## 3. 真实调用与缺陷捕获证据
|
||||
|
||||
先仅替换测试替身,保留错误生产判断,执行原 7 项停发测试:**已核实**失败 1、错误 1,跳过 0。
|
||||
|
||||
- `lineOfflineDelegatesBulkUpdate`:真实存在的 `g-line` 被生产 `requireLine` 抛错拒绝。
|
||||
- `unknownLineRejected`:不存在的 `g-nope` 未抛异常,断言失败。
|
||||
|
||||
原始输出见 [red-test.log](evidence/core-fix-20261027/red-test.log)。随后才修复生产判断并补测试。当前测试字段类型是 `FlowDefAdminService`,由 `new FlowDefAdminService(...)` 实例化,直接调用其公开 `offlineLine` / `enableLine`;被测类和被测方法均未 mock/spy,也没有测试替身里的守卫副本。这一真实失败足以证明重新写反判断会被捕获。
|
||||
|
||||
## 4. 测试结果及实际验证层级
|
||||
|
||||
| 范围 | 结果 | 状态与限制 |
|
||||
|---|---:|---|
|
||||
| `FlDefLineOfflineTest` | 14/14 | **已核实**真实管理 Service:已有线双路径通过,分别委托批量更新 1/0;未知 GUID、null、空串、两种空白和错误学校双路径拒绝且不更新;trim;发布版本、内容及在途协作隔离;新发起/预览停发守卫。repository 为 mock。 |
|
||||
| `FlNewInstancePathTest` | 1/1 | **已核实**新实例创建预期办理人的 pending 任务;任务创建事件通过生产监听器/Service 登记本次真实任务成员、定义 ID 和轮次;合法审批记录意图并推进到完成。把定义线标志置为停发后,在途任务仍使用原 defId/version 完成;未查询最新已发布版本替换原版本。 |
|
||||
| `FlApplyRecoveryTest#createsWhenAbsentAndStampsIdentity` | 1/1 | **已核实**新建实例分支、token/轮次、审批中状态与 START 委托。仅执行这个方法。 |
|
||||
| `FlAdvanceIntentRecoveryTest` | 11/11 | **已核实**生产激活结算 Service 的推进/重试/代次保护定向回归。 |
|
||||
| `FlStateEventRecoveryTest` | 7/7 | **已核实**生产事实记录器的事件身份及冻结载荷等断言。 |
|
||||
| `CallbackRecoveryDeliveryTest` | 9/9 | **已核实**生产分发/恢复逻辑和接收契约的内存协作测试;不是线上网络送达验证。 |
|
||||
| 合计 | **43/43** | Failures 0 / Errors 0 / Skipped 0;Maven BUILD SUCCESS,43.529 秒。 |
|
||||
|
||||
完整输出见 [targeted-test.log](evidence/core-fix-20261027/targeted-test.log),同目录保存了这六个类的 Surefire XML 和文本报告,避免后续运行覆盖证据。Windows Maven 输出部分中文因原工具链编码显示乱码,测试名、数量及 XML 可复核。
|
||||
|
||||
新路径测试直接调用真实业务代码;测试显式驱动同步监听器和后提交入口,并仅初始化 Spring synchronization 以校验激活批次完整性。**没有真实事务、Spring AOP、JPA 锁或数据库提交;不能把这个测试描述为真实 AFTER_COMMIT/数据库集成通过。**
|
||||
|
||||
## 5. 实际命令
|
||||
|
||||
在 service 仓库执行:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
git branch --show-current
|
||||
git rev-parse HEAD
|
||||
git diff --binary
|
||||
mvn -version
|
||||
|
||||
# 生产缺陷仍存在,仅将测试改为调用真实 Service;预期失败,exit=1
|
||||
mvn -o '-Dmaven.repo.local=D:\SoftProject\yunhai\.m2\repository' '-Dtest=FlDefLineOfflineTest' test
|
||||
|
||||
# 修复后定向验证;exit=0
|
||||
mvn -o '-Dmaven.repo.local=D:\SoftProject\yunhai\.m2\repository' '-Dtest=FlDefLineOfflineTest,FlNewInstancePathTest,FlApplyRecoveryTest#createsWhenAbsentAndStampsIdentity,FlAdvanceIntentRecoveryTest,FlStateEventRecoveryTest,CallbackRecoveryDeliveryTest' test
|
||||
|
||||
git diff --check -- src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
|
||||
git diff --no-index --check docs/evidence/core-fix-20261027/FlDefLineOfflineTest.before.java.txt src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
|
||||
git apply --reverse --check docs/evidence/core-fix-20261027/task-only.patch
|
||||
```
|
||||
|
||||
**已核实**JDK `1.8.0_121`、Maven `3.9.16`;复用了既有私有依赖本地仓库,离线构建测试可用,没有变更依赖或全局配置。末项只检查补丁,不应用补丁;最终 exit=0,见 [patch-check.txt](evidence/core-fix-20261027/patch-check.txt)。补丁初次导出为 CRLF 导致检查失败,改为 UTF-8/LF 后通过,Java 源码不受此导出修正影响。
|
||||
|
||||
## 6. 环境阻塞与未处理事项
|
||||
|
||||
**已核实**使用 `Get-Command docker`、环境变量存在性检查、`System.Net.Sockets.TcpClient.ConnectAsync('127.0.0.1', port)` 检查本地已知测试入口;证据见 [environment.txt](evidence/core-fix-20261027/environment.txt):
|
||||
|
||||
- Docker 命令不可用,`CI_TEST_DB_URL` / `SPRING_DATASOURCE_URL` 未配置。
|
||||
- `127.0.0.1:3306` 与 `127.0.0.1:13429` 未连接成功。这仅说明所查入口不可用,不声称全机不存在其他数据库。
|
||||
- 当前 `application-test.yml` 指向本机测试库,`FlowEngineIT` 又要求端口 13429 / `fl_d09_20261026`;共享 `FlowTestSupport.cleanDb()` 含全表删除。未启动此类 IT,未尝试使用开发或生产库替代。
|
||||
|
||||
因此以下为**待核实(环境阻塞)**:停发提交后 `line_offline=1`、恢复提交后 `line_offline=0` 的数据库刷新/回读;真实仓储批量更新的全版本/学校隔离;真实事务下新实例、任务推进与回调提交链。所需条件是明确隔离、可写且专用的测试数据库及连接参数,后续测试应只创建新测试记录并限定清理范围,禁止直接运行共享清库 helper。
|
||||
|
||||
**已核实(静态检查)**仓储 `updateLineOffline` JPQL 仅更新匹配 `schId + flowGuid` 的 `lineOffline`,不更新发布状态、版本、内容或实例。数据库行为仍需上项补证。
|
||||
|
||||
未运行 C01 八项、`CallbackDispatchIT` 七项或完整 CI;本次只有一个管理守卫字符变更,定向回归已覆盖直接影响。历史任务 339、notice.id=78、旧激活意图回填、历史事件补发、重置数据、D14 均不在范围。未发现需要扩展异常语义的已证实新路径缺陷。
|
||||
|
||||
## 7. WorkBuddy 联调说明(准备完成,未执行外部写入)
|
||||
|
||||
在获准的隔离测试环境、使用新数据,按以下顺序复验:
|
||||
|
||||
1. 用本校管理员创建/发布一条测试流程线,先发起新实例并记录 defId/version、任务 ID;确认该任务在 `flw_advance_intent.activation_task_ids` 中有本次激活证明。
|
||||
2. `POST /api/flow/admin/def/offlineLine`,JSON `{"flowGuid":"实际测试GUID"}`。学校来自登录管理员上下文,不从请求体指定。期望返回受影响行数;读本校该 GUID 全版本 `flw_def.line_offline` 应全为 1。
|
||||
3. 刷新页面并用 `GET /api/flow/admin/def/versions?flowGuid=...` 回读,仍为停发;新发起和选人预览应提示停发。继续合法办理第 1 步的新实例,在途仍应按原版本推进完成。
|
||||
4. `POST /api/flow/admin/def/enableLine`,同一 JSON。期望受影响行数,刷新/数据库回读全版本 `line_offline=0`;之后新发起使用最高已发布版。
|
||||
5. 对照测试前后各版本 `status/version/content_json` 及原实例 `def_id/def_version` 应保持不变;空白、不存在 GUID 和另一学校管理员访问应被拒绝且不更新。
|
||||
|
||||
路由如有服务 context-path,再加环境配置中的前缀。观察 `flw_instance`、`flw_task`、`flw_advance_intent` 的新记录身份/状态,以及对应新 `flw_state_event`、`flw_callback_log` 的稳定 event_id 和投递状态;本次用少量定向新事件,无需重跑六类 36 项矩阵。若出现 D09 无激活证明,先核对是否确为新建实例及其 TASK_CREATED 注册,禁止对旧任务绕过 fail-closed。
|
||||
|
||||
## 8. 交付边界
|
||||
|
||||
已交付:最小生产修复、真实调用回归测试、新实例路径测试、实际红/绿日志、可复核代码差异与本回执。未执行历史回填、生产数据库操作、部署或 Git commit/push。数据库与环境联调阻塞不影响已完成代码工作;最终 FL 代码审计仍待前端交付和 WorkBuddy 集成复验后另行安排。
|
||||
@@ -0,0 +1,259 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.AssigneeResolver;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiAppClient;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 线级停发与发布封存的单测(2026-10-25 T03):
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>线级停发</b>:整线停止新发起/预览;不存在"停发后回退旧版"(停发不是降级);
|
||||
* 在途实例不经新发起解析路径,天然不受影响;恢复只归零标志,不改发布内容。</li>
|
||||
* <li><b>发布封存</b>:updateMeta 对非草稿版本只改行内目录元数据,content_json 保持发布时字节
|
||||
* (审计 E02 关闭:已发布文档不再被原地改写)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@DisplayName("线级停发/恢复 + 发布封存(T03)")
|
||||
class FlDefLineOfflineTest {
|
||||
|
||||
private static final Long SCH = 1L;
|
||||
private static final String GUID = "g-line";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
private FlwDefAdminServiceSpy adminService;
|
||||
private OapiFlowService oapiService;
|
||||
private FlwInstanceRepository instances;
|
||||
private com.yunhai.flowcenter.service.FlowInstanceService instanceService;
|
||||
|
||||
/** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */
|
||||
static class FlwDefAdminServiceSpy {
|
||||
final FlwDefRepository defRepository;
|
||||
FlwDefAdminServiceSpy(FlwDefRepository defRepository) {
|
||||
this.defRepository = defRepository;
|
||||
}
|
||||
int offlineLine(Long schId, String flowGuid) {
|
||||
if (flowGuid == null || flowGuid.trim().isEmpty()
|
||||
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
|
||||
.isPresent()) {
|
||||
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
|
||||
"流程线不存在:" + flowGuid);
|
||||
}
|
||||
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
|
||||
}
|
||||
int enableLine(Long schId, String flowGuid) {
|
||||
if (flowGuid == null || flowGuid.trim().isEmpty()
|
||||
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
|
||||
.isPresent()) {
|
||||
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
|
||||
"流程线不存在:" + flowGuid);
|
||||
}
|
||||
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
adminService = new FlwDefAdminServiceSpy(defRepository);
|
||||
instances = mock(FlwInstanceRepository.class);
|
||||
instanceService = mock(FlowInstanceService.class);
|
||||
oapiService = new OapiFlowService(defRepository, instances,
|
||||
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
|
||||
mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class),
|
||||
mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(),
|
||||
mock(OpenApiUserClient.class), mock(OpenApiAppClient.class),
|
||||
mock(DiagramTokenService.class), mock(ObjectProvider.class));
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(eq(SCH), eq(GUID)))
|
||||
.thenReturn(Optional.of(def(9, 3, 0)));
|
||||
}
|
||||
|
||||
// ==================== 线级停发/恢复(管理面委托) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("offlineLine:批量置 1;enableLine:批量归零")
|
||||
void lineOfflineDelegatesBulkUpdate() {
|
||||
when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3);
|
||||
assertThat(adminService.offlineLine(SCH, GUID)).isEqualTo(3);
|
||||
verify(defRepository).updateLineOffline(SCH, GUID, 1);
|
||||
|
||||
when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3);
|
||||
assertThat(adminService.enableLine(SCH, GUID)).isEqualTo(3);
|
||||
verify(defRepository).updateLineOffline(SCH, GUID, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不存在的流程线:拒绝且不出更新")
|
||||
void unknownLineRejected() {
|
||||
assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("流程线不存在");
|
||||
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
|
||||
}
|
||||
|
||||
// ==================== 新发起/预览的线级守卫 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("线停发:applyV2 新发起被拒且不建实例(不回退旧版)")
|
||||
void applyRejectedWhenLineOffline() {
|
||||
FlwDef offline = def(9, 3, 1);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
|
||||
.thenReturn(Optional.of(offline));
|
||||
|
||||
FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2();
|
||||
body.setFlowGuid(GUID);
|
||||
body.setBizType("svc-hall");
|
||||
body.setBizId("42");
|
||||
body.setSubmitterId(7);
|
||||
|
||||
assertThatThrownBy(() -> oapiService.applyV2(SCH, "100018", body))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("停发");
|
||||
verify(instanceService, never()).applyGuarded(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("线在线:新发起照常解析最高已发布版")
|
||||
void applyPassesWhenLineOnline() {
|
||||
FlwDef online = def(9, 3, 0);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
|
||||
.thenReturn(Optional.of(online));
|
||||
when(instanceService.applyGuarded(any(), any()))
|
||||
.thenReturn(new FlowInstanceService.GuardedOutcome(
|
||||
500L, false, FlowApplyRetryModels.Branch.CREATED, 1));
|
||||
|
||||
FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2();
|
||||
body.setFlowGuid(GUID);
|
||||
body.setBizType("svc-hall");
|
||||
body.setBizId("42");
|
||||
body.setSubmitterId(7);
|
||||
|
||||
FlowApplyRetryModels.ApplyResultV2 vo = oapiService.applyV2(SCH, "100018", body);
|
||||
assertThat(vo.getInstanceId()).isEqualTo(500L);
|
||||
ArgumentCaptor<FlowInstanceService.ApplyCommand> cmd =
|
||||
ArgumentCaptor.forClass(FlowInstanceService.ApplyCommand.class);
|
||||
verify(instanceService).applyGuarded(cmd.capture(), any());
|
||||
assertThat(cmd.getValue().def.getId()).isEqualTo(9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("预览同口径:线停发拒绝(选人预览不是新发起旁路)")
|
||||
void previewRejectedWhenLineOffline() {
|
||||
FlwDef offline = def(9, 3, 1);
|
||||
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
|
||||
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
|
||||
.thenReturn(Optional.of(offline));
|
||||
|
||||
assertThatThrownBy(() -> oapiService.applyPreview(SCH, GUID, 7))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("停发");
|
||||
}
|
||||
|
||||
// ==================== 发布封存(updateMeta) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("updateMeta 对已发布版本:行 name/appKey 可改,content_json 保持发布字节")
|
||||
void updateMetaFreezesPublishedDocument() throws Exception {
|
||||
FlowDefAdminService service = newFlowDefAdminService();
|
||||
FlwDef published = def(9, 3, FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
published.setName("旧名");
|
||||
String originalJson = published.getContentJson();
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(published));
|
||||
when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0));
|
||||
ReflectionTestUtils.setField(service, "appRepository", appRepo(true));
|
||||
|
||||
FlwDef saved = service.updateMeta(SCH, 9L, "新名", null);
|
||||
|
||||
assertThat(saved.getName()).isEqualTo("新名");
|
||||
assertThat(saved.getContentJson()).isEqualTo(originalJson);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateMeta 对草稿:content_json 顶层 name 照旧同步(草稿文档可变)")
|
||||
void updateMetaStillSyncsDraftDocument() throws Exception {
|
||||
FlowDefAdminService service = newFlowDefAdminService();
|
||||
FlwDef draft = def(9, 3, 0);
|
||||
draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
draft.setName("旧名");
|
||||
draft.setContentJson("{\"name\":\"旧名\",\"nodes\":[],\"edges\":[]}");
|
||||
when(defRepository.findById(9L)).thenReturn(Optional.of(draft));
|
||||
when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0));
|
||||
ReflectionTestUtils.setField(service, "appRepository", appRepo(true));
|
||||
|
||||
FlwDef saved = service.updateMeta(SCH, 9L, "新名", null);
|
||||
|
||||
assertThat(saved.getContentJson()).contains("\"新名\"");
|
||||
}
|
||||
|
||||
// ==================== fixtures ====================
|
||||
|
||||
private FlowDefAdminService newFlowDefAdminService() {
|
||||
// 依赖协作全部 mock(构造器形参多,反射装配与生产 Spring 无关,只测本类逻辑)
|
||||
return new FlowDefAdminService(
|
||||
defRepository,
|
||||
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
|
||||
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
|
||||
appRepo(true),
|
||||
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
|
||||
mock(FlowDeptAdminService.class),
|
||||
mock(FlowEngine.class),
|
||||
new ObjectMapper(),
|
||||
mock(FormCenterSchemaClient.class));
|
||||
}
|
||||
|
||||
private com.yunhai.flowcenter.repository.FlwAppRepository appRepo(boolean enabled) {
|
||||
com.yunhai.flowcenter.repository.FlwAppRepository repo =
|
||||
mock(com.yunhai.flowcenter.repository.FlwAppRepository.class);
|
||||
when(repo.isEnabledApp(anyLong(), anyString())).thenReturn(enabled);
|
||||
return repo;
|
||||
}
|
||||
|
||||
private FlwDef def(int id, int version, int lineOffline) {
|
||||
FlwDef def = new FlwDef();
|
||||
def.setId((long) id);
|
||||
def.setSchId(SCH);
|
||||
def.setFlowGuid(GUID);
|
||||
def.setCode("leave");
|
||||
def.setName("请假");
|
||||
def.setVersion(version);
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setLineOffline(lineOffline);
|
||||
def.setContentJson("{\"nodes\":[], \"edges\":[], \"start\":\"n1\"}");
|
||||
return def;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.DefValidator;
|
||||
import com.yunhai.flowcenter.engine.FlowDefModel;
|
||||
import com.yunhai.flowcenter.engine.VariableContract;
|
||||
import com.yunhai.flowcenter.engine.FlowEngine;
|
||||
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 流程定义管理(M1:JSON 文本编辑模式,概览 §9.2):
|
||||
* 保存=草稿直改/已发布升版本(flowGuid 跨版本不变);发布跑静态校验(把"任务无主"拦在发布期)。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowDefAdminService {
|
||||
|
||||
private final FlwDefRepository defRepository;
|
||||
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/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 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);
|
||||
}
|
||||
|
||||
/** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */
|
||||
public List<FlwDef> listAll(Long schId) {
|
||||
return defRepository.findPublishedLatestPerGuid(schId);
|
||||
}
|
||||
|
||||
public FlwDef get(Long schId, Long id) {
|
||||
FlwDef def = requireDef(schId, id);
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存(2026-10-10 起唯一键为 uk(sch,flow_guid,version),编码降级为可重名的分类标签):
|
||||
* - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1)
|
||||
* - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本"
|
||||
* - 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, 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()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "code/name 必填");
|
||||
}
|
||||
FlwDef def;
|
||||
if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) {
|
||||
// 在既有流程线上开新版本草稿(appKey 缺省继承源线)
|
||||
FlwDef base = defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim())
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "baseGuid 流程线不存在"));
|
||||
def = new FlwDef();
|
||||
def.setSchId(schId);
|
||||
def.setFlowGuid(baseGuid.trim());
|
||||
def.setCode(code.trim());
|
||||
def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1);
|
||||
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey());
|
||||
def.setFormUuid(base.getFormUuid());
|
||||
def.setFormVersion(base.getFormVersion());
|
||||
} else if (id == null) {
|
||||
// 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用)
|
||||
def = new FlwDef();
|
||||
def.setSchId(schId);
|
||||
def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase());
|
||||
def.setCode(code.trim());
|
||||
def.setVersion(1);
|
||||
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
def.setAppKey(cleanAppKey);
|
||||
} 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();
|
||||
next.setSchId(schId);
|
||||
next.setFlowGuid(def.getFlowGuid());
|
||||
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());
|
||||
// 归属应用必填(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());
|
||||
}
|
||||
if (parsed.name != null && !parsed.name.trim().isEmpty()) {
|
||||
def.setName(parsed.name.trim());
|
||||
}
|
||||
try {
|
||||
return defRepository.save(def);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// 撞唯一键 (sch,flow_guid,version):并发升版本同一线时可能撞号,转可理解提示(原样抛出=笼统"内部服务错误")
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存重载(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 零依赖;新发布的无绑定版本必须显式声明 variables。 */
|
||||
@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());
|
||||
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
|
||||
Map<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
|
||||
def.getFormVersion(), "发布");
|
||||
model.variables = VariableContract.fromSnapshot(snapshot);
|
||||
VariableContract.validateDefinition(model, true);
|
||||
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
|
||||
def.setContentJson(embedFormContract(def.getContentJson(), snapshot, model.variables));
|
||||
} else if (def.getFormVersion() != null) {
|
||||
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
|
||||
}
|
||||
if (def.getFormUuid() == null || def.getFormUuid().trim().isEmpty()) {
|
||||
VariableContract.validateDefinition(model, true);
|
||||
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
|
||||
}
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setPublishTime(LocalDateTime.now());
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
|
||||
private String embedFormContract(String contentJson, Map<String, Object> snapshot,
|
||||
List<FlowDefModel.Variable> variables) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
|
||||
objectMapper.readTree(contentJson);
|
||||
root.set("formContract", objectMapper.valueToTree(snapshot));
|
||||
root.set("variables", objectMapper.valueToTree(variables));
|
||||
return objectMapper.writeValueAsString(root);
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "快照嵌入失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 停用:不可新发起,存量跑完 */
|
||||
@Transactional
|
||||
public void offline(Long schId, Long id) {
|
||||
FlwDef def = requireDef(schId, id);
|
||||
if (def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "草稿不能停用后启用,请先通过发布校验");
|
||||
}
|
||||
def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||
defRepository.save(def);
|
||||
}
|
||||
|
||||
/**
|
||||
* 线级停发(2026-10-25 T03):整条 flowGuid 停止新发起/预览,在途实例按各自固定 defId 原版跑完。
|
||||
* 与版本级 {@link #offline} 正交(可叠加);批量 UPDATE 全版本行,读任一行即读线状态。
|
||||
* 不改写任何发布内容字节,恢复走 {@link #enableLine}。
|
||||
*/
|
||||
@Transactional
|
||||
public int offlineLine(Long schId, String flowGuid) {
|
||||
requireLine(schId, flowGuid);
|
||||
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 线级恢复(T03):只恢复线的可用性——line_offline 归零;各版本 status/发布内容/版本指针
|
||||
* 一律不动(停发期间发布新版不受影响,恢复后新发起即用当时最高已发布版)。
|
||||
*/
|
||||
@Transactional
|
||||
public int enableLine(Long schId, String flowGuid) {
|
||||
requireLine(schId, flowGuid);
|
||||
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
|
||||
}
|
||||
|
||||
private void requireLine(Long schId, String flowGuid) {
|
||||
if (flowGuid == null || flowGuid.trim().isEmpty()
|
||||
|| defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
|
||||
.isPresent()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用(2026-09-18):只对"停用"版恢复为已发布,与 offline 对称。
|
||||
* 复用发布期的静态校验(把"任务无主"重新拦一次)——若建的时候某科室还有启用办理人、之后
|
||||
* 被停用,这里会点名报错(如"科室 X 无启用办理人"),管理员在办理人映射页点"启用"即可解。
|
||||
*/
|
||||
@Transactional
|
||||
public FlwDef enable(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.DISABLED.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()) {
|
||||
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);
|
||||
// 发布封存(2026-10-25 T03):非草稿版本的执行文档不可变——行 name 照改(目录元数据),
|
||||
// content_json 顶层 name 保持发布时字节,消除"已发布文档被原地改写"的旁路
|
||||
//(审计 E02:updateMeta 曾同步改已发布 contentJson 顶层 name)。
|
||||
if (def.getStatus() != null && def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
|
||||
def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName));
|
||||
}
|
||||
try {
|
||||
return defRepository.save(def);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
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) {
|
||||
// 2026-10-10 科室管理切源:发布校验白名单改查 flw_dept/flw_dept_member(拍板 D1)
|
||||
return deptAdminService.enabledDeptKeys(schId);
|
||||
}
|
||||
|
||||
/** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */
|
||||
private Set<String> enabledRouterKeys() {
|
||||
return routerRepository.findByStatusOrderByIdAsc(1).stream()
|
||||
.map(com.yunhai.flowcenter.entity.FlwRouter::getRouterKey)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private FlwDef requireDef(Long schId, Long id) {
|
||||
FlwDef def = defRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义不存在:" + id));
|
||||
if (!def.getSchId().equals(schId)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "流程定义不属于该学校");
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
}
|
||||
+74
File diff suppressed because one or more lines are too long
+72
File diff suppressed because one or more lines are too long
+76
File diff suppressed because one or more lines are too long
+66
File diff suppressed because one or more lines are too long
+79
File diff suppressed because one or more lines are too long
+66
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
|
||||
M .gitea/workflows/build.yml
|
||||
M pom.xml
|
||||
M src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java
|
||||
M src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java
|
||||
M src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java
|
||||
M src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
|
||||
M src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java
|
||||
M src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/DefValidator.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
|
||||
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
|
||||
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
|
||||
M src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
|
||||
M src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
|
||||
M src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
|
||||
M src/main/java/com/yunhai/flowcenter/model/OapiModels.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
|
||||
M src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java
|
||||
M src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
|
||||
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
|
||||
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java
|
||||
M src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java
|
||||
M src/test/resources/application-test.yml
|
||||
?? ci/
|
||||
?? docs/form-binding-contract.md
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/CallbackFactStore.java
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/FlowEventIdentityListener.java
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/FlwStateEventRecorder.java
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/FormalCoordinateReleaseListener.java
|
||||
?? src/main/java/com/yunhai/flowcenter/config/FlowSourceScopeConfiguration.java
|
||||
?? src/main/java/com/yunhai/flowcenter/controller/oapi/FormalApplyCommandController.java
|
||||
?? src/main/java/com/yunhai/flowcenter/engine/VariableContract.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwAdvanceIntent.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwApplyCommand.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwBizCoordinate.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwCommandSource.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwStateEvent.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwUsageGrant.java
|
||||
?? src/main/java/com/yunhai/flowcenter/enums/FormBindingErrorCode.java
|
||||
?? src/main/java/com/yunhai/flowcenter/model/FormalApplyModels.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwAdvanceIntentRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwApplyCommandRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwBizCoordinateRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwCommandSourceRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwStateEventRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/ApplyVariableValidator.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowActorContextGate.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizer.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java
|
||||
?? src/main/resources/sql/20261025_add_flw_def_line_offline.sql
|
||||
?? src/main/resources/sql/20261025_add_flw_instance_payload_hash.sql
|
||||
?? src/main/resources/sql/20261025_create_flw_state_event_and_intent.sql
|
||||
?? src/main/resources/sql/20261025_create_flw_usage_grant.sql
|
||||
?? src/main/resources/sql/20261025_drop_event_seq_uk.sql
|
||||
?? src/main/resources/sql/20261025_fix_flw_callback_log_uk.sql
|
||||
?? src/main/resources/sql/20261026_d09_activation_intent_draft.sql
|
||||
?? src/main/resources/sql/20261026_formal_commands_draft.sql
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/CallbackConcurrentPushIT.java
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/CallbackFactDispatchIT.java
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/CallbackRecoveryDeliveryTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/FlStateEventRecoveryTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/engine/AssigneeResolverEligibilityTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/engine/FlAdvanceIntentRecoveryTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FlActorContextGateTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FormalFlowHttpFixtureMain.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizerTest.java
|
||||
File diff suppressed because it is too large
Load Diff
+4
@@ -0,0 +1,4 @@
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.69 s - in com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.yunhai.flowcenter.callback.FlStateEventRecoveryTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s - in com.yunhai.flowcenter.callback.FlStateEventRecoveryTest
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.471 s - in com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest
|
||||
@@ -0,0 +1,4 @@
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.yunhai.flowcenter.service.FlApplyRecoveryTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.093 s - in com.yunhai.flowcenter.service.FlApplyRecoveryTest
|
||||
@@ -0,0 +1,4 @@
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.yunhai.flowcenter.service.FlDefLineOfflineTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 14, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.394 s - in com.yunhai.flowcenter.service.FlDefLineOfflineTest
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.yunhai.flowcenter.service.FlNewInstancePathTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.078 s - in com.yunhai.flowcenter.service.FlNewInstancePathTest
|
||||
@@ -0,0 +1,6 @@
|
||||
Checked: 2026-10-27 Asia/Shanghai
|
||||
Docker command available: False
|
||||
CI_TEST_DB_URL configured: False
|
||||
SPRING_DATASOURCE_URL configured: False
|
||||
127.0.0.1:3306 OPEN=False
|
||||
127.0.0.1:13429 OPEN=False
|
||||
@@ -0,0 +1,3 @@
|
||||
4CD39FBEC874DB0A2B5EF20CA5F6A713ADF7E8D9E3B834DF4ECCDFEE51435E04 D:\SoftProject\yunhai\flow-center\service\src\main\java\com\yunhai\flowcenter\service\FlowDefAdminService.java
|
||||
DB6BEBF60CD77FEF1C696527C23B65170E8EAFC108D980352361BA01EB1A0EB0 D:\SoftProject\yunhai\flow-center\service\src\test\java\com\yunhai\flowcenter\service\FlDefLineOfflineTest.java
|
||||
E925A3D742801E7666FAC9C4ED1E628F8C5C91C4E9B5ED5A6B2755F1567F29DE D:\SoftProject\yunhai\flow-center\service\src\test\java\com\yunhai\flowcenter\service\FlNewInstancePathTest.java
|
||||
@@ -0,0 +1,107 @@
|
||||
M .gitea/workflows/build.yml
|
||||
M pom.xml
|
||||
M src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java
|
||||
M src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java
|
||||
M src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java
|
||||
M src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
|
||||
M src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java
|
||||
M src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/DefValidator.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
|
||||
M src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
|
||||
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
|
||||
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
|
||||
M src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
|
||||
M src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
|
||||
M src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
|
||||
M src/main/java/com/yunhai/flowcenter/model/OapiModels.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
|
||||
M src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
|
||||
M src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java
|
||||
M src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
|
||||
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
|
||||
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java
|
||||
M src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java
|
||||
M src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java
|
||||
M src/test/resources/application-test.yml
|
||||
?? ci/
|
||||
?? "docs/FL\345\220\216\347\253\257\346\240\270\345\277\203\344\277\256\345\244\215\346\211\247\350\241\214\345\233\236\346\211\247-Codex.md"
|
||||
?? docs/evidence/
|
||||
?? docs/form-binding-contract.md
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/CallbackFactStore.java
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/FlowEventIdentityListener.java
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/FlwStateEventRecorder.java
|
||||
?? src/main/java/com/yunhai/flowcenter/callback/FormalCoordinateReleaseListener.java
|
||||
?? src/main/java/com/yunhai/flowcenter/config/FlowSourceScopeConfiguration.java
|
||||
?? src/main/java/com/yunhai/flowcenter/controller/oapi/FormalApplyCommandController.java
|
||||
?? src/main/java/com/yunhai/flowcenter/engine/VariableContract.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwAdvanceIntent.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwApplyCommand.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwBizCoordinate.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwCommandSource.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwStateEvent.java
|
||||
?? src/main/java/com/yunhai/flowcenter/entity/FlwUsageGrant.java
|
||||
?? src/main/java/com/yunhai/flowcenter/enums/FormBindingErrorCode.java
|
||||
?? src/main/java/com/yunhai/flowcenter/model/FormalApplyModels.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwAdvanceIntentRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwApplyCommandRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwBizCoordinateRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwCommandSourceRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwStateEventRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/ApplyVariableValidator.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowActorContextGate.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizer.java
|
||||
?? src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java
|
||||
?? src/main/resources/sql/20261025_add_flw_def_line_offline.sql
|
||||
?? src/main/resources/sql/20261025_add_flw_instance_payload_hash.sql
|
||||
?? src/main/resources/sql/20261025_create_flw_state_event_and_intent.sql
|
||||
?? src/main/resources/sql/20261025_create_flw_usage_grant.sql
|
||||
?? src/main/resources/sql/20261025_drop_event_seq_uk.sql
|
||||
?? src/main/resources/sql/20261025_fix_flw_callback_log_uk.sql
|
||||
?? src/main/resources/sql/20261026_d09_activation_intent_draft.sql
|
||||
?? src/main/resources/sql/20261026_formal_commands_draft.sql
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/CallbackConcurrentPushIT.java
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/CallbackFactDispatchIT.java
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/CallbackRecoveryDeliveryTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/callback/FlStateEventRecoveryTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/engine/AssigneeResolverEligibilityTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/engine/FlAdvanceIntentRecoveryTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FlActorContextGateTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FormalFlowHttpFixtureMain.java
|
||||
?? src/test/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizerTest.java
|
||||
@@ -0,0 +1 @@
|
||||
git apply --reverse --check task-only.patch exit=0
|
||||
@@ -0,0 +1,281 @@
|
||||
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
|
||||
index da109e3..3837c1a 100644
|
||||
--- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
|
||||
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
|
||||
@@ -299,7 +299,7 @@ public class FlowDefAdminService {
|
||||
|
||||
private void requireLine(Long schId, String flowGuid) {
|
||||
if (flowGuid == null || flowGuid.trim().isEmpty()
|
||||
- || defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
|
||||
+ || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
|
||||
.isPresent()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
|
||||
}
|
||||
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
|
||||
index 76bbf34..3cff9e9 100644
|
||||
--- a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
|
||||
+++ b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
|
||||
@@ -18,6 +18,9 @@ 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.junit.jupiter.params.ParameterizedTest;
|
||||
+import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
+import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -33,7 +36,9 @@ 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.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
@@ -53,43 +58,17 @@ class FlDefLineOfflineTest {
|
||||
private static final String GUID = "g-line";
|
||||
|
||||
private FlwDefRepository defRepository;
|
||||
- private FlwDefAdminServiceSpy adminService;
|
||||
+ private FlowDefAdminService adminService;
|
||||
private OapiFlowService oapiService;
|
||||
private FlwInstanceRepository instances;
|
||||
private com.yunhai.flowcenter.service.FlowInstanceService instanceService;
|
||||
|
||||
- /** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */
|
||||
- static class FlwDefAdminServiceSpy {
|
||||
- final FlwDefRepository defRepository;
|
||||
- FlwDefAdminServiceSpy(FlwDefRepository defRepository) {
|
||||
- this.defRepository = defRepository;
|
||||
- }
|
||||
- int offlineLine(Long schId, String flowGuid) {
|
||||
- if (flowGuid == null || flowGuid.trim().isEmpty()
|
||||
- || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
|
||||
- .isPresent()) {
|
||||
- throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
|
||||
- "流程线不存在:" + flowGuid);
|
||||
- }
|
||||
- return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
|
||||
- }
|
||||
- int enableLine(Long schId, String flowGuid) {
|
||||
- if (flowGuid == null || flowGuid.trim().isEmpty()
|
||||
- || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
|
||||
- .isPresent()) {
|
||||
- throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
|
||||
- "流程线不存在:" + flowGuid);
|
||||
- }
|
||||
- return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
|
||||
- }
|
||||
- }
|
||||
-
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
defRepository = mock(FlwDefRepository.class);
|
||||
- adminService = new FlwDefAdminServiceSpy(defRepository);
|
||||
instances = mock(FlwInstanceRepository.class);
|
||||
+ adminService = newFlowDefAdminService();
|
||||
instanceService = mock(FlowInstanceService.class);
|
||||
oapiService = new OapiFlowService(defRepository, instances,
|
||||
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
|
||||
@@ -121,9 +100,61 @@ class FlDefLineOfflineTest {
|
||||
assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("流程线不存在");
|
||||
+ assertThatThrownBy(() -> adminService.enableLine(SCH, "g-nope"))
|
||||
+ .isInstanceOf(BizException.class)
|
||||
+ .hasMessageContaining("流程线不存在");
|
||||
+ verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
|
||||
+ }
|
||||
+
|
||||
+ @ParameterizedTest
|
||||
+ @NullAndEmptySource
|
||||
+ @ValueSource(strings = {" ", "\t\r\n"})
|
||||
+ void blankGuidRejectedOnBothPaths(String guid) {
|
||||
+ assertThatThrownBy(() -> adminService.offlineLine(SCH, guid))
|
||||
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
|
||||
+ assertThatThrownBy(() -> adminService.enableLine(SCH, guid))
|
||||
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
|
||||
+ verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
|
||||
+ verify(defRepository, never()).findFirstBySchIdAndFlowGuidOrderByVersionDesc(any(), any());
|
||||
+ }
|
||||
+
|
||||
+ @Test
|
||||
+ void otherSchoolCannotChangeExistingLine() {
|
||||
+ assertThatThrownBy(() -> adminService.offlineLine(2L, GUID))
|
||||
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
|
||||
+ assertThatThrownBy(() -> adminService.enableLine(2L, GUID))
|
||||
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
|
||||
+ verify(defRepository, times(2)).findFirstBySchIdAndFlowGuidOrderByVersionDesc(2L, GUID);
|
||||
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
|
||||
}
|
||||
|
||||
+ @Test
|
||||
+ void guidTrimmedForBothLookupAndUpdate() {
|
||||
+ when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3);
|
||||
+ when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3);
|
||||
+ assertThat(adminService.offlineLine(SCH, " " + GUID + "\t")).isEqualTo(3);
|
||||
+ assertThat(adminService.enableLine(SCH, "\t" + GUID + " ")).isEqualTo(3);
|
||||
+ verify(defRepository).updateLineOffline(SCH, GUID, 1);
|
||||
+ verify(defRepository).updateLineOffline(SCH, GUID, 0);
|
||||
+ }
|
||||
+
|
||||
+ @Test
|
||||
+ void lineToggleDoesNotChangePublishedDefinitionOrInflightInstances() {
|
||||
+ FlwDef published = def(9, 3, 0);
|
||||
+ String frozenJson = published.getContentJson();
|
||||
+ when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, GUID))
|
||||
+ .thenReturn(Optional.of(published));
|
||||
+
|
||||
+ adminService.offlineLine(SCH, GUID);
|
||||
+ adminService.enableLine(SCH, GUID);
|
||||
+
|
||||
+ assertThat(published.getVersion()).isEqualTo(3);
|
||||
+ assertThat(published.getStatus()).isEqualTo(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
+ assertThat(published.getContentJson()).isEqualTo(frozenJson);
|
||||
+ verify(defRepository, never()).save(any());
|
||||
+ verifyNoInteractions(instances, instanceService);
|
||||
+ }
|
||||
+
|
||||
// ==================== 新发起/预览的线级守卫 ====================
|
||||
|
||||
@Test
|
||||
@@ -229,7 +260,7 @@ class FlDefLineOfflineTest {
|
||||
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
|
||||
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
|
||||
appRepo(true),
|
||||
- mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
|
||||
+ instances,
|
||||
mock(FlowDeptAdminService.class),
|
||||
mock(FlowEngine.class),
|
||||
new ObjectMapper(),
|
||||
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
|
||||
@@ -0,0 +1,122 @@
|
||||
+package com.yunhai.flowcenter.service;
|
||||
+
|
||||
+import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
+import com.yunhai.flowcenter.engine.*;
|
||||
+import com.yunhai.flowcenter.entity.*;
|
||||
+import com.yunhai.flowcenter.repository.*;
|
||||
+import org.junit.jupiter.api.Test;
|
||||
+import org.springframework.context.ApplicationEventPublisher;
|
||||
+import org.springframework.test.util.ReflectionTestUtils;
|
||||
+import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
+
|
||||
+import javax.persistence.EntityManager;
|
||||
+import java.util.*;
|
||||
+import java.util.concurrent.atomic.AtomicReference;
|
||||
+
|
||||
+import static org.assertj.core.api.Assertions.assertThat;
|
||||
+import static org.mockito.ArgumentMatchers.*;
|
||||
+import static org.mockito.Mockito.*;
|
||||
+
|
||||
+/** Real service/engine/activation code with repository mocks. Does not prove DB or transaction behavior. */
|
||||
+class FlNewInstancePathTest {
|
||||
+ @Test
|
||||
+ void freshInstanceCreatesProvenTaskAndApprovesWhileLineIsOffline() {
|
||||
+ ObjectMapper mapper = new ObjectMapper();
|
||||
+ FlwDefRepository defs = mock(FlwDefRepository.class);
|
||||
+ FlwInstanceRepository instances = mock(FlwInstanceRepository.class);
|
||||
+ FlwTaskRepository tasks = mock(FlwTaskRepository.class);
|
||||
+ FlwBranchRepository branches = mock(FlwBranchRepository.class);
|
||||
+ FlwAdvanceIntentRepository intents = mock(FlwAdvanceIntentRepository.class);
|
||||
+ AssigneeResolver assignees = mock(AssigneeResolver.class);
|
||||
+ ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
|
||||
+ List<FlowEvent> events = new ArrayList<>();
|
||||
+ doAnswer(call -> { events.add(call.getArgument(0)); return null; })
|
||||
+ .when(publisher).publishEvent(any(FlowEvent.class));
|
||||
+ FlowEngine engine = new FlowEngine(mapper, tasks, branches, mock(FlwCcRepository.class),
|
||||
+ assignees, mock(ConditionRouteService.class), publisher);
|
||||
+ FlowInstanceService apply = new FlowInstanceService(instances, defs, tasks, branches, engine, publisher);
|
||||
+ FlowTaskService handle = new FlowTaskService(mapper, tasks, instances, branches, defs,
|
||||
+ assignees, engine, publisher);
|
||||
+ FlowAdvanceService advance = new FlowAdvanceService(instances, defs, tasks, engine, intents);
|
||||
+ ReflectionTestUtils.setField(advance, "entityManager", mock(EntityManager.class));
|
||||
+ FlowNodeAdvanceListener listener = new FlowNodeAdvanceListener(advance, intents);
|
||||
+
|
||||
+ FlwDef def = new FlwDef();
|
||||
+ def.setId(7L); def.setSchId(1L); def.setFlowGuid("fresh-line");
|
||||
+ def.setCode("fresh"); def.setVersion(1); def.setStatus(1); def.setLineOffline(0);
|
||||
+ def.setContentJson("{\"start\":\"start\",\"nodes\":["
|
||||
+ + "{\"id\":\"start\",\"type\":\"START\"},"
|
||||
+ + "{\"id\":\"review\",\"type\":\"APPROVAL\",\"name\":\"Review\",\"countersign\":\"ALL\"},"
|
||||
+ + "{\"id\":\"end\",\"type\":\"END\"}],"
|
||||
+ + "\"edges\":[{\"from\":\"start\",\"to\":\"review\"},{\"from\":\"review\",\"to\":\"end\"}]}");
|
||||
+ when(defs.findById(7L)).thenReturn(Optional.of(def));
|
||||
+ when(assignees.resolve(eq(1L), any())).thenReturn(Collections.singletonList(new AssigneeRef(201, "Reviewer")));
|
||||
+ AtomicReference<FlwInstance> created = new AtomicReference<>();
|
||||
+ when(instances.saveAndFlush(any())).thenAnswer(call -> {
|
||||
+ FlwInstance instance = call.getArgument(0);
|
||||
+ if (instance.getId() == null) instance.setId(900L);
|
||||
+ created.set(instance); return instance;
|
||||
+ });
|
||||
+ when(instances.findById(900L)).thenAnswer(call -> Optional.ofNullable(created.get()));
|
||||
+ AtomicReference<FlwTask> task = new AtomicReference<>();
|
||||
+ when(tasks.save(any())).thenAnswer(call -> {
|
||||
+ FlwTask row = call.getArgument(0); row.setId(33L); task.set(row); return row;
|
||||
+ });
|
||||
+ when(tasks.findById(33L)).thenAnswer(call -> Optional.ofNullable(task.get()));
|
||||
+ when(tasks.findByInstanceIdAndStatus(900L, 0)).thenAnswer(call ->
|
||||
+ task.get().getStatus() == 0 ? Collections.singletonList(task.get()) : Collections.emptyList());
|
||||
+ when(tasks.claimAndFinish(eq(33L), eq(1), any(), any(), any())).thenAnswer(call -> {
|
||||
+ task.get().setStatus(1); task.get().setEdgeName(call.getArgument(4)); return 1;
|
||||
+ });
|
||||
+ AtomicReference<FlwAdvanceIntent> activation = new AtomicReference<>();
|
||||
+ when(intents.save(any())).thenAnswer(call -> {
|
||||
+ FlwAdvanceIntent row = call.getArgument(0); row.setId(80L); activation.set(row); return row;
|
||||
+ });
|
||||
+ when(intents.findByIdForUpdate(80L)).thenAnswer(call -> Optional.of(activation.get()));
|
||||
+ when(intents.findByInstanceIdAndNodeIdOrderByIdDesc(900L, "review"))
|
||||
+ .thenAnswer(call -> Collections.singletonList(activation.get()));
|
||||
+ when(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(900L, "review"))
|
||||
+ .thenAnswer(call -> Optional.of(activation.get()));
|
||||
+
|
||||
+ FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(
|
||||
+ 1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant",
|
||||
+ null, "100001", "notice", null);
|
||||
+ FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command,
|
||||
+ new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false));
|
||||
+ assertThat(outcome.duplicated).isFalse();
|
||||
+ assertThat(created.get().getCurrentNodes()).isEqualTo("review");
|
||||
+ assertThat(task.get().getStatus()).isZero();
|
||||
+ assertThat(task.get().getAssigneeId()).isEqualTo(201);
|
||||
+ FlowEvent taskCreated = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_CREATED)
|
||||
+ .findFirst().orElseThrow(AssertionError::new);
|
||||
+ // Explicit listener harness: test synchronizations only, no real transaction/AFTER_COMMIT claim.
|
||||
+ TransactionSynchronizationManager.initSynchronization();
|
||||
+ try {
|
||||
+ listener.persistIntent(taskCreated);
|
||||
+ TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.beforeCommit(false));
|
||||
+ } finally {
|
||||
+ TransactionSynchronizationManager.clearSynchronization();
|
||||
+ }
|
||||
+ assertThat(activation.get().containsTask(task.get().getId())).isTrue();
|
||||
+ assertThat(activation.get().getActivationDefId()).isEqualTo(7L);
|
||||
+ assertThat(activation.get().getActivationApplyRound()).isEqualTo(1);
|
||||
+
|
||||
+ // Existing tasks resolve their pinned definition even when the line is stopped for new applications.
|
||||
+ def.setLineOffline(1);
|
||||
+ assertThat(handle.handle(1L, 33L, true, "Approved", 201).getStatus()).isEqualTo(1);
|
||||
+ FlowEvent handled = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_HANDLED)
|
||||
+ .findFirst().orElseThrow(AssertionError::new);
|
||||
+ listener.persistIntent(handled);
|
||||
+ listener.onTaskHandled(handled);
|
||||
+ assertThat(activation.get().getStatus()).isEqualTo("DONE");
|
||||
+ assertThat(activation.get().getLastError()).isNull();
|
||||
+ assertThat(created.get().getStatus()).isEqualTo(20);
|
||||
+ assertThat(created.get().getCurrentNodes()).isNullOrEmpty();
|
||||
+ assertThat(created.get().getDefId()).isEqualTo(7L);
|
||||
+ assertThat(created.get().getDefVersion()).isEqualTo(1);
|
||||
+ assertThat(events).extracting(FlowEvent::getType)
|
||||
+ .contains(FlowEvent.Type.SUBMITTED, FlowEvent.Type.TASK_CREATED,
|
||||
+ FlowEvent.Type.TASK_HANDLED, FlowEvent.Type.COMPLETED);
|
||||
+ verify(defs, never()).findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any());
|
||||
+ }
|
||||
+}
|
||||
@@ -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,24 @@
|
||||
<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,应用起不来 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<!-- CAS 客户端(UserHome/Cms/svc-hall 同款显式声明) -->
|
||||
<dependency>
|
||||
<groupId>org.jasig.cas.client</groupId>
|
||||
@@ -63,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>
|
||||
@@ -74,6 +100,14 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- R2(Codex D04):正常/恢复投递信封与真实接收端 FlowCallbackReceiver 的契约测试用。
|
||||
仅 test 域——生产件不引入(FL 是回调生产方不是消费方);测试上下文显式关装配
|
||||
(application-test.yml yh.flow-callback.enabled=false),接收端由测试手工构造。 -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-flow-callback-spring-boot-starter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
@@ -28,10 +29,22 @@ import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 回调分发器(概览 §8.4 可靠性设计):引擎 AFTER_COMMIT 事件 → 匹配 flw_callback_reg
|
||||
* (flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
|
||||
* 回调分发器(概览 §8.4 可靠性设计;2026-10-25 R2 返修):引擎 AFTER_COMMIT 事件 →
|
||||
* 匹配 flw_callback_reg(flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
|
||||
* 落库成功即视为主业务完成,推送失败不影响审批(异步解耦是特性)。
|
||||
* @Scheduled(fixedDelay=30s) 扫 (status=0, next_retry_time<=now) 重推;死信留管理端手工重推。
|
||||
*
|
||||
* <p>R2(Codex D04)投递可靠性口径:
|
||||
* <ul>
|
||||
* <li><b>统一冻结信封</b>——事实行在状态事务内冻结完整投递体(含 eventId/eventSeq/flowGuid);
|
||||
* 正常分发优先复用冻结体,崩溃恢复分发原样复用同一冻结体,两条路径消息体同构;恢复体
|
||||
* 对 R2 前存量事实行按列值幂等补齐 eventId/eventSeq/flowGuid 后投出。</li>
|
||||
* <li><b>未可靠建行不记账</b>——任一匹配订阅方的投递行建行失败(含持久化异常)时,事实行
|
||||
* 不标记 dispatched:正常链不标记(恢复扫描补建),恢复链保持 dispatched=0 等下轮重扫;
|
||||
* 异步首推的提交失败不回退建行结果(行已在,重试扫描接手)。</li>
|
||||
* <li><b>并发幂等靠数据库唯一键</b>——(event_id, reg_id) 复合唯一约束兜底 exists→insert 竞态,
|
||||
* DuplicateKeyException 一律视为"已建行"幂等成功。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -48,6 +61,17 @@ public class CallbackDispatcher {
|
||||
private final CallbackPushService pushService;
|
||||
private final CallbackProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final com.yunhai.flowcenter.repository.FlwStateEventRepository stateEventRepository;
|
||||
private final CallbackFactStore factStore;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private CallbackSourcePolicy sourcePolicy;
|
||||
|
||||
private boolean self(FlwCallbackReg reg, FlwInstance instance) {
|
||||
if (instance.getSourcePrincipalId() == null) return true; // legacy cutover is a separate migration
|
||||
if (sourcePolicy == null) throw new IllegalStateException("Missing formal callback source policy");
|
||||
return sourcePolicy.matches(reg, instance);
|
||||
}
|
||||
|
||||
private volatile ExecutorService pushExecutor;
|
||||
|
||||
@@ -70,14 +94,129 @@ public class CallbackDispatcher {
|
||||
}
|
||||
try {
|
||||
dispatch(event);
|
||||
markFactDispatched(event);
|
||||
} catch (Exception e) {
|
||||
// 分发失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程
|
||||
log.error("[flow-center] 回调分发异常:instance={} event={} {}",
|
||||
// 分发/建行失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程;
|
||||
// R2:不标记 dispatched——恢复扫描稍后按事实行补建缺失的投递行
|
||||
log.error("[flow-center] 回调分发异常(事实保持待补分发):instance={} event={} {}",
|
||||
event.getInstanceId(), event.getType().getCode(), e.toString(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(FlowEvent event) {
|
||||
/** T05:事实行分发完成记账(崩溃窗口恢复扫描据此区分"已分发"与"待补分发") */
|
||||
private void markFactDispatched(FlowEvent event) {
|
||||
if (event.getStableEventId() == null) {
|
||||
return; // 无事实行(存量发布路径/记录器缺席),维持旧行为
|
||||
}
|
||||
try {
|
||||
// Cross the separate bean's proxy: the completed business transaction cannot commit this write.
|
||||
factStore.markDispatched(event.getStableEventId());
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 事件事实分发记账失败(重扫会幂等补):eventId={} {}",
|
||||
event.getStableEventId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T05 崩溃窗口恢复扫描:状态事务已提交但分发未及执行(进程被杀/分发异常)的事实行,
|
||||
* 过缓冲期后按存量事实补分发——幂等(同 eventId 已建投递行则跳过),不重放旧轮身份
|
||||
* (载荷即事实快照)。R2:任一订阅方建行失败则保持 dispatched=0,下轮重扫补齐。
|
||||
*/
|
||||
@Scheduled(fixedDelay = 60_000)
|
||||
public void sweepUndispatchedFacts() {
|
||||
try {
|
||||
java.util.List<com.yunhai.flowcenter.entity.FlwStateEvent> due =
|
||||
stateEventRepository.findTop50ByDispatchedAndCreateTimeLessThanEqualOrderByCreateTimeAsc(
|
||||
0, java.time.LocalDateTime.now().minusSeconds(60));
|
||||
for (com.yunhai.flowcenter.entity.FlwStateEvent fact : due) {
|
||||
executor().submit(() -> dispatchFact(fact));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 事件事实补分发扫描异常:{}", e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void dispatchFact(com.yunhai.flowcenter.entity.FlwStateEvent fact) {
|
||||
try {
|
||||
FlwInstance instance = instanceRepository.findById(fact.getInstanceId()).orElse(null);
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
if (def == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> stored = objectMapper.readValue(fact.getPayloadJson(), Map.class);
|
||||
java.util.List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
|
||||
instance.getSchId(), def.getFlowGuid(), 0);
|
||||
boolean allDelivered = true;
|
||||
for (FlwCallbackReg reg : regs) {
|
||||
if (!self(reg, instance)) continue;
|
||||
if (!subscribed(reg.getEventTypes(), fact.getEventType())) {
|
||||
continue;
|
||||
}
|
||||
if (logRepository.existsByEventIdAndRegId(fact.getEventId(), reg.getId())) {
|
||||
continue; // 幂等:崩溃前已建的投递行不重建
|
||||
}
|
||||
try {
|
||||
persistFactDelivery(reg, instance, def, fact, stored);
|
||||
} catch (DuplicateKeyException dup) {
|
||||
// uk(event_id, reg_id) 并发兜底:另一路径已建行,视为已投递
|
||||
} catch (Exception e) {
|
||||
allDelivered = false;
|
||||
log.error("[flow-center] 事件事实补分发建行失败(保留待重扫):eventId={} regId={} {}",
|
||||
fact.getEventId(), reg.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
if (!allDelivered) {
|
||||
return; // R2:未全部可靠建行,不标记 dispatched
|
||||
}
|
||||
fact.setDispatched(1);
|
||||
stateEventRepository.save(fact);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 事件事实补分发失败(下轮重扫):eventId={} {}", fact.getEventId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 补分发建行:载荷以事实冻结体为准(R2 冻结信封原样复用,与正常链同构);
|
||||
* R2 前存量事实行缺 eventId/eventSeq/flowGuid 时按列值幂等补齐。异步首推同正常链。
|
||||
*/
|
||||
private void persistFactDelivery(FlwCallbackReg reg, FlwInstance instance, FlwDef def,
|
||||
com.yunhai.flowcenter.entity.FlwStateEvent fact,
|
||||
Map<String, Object> stored) throws Exception {
|
||||
String payloadJson;
|
||||
if (stored.containsKey("eventId") && stored.containsKey("flowGuid")) {
|
||||
// R2 冻结体:完整信封已含全部投递字段,原样复用(不可变)
|
||||
payloadJson = fact.getPayloadJson();
|
||||
} else {
|
||||
// R2 前存量事实行:信封字段缺失,按事实列值+现查 flowGuid 幂等修复
|
||||
Map<String, Object> repaired = new HashMap<>(stored);
|
||||
repaired.put("eventId", fact.getEventId());
|
||||
if (fact.getEventSeq() != null) {
|
||||
repaired.put("eventSeq", fact.getEventSeq());
|
||||
}
|
||||
repaired.put("flowGuid", def.getFlowGuid());
|
||||
payloadJson = objectMapper.writeValueAsString(repaired);
|
||||
}
|
||||
FlwCallbackLog callbackLog = new FlwCallbackLog();
|
||||
callbackLog.setSchId(instance.getSchId());
|
||||
callbackLog.setRegId(reg.getId());
|
||||
callbackLog.setEventId(fact.getEventId());
|
||||
callbackLog.setEvent(fact.getEventType());
|
||||
callbackLog.setInstanceId(instance.getId());
|
||||
callbackLog.setBizType(instance.getBizType());
|
||||
callbackLog.setBizId(instance.getBizId());
|
||||
callbackLog.setPayloadJson(payloadJson);
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setRetryCount(0);
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now());
|
||||
Long logId = logStore.persist(callbackLog);
|
||||
submitPush(logId);
|
||||
}
|
||||
|
||||
private void dispatch(FlowEvent event) throws Exception {
|
||||
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null);
|
||||
if (instance == null) {
|
||||
return;
|
||||
@@ -90,6 +229,7 @@ public class CallbackDispatcher {
|
||||
List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
|
||||
instance.getSchId(), def.getFlowGuid(), 0);
|
||||
for (FlwCallbackReg reg : regs) {
|
||||
if (!self(reg, instance)) continue;
|
||||
if (!subscribed(reg.getEventTypes(), eventCode)) {
|
||||
continue;
|
||||
}
|
||||
@@ -97,49 +237,87 @@ public class CallbackDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
private void persistAndPush(FlwCallbackReg reg, FlwInstance instance, FlwDef def, FlowEvent event) {
|
||||
private void persistAndPush(FlwCallbackReg reg, FlwInstance instance, FlwDef def, FlowEvent event)
|
||||
throws Exception {
|
||||
// T05:eventId 用事实行稳定身份(全部订阅方/重试复用);事实行缺席(存量路径)才现场生成
|
||||
String eventId = event.getStableEventId() != null ? event.getStableEventId()
|
||||
: UUID.randomUUID().toString().replace("-", "").toLowerCase();
|
||||
if (logRepository.existsByEventIdAndRegId(eventId, reg.getId())) {
|
||||
return; // 幂等:补分发与正常分发竞争时不重复建投递行
|
||||
}
|
||||
// R2:优先复用事实冻结信封(与崩溃恢复链同构、消息体恒含 eventId/eventSeq);
|
||||
// 冻结体缺席(存量路径/记录器缺席)才现场构建
|
||||
String payloadJson = event.getStablePayloadJson() != null
|
||||
? event.getStablePayloadJson()
|
||||
: objectMapper.writeValueAsString(legacyEnvelope(event, instance, def, eventId));
|
||||
FlwCallbackLog callbackLog = new FlwCallbackLog();
|
||||
callbackLog.setSchId(instance.getSchId());
|
||||
callbackLog.setRegId(reg.getId());
|
||||
callbackLog.setEventId(eventId);
|
||||
callbackLog.setEvent(event.getType().getCode());
|
||||
callbackLog.setInstanceId(instance.getId());
|
||||
callbackLog.setBizType(instance.getBizType());
|
||||
callbackLog.setBizId(instance.getBizId());
|
||||
callbackLog.setPayloadJson(payloadJson);
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setRetryCount(0);
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now());
|
||||
// REQUIRES_NEW 落库(afterCommit 阶段原事务已完结,REQUIRED 会挂空事务);
|
||||
// R2:建行失败(含并发撞 uk)向外传播——调用方据此不标记事实已分发/保持待重扫
|
||||
Long logId;
|
||||
try {
|
||||
String eventId = UUID.randomUUID().toString().replace("-", "").toLowerCase();
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("event", event.getType().getCode());
|
||||
payload.put("schId", instance.getSchId());
|
||||
payload.put("flowGuid", def.getFlowGuid());
|
||||
payload.put("instanceId", instance.getId());
|
||||
payload.put("instanceVersion", instance.getVersion());
|
||||
payload.put("bizType", instance.getBizType());
|
||||
payload.put("bizId", instance.getBizId());
|
||||
payload.put("bizTitle", instance.getBizTitle());
|
||||
payload.put("occurredTime", LocalDateTime.now().format(TIME));
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
if (event.getData() != null) {
|
||||
data.putAll(event.getData());
|
||||
}
|
||||
payload.put("data", data);
|
||||
String payloadJson = objectMapper.writeValueAsString(payload);
|
||||
logId = logStore.persist(callbackLog);
|
||||
} catch (DuplicateKeyException dup) {
|
||||
return; // uk(event_id, reg_id) 并发兜底:另一路径已建行,幂等成功
|
||||
}
|
||||
submitPush(logId);
|
||||
}
|
||||
|
||||
FlwCallbackLog callbackLog = new FlwCallbackLog();
|
||||
callbackLog.setSchId(instance.getSchId());
|
||||
callbackLog.setRegId(reg.getId());
|
||||
callbackLog.setEventId(eventId);
|
||||
callbackLog.setEvent(event.getType().getCode());
|
||||
callbackLog.setInstanceId(instance.getId());
|
||||
callbackLog.setBizType(instance.getBizType());
|
||||
callbackLog.setBizId(instance.getBizId());
|
||||
callbackLog.setPayloadJson(payloadJson);
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setRetryCount(0);
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now());
|
||||
// REQUIRES_NEW 落库(afterCommit 阶段原事务已完结,REQUIRED 会挂空事务)
|
||||
Long logId = logStore.persist(callbackLog);
|
||||
// 异步首推:失败由退避重试接手(1m 起)
|
||||
/** 异步首推提交:失败不回退建行结果(行已在,重试扫描接手) */
|
||||
private void submitPush(Long logId) {
|
||||
try {
|
||||
executor().submit(() -> pushService.pushAsync(logId));
|
||||
} catch (Exception e) {
|
||||
log.error("[flow-center] 回调日志落库失败(对账兜底会追平):instance={} event={} {}",
|
||||
instance.getId(), event.getType().getCode(), e.toString(), e);
|
||||
log.warn("[flow-center] 异步首推提交失败(重试扫描接手):logId={} {}", logId, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 存量现场信封(事实冻结体缺席的兜底路径):字段口径与 FlwStateEventRecorder 冻结体一致。
|
||||
*/
|
||||
private Map<String, Object> legacyEnvelope(FlowEvent event, FlwInstance instance, FlwDef def,
|
||||
String eventId) {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("eventId", eventId);
|
||||
if (event.getStableEventSeq() != null) {
|
||||
payload.put("eventSeq", event.getStableEventSeq());
|
||||
}
|
||||
payload.put("event", event.getType().getCode());
|
||||
payload.put("schId", instance.getSchId());
|
||||
payload.put("flowGuid", def.getFlowGuid());
|
||||
payload.put("instanceId", instance.getId());
|
||||
payload.put("instanceVersion", instance.getVersion());
|
||||
payload.put("bizType", instance.getBizType());
|
||||
payload.put("bizId", instance.getBizId());
|
||||
payload.put("bizTitle", instance.getBizTitle());
|
||||
payload.put("occurredTime", LocalDateTime.now().format(TIME));
|
||||
// 2026-09-20 信封升级:任务定位与操作人(均可空,办件级事件/历史重放体为 null——
|
||||
// 契约见《子应用接入流程中心规范.md》§6.3/§6.4)
|
||||
payload.put("taskId", event.getTaskId());
|
||||
payload.put("operatorUserId", event.getOperatorId());
|
||||
payload.put("operatorName", event.getOperatorName());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
if (event.getData() != null) {
|
||||
data.putAll(event.getData());
|
||||
}
|
||||
// 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。
|
||||
if (event.getRoundIdentity() != null) {
|
||||
data.putAll(event.getRoundIdentity());
|
||||
}
|
||||
payload.put("data", data);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** 重推扫描(fixedDelay 串行防重入;推拉结合里 Webhook 永远只是加速器) */
|
||||
@Scheduled(fixedDelay = 30_000)
|
||||
public void scanRetry() {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import com.yunhai.flowcenter.repository.FlwStateEventRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Fact bookkeeping has its own commit; AFTER_COMMIT still has the completed business resources bound. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CallbackFactStore {
|
||||
|
||||
private final FlwStateEventRepository stateEventRepository;
|
||||
|
||||
/** Called only after every matching delivery has been persisted (including the zero-subscriber case). */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void markDispatched(String eventId) {
|
||||
stateEventRepository.findByEventId(eventId).ifPresent(fact -> {
|
||||
fact.setDispatched(1);
|
||||
stateEventRepository.save(fact);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,45 @@ package com.yunhai.flowcenter.callback;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackLog;
|
||||
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.BeanPropertyRowMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 回调日志落库(独立 bean 保证 REQUIRES_NEW 生效):AFTER_COMMIT 监听阶段原事务已提交
|
||||
* 但事务资源仍挂在线程上,REQUIRED 会"加入"已完结事务并抛 no transaction is in progress——
|
||||
* 必须显式开新事务写日志。落库成功即视为主业务完成,推送失败不影响审批。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CallbackLogStore {
|
||||
|
||||
private final FlwCallbackLogRepository logRepository;
|
||||
private final DataSource dataSource;
|
||||
private final int claimIdleSeconds;
|
||||
|
||||
@Autowired
|
||||
public CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource) {
|
||||
this(logRepository, dataSource, 60);
|
||||
}
|
||||
|
||||
// 窄范围故障测试可缩短会话租期;生产固定 60s,大于正常 HTTP 5s 连接 + 10s 读取超时。
|
||||
CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource, int claimIdleSeconds) {
|
||||
if (claimIdleSeconds < 1) throw new IllegalArgumentException("claimIdleSeconds must be positive");
|
||||
this.logRepository = logRepository;
|
||||
this.dataSource = dataSource;
|
||||
this.claimIdleSeconds = claimIdleSeconds;
|
||||
}
|
||||
|
||||
/** @return 落库后的日志行 id */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
@@ -24,4 +48,198 @@ public class CallbackLogStore {
|
||||
FlwCallbackLog saved = logRepository.saveAndFlush(callbackLog);
|
||||
return saved.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 会话锁不是事务/行锁。专用借出连接始终 autocommit,HTTP 不占用数据库事务。
|
||||
* 连接空闲 60s 后由数据库关闭并释放锁;所有结果必须在原连接上验证锁所有权,禁止换连接补写。
|
||||
*/
|
||||
public Claim tryClaim(Long id) {
|
||||
Claim claim = openSession();
|
||||
try {
|
||||
if (!claim.acquire(id)) {
|
||||
claim.close();
|
||||
return null;
|
||||
}
|
||||
claim.row = claim.read(id);
|
||||
if (claim.row == null) {
|
||||
claim.close();
|
||||
return null;
|
||||
}
|
||||
return claim;
|
||||
} catch (RuntimeException | SQLException failure) {
|
||||
claim.close();
|
||||
throw new IllegalStateException("回调认领失败", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private Claim openSession() {
|
||||
Connection connection = null;
|
||||
try {
|
||||
// 不用 DataSourceUtils:绝不借用调用方已绑定的业务事务连接。
|
||||
connection = dataSource.getConnection();
|
||||
if (!connection.getAutoCommit()) throw new SQLException("callback claim requires autocommit");
|
||||
long previous;
|
||||
try (Statement st = connection.createStatement(); ResultSet rs = st.executeQuery("SELECT @@session.wait_timeout")) {
|
||||
rs.next(); previous = rs.getLong(1);
|
||||
}
|
||||
try (Statement st = connection.createStatement()) {
|
||||
st.execute("SET SESSION wait_timeout = " + claimIdleSeconds);
|
||||
}
|
||||
return new Claim(connection, previous);
|
||||
} catch (SQLException failure) {
|
||||
if (connection != null) {
|
||||
try { connection.abort(Runnable::run); } catch (SQLException ignored) { }
|
||||
try { connection.close(); } catch (SQLException ignored) { }
|
||||
}
|
||||
throw new IllegalStateException("回调认领连接不可用", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** 人工重推先完成整批短事务,再交回推送层发 HTTP;成功行不能被旧管理快照复活。 */
|
||||
public List<Long> resetForReplay(Long school, Collection<Long> ids) {
|
||||
try (Claim claim = openSession()) {
|
||||
List<Long> sorted = new ArrayList<>(new TreeSet<>(ids));
|
||||
for (Long id : sorted) {
|
||||
if (!claim.acquire(id)) throw new BizException(ErrorCode.CUSTOM, "回调正在派发,请稍后重推:" + id);
|
||||
FlwCallbackLog row = claim.read(id);
|
||||
if (row == null || !school.equals(row.getSchId())) throw new BizException(ErrorCode.FORBIDDEN, "回调日志不存在或不属于该校");
|
||||
}
|
||||
claim.connection.setAutoCommit(false);
|
||||
List<Long> reset = new ArrayList<>();
|
||||
try {
|
||||
for (Long id : sorted) {
|
||||
// 也检查所有权,旧 MySQL 若不支持同会话多锁会 fail closed,而不会无锁更新。
|
||||
if (!claim.owns(id)) throw new BizException(ErrorCode.CUSTOM, "回调派发权已失效,请重试");
|
||||
int changed = claim.update(id, "status=0, retry_count=0, next_retry_time=?, update_time=?, update_by=?",
|
||||
"status in (0,2)", Timestamp.valueOf(LocalDateTime.now()), Timestamp.valueOf(LocalDateTime.now()), actor());
|
||||
if (changed == 1) reset.add(id);
|
||||
}
|
||||
claim.connection.commit();
|
||||
return reset;
|
||||
} catch (RuntimeException | SQLException failure) {
|
||||
claim.connection.rollback();
|
||||
throw failure;
|
||||
} finally { claim.connection.setAutoCommit(true); }
|
||||
} catch (SQLException failure) {
|
||||
throw new IllegalStateException("回调重推状态提交失败", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String actor() {
|
||||
com.yunhai.platform.auth.LoginUser user = com.yunhai.platform.auth.UserContextHolder.get();
|
||||
return user == null ? null : user.getLoginName();
|
||||
}
|
||||
|
||||
/** 令牌即拥有命名锁的原始数据库会话;状态更新只能在该连接上进行。 */
|
||||
public static final class Claim implements AutoCloseable {
|
||||
private final Connection connection;
|
||||
private final long previousWaitTimeout;
|
||||
private final Map<Long, String> keys = new LinkedHashMap<>();
|
||||
private FlwCallbackLog row;
|
||||
private boolean closed;
|
||||
private boolean settled;
|
||||
|
||||
private Claim(Connection connection, long previousWaitTimeout) {
|
||||
this.connection = connection;
|
||||
this.previousWaitTimeout = previousWaitTimeout;
|
||||
}
|
||||
|
||||
public FlwCallbackLog row() { return row; }
|
||||
|
||||
public boolean active() {
|
||||
try { return !closed && row != null && owns(row.getId()); }
|
||||
catch (SQLException expired) { return false; }
|
||||
}
|
||||
|
||||
private boolean acquire(Long id) throws SQLException {
|
||||
String key;
|
||||
// 64 字符锁名:库名隔离,同 (eventId,regId) 的全部执行者用同一身份。
|
||||
try (PreparedStatement st = connection.prepareStatement(
|
||||
"SELECT SHA2(CONCAT(LOWER(DATABASE()), ':flw_callback:', event_id, ':', reg_id),256) FROM flw_callback_log WHERE id=?")) {
|
||||
st.setLong(1, id);
|
||||
try (ResultSet rs = st.executeQuery()) { if (!rs.next()) return false; key = rs.getString(1); }
|
||||
}
|
||||
try (PreparedStatement st = connection.prepareStatement("SELECT GET_LOCK(?,0)")) {
|
||||
st.setString(1, key);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
rs.next();
|
||||
if (rs.getInt(1) != 1 || rs.wasNull()) return false;
|
||||
}
|
||||
}
|
||||
keys.put(id, key);
|
||||
return true;
|
||||
}
|
||||
|
||||
private FlwCallbackLog read(Long id) throws SQLException {
|
||||
try (PreparedStatement st = connection.prepareStatement("SELECT * FROM flw_callback_log WHERE id=?")) {
|
||||
st.setLong(1, id);
|
||||
try (ResultSet rs = st.executeQuery()) {
|
||||
return rs.next() ? new BeanPropertyRowMapper<>(FlwCallbackLog.class).mapRow(rs, 0) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean owns(Long id) throws SQLException {
|
||||
try (PreparedStatement st = connection.prepareStatement("SELECT IS_USED_LOCK(?)=CONNECTION_ID()")) {
|
||||
st.setString(1, keys.get(id));
|
||||
try (ResultSet rs = st.executeQuery()) { rs.next(); return rs.getBoolean(1); }
|
||||
}
|
||||
}
|
||||
|
||||
private int update(Long id, String assignments, String condition, Object... values) throws SQLException {
|
||||
try (PreparedStatement st = connection.prepareStatement("UPDATE flw_callback_log SET " + assignments
|
||||
+ " WHERE id=? AND " + condition + " AND IS_USED_LOCK(?)=CONNECTION_ID()")) {
|
||||
int i=1;
|
||||
for (Object value : values) st.setObject(i++, value);
|
||||
st.setLong(i++, id); st.setString(i, keys.get(id));
|
||||
return st.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean success(int httpStatus) { return finish("status=1, last_http_status=?, last_error=NULL, push_time=?, next_retry_time=NULL",
|
||||
String.valueOf(httpStatus), Timestamp.valueOf(LocalDateTime.now())); }
|
||||
|
||||
public boolean failure(String error, String httpStatus, long backoffMinutes) {
|
||||
return finish("retry_count=COALESCE(retry_count,0)+1, last_http_status=?, last_error=?, next_retry_time=?",
|
||||
httpStatus, error, Timestamp.valueOf(LocalDateTime.now().plusMinutes(backoffMinutes)));
|
||||
}
|
||||
|
||||
public boolean dead(String error) { return finish("status=2, last_error=?", error); }
|
||||
|
||||
private boolean finish(String assignments, Object... values) {
|
||||
if (closed || settled || row == null) return false;
|
||||
Object[] audited = Arrays.copyOf(values, values.length+2);
|
||||
audited[values.length] = Timestamp.valueOf(LocalDateTime.now()); audited[values.length+1] = actor();
|
||||
try {
|
||||
// count 是附加 CAS;不是把旧实体全量 merge 回库。会话失权/成功/人工处理后均不能覆盖。
|
||||
settled = update(row.getId(), assignments + ", update_time=?, update_by=?",
|
||||
"status=0 AND retry_count=" + row.getRetryCount(), audited) == 1;
|
||||
if (!settled) log.debug("[flow-center] 回调旧结果已忽略:logId={}", row.getId());
|
||||
return settled;
|
||||
} catch (SQLException failure) {
|
||||
log.warn("[flow-center] 回调结果未记账(会话失效或数据库不可用,留待重试):logId={} {}", row.getId(), failure.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
for (String key : keys.values()) {
|
||||
try (PreparedStatement st = connection.prepareStatement("SELECT RELEASE_LOCK(?)")) {
|
||||
st.setString(1, key); st.execute();
|
||||
}
|
||||
}
|
||||
try (Statement st = connection.createStatement()) {
|
||||
st.execute("SET SESSION wait_timeout = " + previousWaitTimeout);
|
||||
}
|
||||
} catch (SQLException failedRelease) {
|
||||
// 不能把可能残留会话锁的物理连接还给池;显式中止,让数据库释放全部锁。
|
||||
try { connection.abort(Runnable::run); } catch (SQLException ignored) { }
|
||||
} finally {
|
||||
try { connection.close(); } catch (SQLException ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ import java.util.UUID;
|
||||
* <b>持续重试不终止</b>(T1.1:消费方只承诺"瞬时失败回 5xx",恢复后自动追平,消费方免对账);
|
||||
* 挂起(retryCount 首达 hang-alert-retries)时 UserMsg 告警恰一次;
|
||||
* 死信(status=2)仅两个入口——注册停用、管理端手工标记,重试次数永不自动触发。
|
||||
* 推送一律在新事务里落库(AFTER_COMMIT 监听器/调度线程均无外层事务)。
|
||||
* HTTP 在业务事务之外执行,结果在认领会话上以自动提交的条件 UPDATE 落定。
|
||||
* 推送本身与落库解耦:payload_json 是完整快照,可重放。
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -40,43 +40,72 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class CallbackPushService {
|
||||
|
||||
private static final Set<Long> IN_FLIGHT = new HashSet<>();
|
||||
/** 仅观测,不阻断数据库租期后的恢复;每个执行者有独立本地状态。 */
|
||||
private final Map<Long, Integer> IN_FLIGHT = new HashMap<>();
|
||||
|
||||
/** 行是否有本地执行者在途(仅观测;互斥由数据库认领负责)。 */
|
||||
boolean inFlight(Long logId) {
|
||||
synchronized (IN_FLIGHT) {
|
||||
return IN_FLIGHT.containsKey(logId);
|
||||
}
|
||||
}
|
||||
|
||||
private final FlwCallbackLogRepository logRepository;
|
||||
private final FlwCallbackRegRepository regRepository;
|
||||
private final CallbackProperties properties;
|
||||
private final CallbackLogStore logStore;
|
||||
private final UserMsgClient userMsgClient;
|
||||
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
|
||||
appIdentityProvider;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private CallbackSourcePolicy sourcePolicy;
|
||||
|
||||
/** 推送指定日志行(幂等入口:成功/终态/在途跳过) */
|
||||
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
|
||||
public void pushAsync(Long logId) {
|
||||
synchronized (IN_FLIGHT) {
|
||||
if (!IN_FLIGHT.add(logId)) {
|
||||
IN_FLIGHT.merge(logId, 1, Integer::sum);
|
||||
}
|
||||
try (CallbackLogStore.Claim claim = logStore.tryClaim(logId)) {
|
||||
if (claim == null) {
|
||||
log.debug("[flow-center] 回调数据库派发权未取得:logId={}", logId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
doPush(logId);
|
||||
doPush(claim);
|
||||
} finally {
|
||||
synchronized (IN_FLIGHT) {
|
||||
IN_FLIGHT.remove(logId);
|
||||
IN_FLIGHT.computeIfPresent(logId, (id, count) -> count == 1 ? null : count - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void doPush(Long logId) {
|
||||
FlwCallbackLog callbackLog = logRepository.findById(logId).orElse(null);
|
||||
private void doPush(CallbackLogStore.Claim claim) {
|
||||
FlwCallbackLog callbackLog = claim.row();
|
||||
if (callbackLog == null
|
||||
|| callbackLog.getStatus() != FlwEnums.CallbackLogStatus.PENDING.getCode()) {
|
||||
log.debug("[flow-center] 回调推送早退(行缺席或非待推):logId={} status={}",
|
||||
callbackLog == null ? null : callbackLog.getId(), callbackLog == null ? null : callbackLog.getStatus());
|
||||
return;
|
||||
}
|
||||
FlwCallbackReg reg = regRepository.findById(callbackLog.getRegId()).orElse(null);
|
||||
if (reg == null || reg.getStatus() != 0) {
|
||||
// 注册已停用:保留日志待查,不再推送(置死信便于管理端一次性清理)
|
||||
markDead(callbackLog, "registration disabled");
|
||||
markDead(claim, "registration disabled");
|
||||
return;
|
||||
}
|
||||
if (sourcePolicy != null) {
|
||||
try {
|
||||
if (!sourcePolicy.permits(reg, callbackLog)) {
|
||||
markDead(claim, "SELF source or command identity mismatch");
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException unavailable) {
|
||||
markRetryOrFail(claim, "source authentication unavailable", null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!claim.active()) return; // 权威查询迟到/会话租期已失效:不再发送。
|
||||
String body = callbackLog.getPayloadJson();
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
@@ -94,41 +123,34 @@ public class CallbackPushService {
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
int status = resp.getStatusCodeValue();
|
||||
if (status >= 200 && status < 300) {
|
||||
markSuccess(callbackLog, status);
|
||||
markSuccess(claim, status);
|
||||
} else {
|
||||
markRetryOrFail(callbackLog, "HTTP " + status, String.valueOf(status));
|
||||
markRetryOrFail(claim, "HTTP " + status, String.valueOf(status));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
markRetryOrFail(callbackLog, rootMessage(e), null);
|
||||
markRetryOrFail(claim, rootMessage(e), null);
|
||||
}
|
||||
}
|
||||
|
||||
private void markSuccess(FlwCallbackLog callbackLog, int httpStatus) {
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.SUCCESS.getCode());
|
||||
callbackLog.setRetryCount(callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount());
|
||||
callbackLog.setLastHttpStatus(String.valueOf(httpStatus));
|
||||
callbackLog.setLastError(null);
|
||||
callbackLog.setPushTime(LocalDateTime.now());
|
||||
saveQuietly(callbackLog);
|
||||
log.debug("[flow-center] 回调推送成功:eventId={} http={}", callbackLog.getEventId(), httpStatus);
|
||||
private void markSuccess(CallbackLogStore.Claim claim, int httpStatus) {
|
||||
if (claim.success(httpStatus)) {
|
||||
log.debug("[flow-center] 回调推送成功:eventId={} http={}", claim.row().getEventId(), httpStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败落定(持续重试语义):越过退避表后按 {@link CallbackProperties#getCapBackoffMinutes()}
|
||||
* 封顶继续排期,<b>绝不自动死信</b>;retryCount 首次等于挂起阈值时告警恰一次。
|
||||
*/
|
||||
private void markRetryOrFail(FlwCallbackLog callbackLog, String error, String httpStatus) {
|
||||
private void markRetryOrFail(CallbackLogStore.Claim claim, String error, String httpStatus) {
|
||||
FlwCallbackLog callbackLog = claim.row();
|
||||
int retryCount = (callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount()) + 1;
|
||||
callbackLog.setRetryCount(retryCount);
|
||||
callbackLog.setLastHttpStatus(httpStatus);
|
||||
callbackLog.setLastError(truncate(error));
|
||||
java.util.List<Long> backoff = properties.getRetryBackoffMinutes();
|
||||
long backoffMinutes = retryCount <= backoff.size()
|
||||
? backoff.get(retryCount - 1)
|
||||
: Math.max(1, properties.getCapBackoffMinutes());
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
|
||||
callbackLog.setNextRetryTime(LocalDateTime.now().plusMinutes(backoffMinutes));
|
||||
saveQuietly(callbackLog);
|
||||
if (!claim.failure(truncate(error), httpStatus, backoffMinutes)) return;
|
||||
callbackLog.setRetryCount(retryCount); // 仅用于日志/告警;数据库已原子 +1,绝不 merge 此快照。
|
||||
log.warn("[flow-center] 回调推送失败(第 {} 次重试,{} 分钟后):eventId={} {}",
|
||||
retryCount, backoffMinutes, callbackLog.getEventId(), error);
|
||||
// 挂起告警恰一次:retryCount 每失败 +1 单调递增,等于阈值的时机只有一次;
|
||||
@@ -152,7 +174,7 @@ public class CallbackPushService {
|
||||
+ " 推送连续失败 " + callbackLog.getRetryCount() + " 次,已转入持续重试(封顶 "
|
||||
+ capMinutes + " 分钟/次)。消费方恢复后将自动追平,无需人工干预;"
|
||||
+ "如确认该事件已无意义,请在管理端标记死信。最近错误:" + truncate(error),
|
||||
null, "hang:" + callbackLog.getEventId(), null);
|
||||
null, null, "hang:" + callbackLog.getEventId(), null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 挂起告警发送失败:eventId={} {}", callbackLog.getEventId(), e.toString());
|
||||
}
|
||||
@@ -163,9 +185,20 @@ public class CallbackPushService {
|
||||
* 仍被注册停用场景使用(doPush:注册已停用即无意义重试,置死信便于管理端清理)。
|
||||
*/
|
||||
public void markDead(FlwCallbackLog callbackLog, String error) {
|
||||
callbackLog.setStatus(FlwEnums.CallbackLogStatus.DEAD.getCode());
|
||||
callbackLog.setLastError(truncate(error));
|
||||
saveQuietly(callbackLog);
|
||||
try (CallbackLogStore.Claim claim = logStore.tryClaim(callbackLog.getId())) {
|
||||
if (claim == null) throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.CUSTOM, "回调正在派发,请稍后标记");
|
||||
if (claim.row().getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "已成功日志无需标记死信");
|
||||
}
|
||||
markDead(claim, error);
|
||||
}
|
||||
}
|
||||
|
||||
private void markDead(CallbackLogStore.Claim claim, String error) {
|
||||
if (!claim.dead(truncate(error))) return;
|
||||
FlwCallbackLog callbackLog = claim.row();
|
||||
log.error("[flow-center] 回调推送进入死信:eventId={} biz={}/{} error={}",
|
||||
callbackLog.getEventId(), callbackLog.getBizType(), callbackLog.getBizId(), error);
|
||||
alertDead(callbackLog, error);
|
||||
@@ -197,17 +230,108 @@ public class CallbackPushService {
|
||||
"回调推送死信告警",
|
||||
"办件 " + callbackLog.getInstanceId() + " 事件 " + callbackLog.getEvent()
|
||||
+ " 推送失败进入死信:" + error,
|
||||
null, "dead:" + callbackLog.getEventId(), null);
|
||||
null, null, "dead:" + callbackLog.getEventId(), null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 死信告警发送失败:eventId={} {}", callbackLog.getEventId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void saveQuietly(FlwCallbackLog callbackLog) {
|
||||
/**
|
||||
* 回调探测(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 {
|
||||
logRepository.save(callbackLog);
|
||||
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) {
|
||||
log.error("[flow-center] 回调日志落库失败:eventId={} {}", callbackLog.getEventId(), e.toString());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwCallbackReg;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.service.FlowSourcePrincipalResolver;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Same SELF predicate for live fanout, fact recovery, retries and manual re-delivery. */
|
||||
@Component @RequiredArgsConstructor
|
||||
public class CallbackSourcePolicy {
|
||||
private final FlowSourcePrincipalResolver resolver;
|
||||
private final FlwInstanceRepository instances;
|
||||
private final ObjectMapper mapper;
|
||||
public boolean matches(FlwCallbackReg reg, FlwInstance instance) {
|
||||
if (instance.getSourcePrincipalId() == null) {
|
||||
return false; // Unknown provenance must not fan out to unrelated subscriptions.
|
||||
}
|
||||
if (instance.getSchId() == null || !instance.getSchId().equals(reg.getSchId())) return false;
|
||||
if (!instance.getSourcePrincipalId().equals(reg.getSourcePrincipalId())) return false;
|
||||
// Recheck the credential mapping: a reused appKey cannot receive its predecessor's facts.
|
||||
return instance.getSourcePrincipalId().equals(resolver.resolve(reg.getAppKey()).id);
|
||||
}
|
||||
public boolean permits(FlwCallbackReg reg, com.yunhai.flowcenter.entity.FlwCallbackLog delivery) {
|
||||
if (!permits(reg, delivery.getInstanceId(), delivery.getPayloadJson())) return false;
|
||||
FlwInstance instance = instances.findById(delivery.getInstanceId()).orElseThrow(IllegalStateException::new);
|
||||
if (instance.getSourcePrincipalId() == null) return true;
|
||||
try {
|
||||
JsonNode body = mapper.readTree(delivery.getPayloadJson());
|
||||
return java.util.Objects.equals(delivery.getEventId(), body.path("eventId").asText())
|
||||
&& java.util.Objects.equals(delivery.getEvent(), body.path("event").asText())
|
||||
&& java.util.Objects.equals(delivery.getBizType(), body.path("bizType").asText())
|
||||
&& java.util.Objects.equals(delivery.getBizId(), body.path("bizId").asText());
|
||||
} catch (Exception e) { return false; }
|
||||
}
|
||||
public boolean permits(FlwCallbackReg reg, Long instanceId, String payload) {
|
||||
FlwInstance instance = instances.findById(instanceId).orElse(null);
|
||||
if (instance == null || !matches(reg, instance)) return false;
|
||||
if (instance.getSourcePrincipalId() == null) return true;
|
||||
try {
|
||||
JsonNode body = mapper.readTree(payload);
|
||||
JsonNode data = body.path("data");
|
||||
if (instance.getLegacySlot() != null) {
|
||||
// v1 has no request token/hash/round; legacy R4 can change those snapshots.
|
||||
// Delivery facts are persisted by FL, while stable SELF coordinates remain mandatory.
|
||||
return instance.getSourcePrincipalId().equals(body.path("sourcePrincipalId").asText())
|
||||
&& instance.getSourcePrincipalId().equals(data.path("sourcePrincipalId").asText())
|
||||
&& instance.getId().equals(body.path("instanceId").asLong())
|
||||
&& instance.getSchId().equals(body.path("schId").asLong())
|
||||
&& java.util.Objects.equals(instance.getBizType(), body.path("bizType").asText())
|
||||
&& java.util.Objects.equals(instance.getBizId(), body.path("bizId").asText());
|
||||
}
|
||||
return instance.getSourcePrincipalId().equals(body.path("sourcePrincipalId").asText())
|
||||
&& instance.getApplyRound().equals(body.path("applyRound").asInt())
|
||||
&& instance.getSourcePrincipalId().equals(data.path("sourcePrincipalId").asText())
|
||||
&& instance.getApplyRequestToken().equals(data.path("requestId").asText())
|
||||
&& instance.getApplyPayloadHash().equals(data.path("canonicalHash").asText())
|
||||
&& instance.getDefVersion().equals(data.path("defVersion").asInt())
|
||||
&& instance.getApplyRound().equals(data.path("applyRound").asInt())
|
||||
&& instance.getId().equals(body.path("instanceId").asLong())
|
||||
&& instance.getSchId().equals(body.path("schId").asLong())
|
||||
&& instance.getBizType().equals(body.path("bizType").asText())
|
||||
&& instance.getBizId().equals(body.path("bizId").asText());
|
||||
} catch (Exception e) { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwStateEvent;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwStateEventRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 状态事件事实记录器(2026-10-25 T05;同日 R2 返修冻结完整信封):普通同步监听器,与发布方
|
||||
* <b>同一事务</b>执行——状态变更提交成功 ⇔ 不可变事件事实已落库,关闭"提交到 AFTER_COMMIT
|
||||
* 分发之间进程被杀,事件永久丢失"的窗口(审计 E24)。
|
||||
*
|
||||
* <p>职责边界:只落事实行并给 {@link FlowEvent} 赋<b>稳定身份</b>与<b>冻结信封</b>
|
||||
* (eventId 全订阅方/全部重试复用;eventSeq 实例内单调;R2 起信封含 eventId/eventSeq/flowGuid
|
||||
* 全部投递字段——正常分发与崩溃恢复共用同一冻结体,Codex D04"恢复消息体缺 eventId"关闭);
|
||||
* 回调分发、订阅匹配、推送仍归 {@link CallbackDispatcher}。URGE 站内信不入事件流(既有口径)。
|
||||
* 无事务的发布(fallbackExecution 场景)退化为自动提交直写——事实仍落,只是失去与状态的
|
||||
* 同事务原子性(记录 warn,不静默)。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FlwStateEventRecorder {
|
||||
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final FlwStateEventRepository eventRepository;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@EventListener
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
|
||||
public void record(FlowEvent event) {
|
||||
if (event.getType() == FlowEvent.Type.URGE) {
|
||||
return;
|
||||
}
|
||||
if (event.getStableEventId() != null) {
|
||||
return; // 幂等:同一事件对象重复发布不重复落事实
|
||||
}
|
||||
try {
|
||||
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null);
|
||||
if (instance == null) {
|
||||
log.warn("[state-event] 事实落库时实例不存在(跳过):instance={} event={}",
|
||||
event.getInstanceId(), event.getType().getCode());
|
||||
return;
|
||||
}
|
||||
FlwDef def = instance.getDefId() == null ? null
|
||||
: defRepository.findById(instance.getDefId()).orElse(null);
|
||||
FlwStateEvent fact = new FlwStateEvent();
|
||||
fact.setEventId(UUID.randomUUID().toString().replace("-", "").toLowerCase());
|
||||
fact.setSchId(instance.getSchId());
|
||||
fact.setInstanceId(instance.getId());
|
||||
fact.setApplyRound(instance.getApplyRound());
|
||||
// T05 发号(终形态):FOR UPDATE 标量当前读(行 X 锁,任何隔离级别串行正确)
|
||||
// + 锁内回写;不实体 merge(@Version 会 StaleObjectState,实证)。
|
||||
Integer nextSeq = instanceRepository.nextEventSeqForUpdate(instance.getId());
|
||||
if (nextSeq == null) {
|
||||
throw new IllegalStateException("事件序号发号返回空:instance=" + instance.getId());
|
||||
}
|
||||
instanceRepository.setEventSeqCounter(instance.getId(), nextSeq);
|
||||
fact.setEventSeq(nextSeq);
|
||||
fact.setEventType(event.getType().getCode());
|
||||
fact.setTaskId(event.getTaskId());
|
||||
fact.setOperatorId(event.getOperatorId());
|
||||
// R2:冻结完整投递信封(含 eventId/eventSeq/flowGuid)——事实载荷即投递体,
|
||||
// 正常链与恢复链字节级同构,接收端必填字段(eventId/event/bizType/bizId)恒在。
|
||||
fact.setPayloadJson(objectMapper.writeValueAsString(
|
||||
fullEnvelope(event, instance, def, fact.getEventId(), nextSeq)));
|
||||
eventRepository.save(fact);
|
||||
event.assignStableIdentity(fact.getEventId(), fact.getEventSeq());
|
||||
event.assignStablePayload(fact.getPayloadJson());
|
||||
} catch (Exception e) {
|
||||
// 事实落库失败=状态事务随之一并失败(同事务)或无事务场景仅告警;
|
||||
// 绝不反噬引擎主流程——分发链对账兜底仍在
|
||||
log.error("[state-event] 事件事实落库异常:instance={} event={} {}",
|
||||
event.getInstanceId(), event.getType().getCode(), e.toString(), e);
|
||||
if (org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
.isActualTransactionActive()) {
|
||||
// 同事务场景:事实写失败必须让状态一起回滚(事件与状态原子),不吞
|
||||
throw new IllegalStateException("状态事件事实落库失败,事务回滚:" + event.getType().getCode(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整投递信封(R2 冻结体):{@code eventId/eventSeq/event/schId/flowGuid/instanceId/
|
||||
* instanceVersion/applyRound/bizType/bizId/bizTitle/occurredTime/taskId/operatorUserId/
|
||||
* operatorName/data}。def 缺席(定义已删)时不带 flowGuid——该场合分发器同样不投递。
|
||||
*/
|
||||
private Map<String, Object> fullEnvelope(FlowEvent event, FlwInstance instance,
|
||||
FlwDef def, String eventId, Integer eventSeq) {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("eventSeq", eventSeq);
|
||||
payload.put("event", event.getType().getCode());
|
||||
payload.put("schId", instance.getSchId());
|
||||
if (def != null) {
|
||||
payload.put("flowGuid", def.getFlowGuid());
|
||||
}
|
||||
payload.put("instanceId", instance.getId());
|
||||
payload.put("instanceVersion", instance.getVersion());
|
||||
payload.put("applyRound", instance.getApplyRound());
|
||||
payload.put("bizType", instance.getBizType());
|
||||
payload.put("bizId", instance.getBizId());
|
||||
payload.put("bizTitle", instance.getBizTitle());
|
||||
payload.put("occurredTime", LocalDateTime.now().format(TIME));
|
||||
payload.put("taskId", event.getTaskId());
|
||||
payload.put("operatorUserId", event.getOperatorId());
|
||||
payload.put("operatorName", event.getOperatorName());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
if (event.getData() != null) {
|
||||
data.putAll(event.getData());
|
||||
}
|
||||
if (event.getRoundIdentity() != null) {
|
||||
data.putAll(event.getRoundIdentity());
|
||||
}
|
||||
if (instance.getSourcePrincipalId() != null) {
|
||||
payload.put("sourcePrincipalId", instance.getSourcePrincipalId());
|
||||
data.put("sourcePrincipalId", instance.getSourcePrincipalId());
|
||||
data.put("requestId", instance.getApplyRequestToken());
|
||||
data.put("defVersion", instance.getDefVersion());
|
||||
data.put("canonicalHash", instance.getApplyPayloadHash());
|
||||
data.put("applyRound", instance.getApplyRound());
|
||||
}
|
||||
payload.put("data", data);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.flowcenter.callback;
|
||||
import com.yunhai.flowcenter.engine.FlowEvent;
|
||||
import com.yunhai.flowcenter.repository.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Release in the state transaction; late events can never free a newer round's pointer. */
|
||||
@Component @RequiredArgsConstructor
|
||||
public class FormalCoordinateReleaseListener {
|
||||
private final FlwInstanceRepository instances;
|
||||
private final FlwBizCoordinateRepository coordinates;
|
||||
@EventListener
|
||||
public void release(FlowEvent event) {
|
||||
if (event.getType() != FlowEvent.Type.COMPLETED && event.getType() != FlowEvent.Type.REJECTED
|
||||
&& event.getType() != FlowEvent.Type.WITHDRAWN) return;
|
||||
instances.findById(event.getInstanceId()).ifPresent(instance -> {
|
||||
if (instance.getSourcePrincipalId() != null) {
|
||||
if (!org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
throw new IllegalStateException("Formal terminal transition requires the state transaction");
|
||||
}
|
||||
coordinates.release(instance.getSchId(), instance.getSourcePrincipalId(), instance.getBizType(),
|
||||
instance.getBizId(), instance.getId(), instance.getApplyRound());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yunhai.flowcenter.config;
|
||||
import com.yunhai.flowcenter.service.FlowOapiSourceScope;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@Configuration @RequiredArgsConstructor
|
||||
public class FlowSourceScopeConfiguration implements WebMvcConfigurer {
|
||||
private final FlowOapiSourceScope scope;
|
||||
@Override public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(scope).addPathPatterns("/oapi/flow/**");
|
||||
}
|
||||
}
|
||||
@@ -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,18 +46,122 @@ 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));
|
||||
}
|
||||
|
||||
// ==================== 应用使用授权(T01) ====================
|
||||
|
||||
/**
|
||||
* 使用授权管理面(字段注入:构造被既有单测逐位钉住,同 formCenterCatalogClient 先例)。
|
||||
* 授权对象是稳定应用主体(UserBase appId),非 appKey 凭据;撤权只拦新发起。
|
||||
*/
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.yunhai.flowcenter.service.FlowUsageGrantAdminService usageGrantAdminService;
|
||||
|
||||
/** 某流程线的使用授权清单(含已撤销行,审计可见) */
|
||||
@GetMapping("/usage-grant/list")
|
||||
public List<com.yunhai.flowcenter.entity.FlwUsageGrant> usageGrantList(
|
||||
BoaUser user, @RequestParam("flowGuid") String flowGuid) {
|
||||
return usageGrantAdminService.listByFlow(adminGuard.requireAdmin(user, null), flowGuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本校全部授权行(应用使用许可管理页数据源,E6):校域取自管理员会话,
|
||||
* 不接受 body/query 指定学校,也不跨校拉取。
|
||||
*/
|
||||
@GetMapping("/usage-grant/schoolList")
|
||||
public List<com.yunhai.flowcenter.entity.FlwUsageGrant> usageGrantSchoolList(BoaUser user) {
|
||||
return usageGrantAdminService.listBySchool(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
/** 流程线 → 生效授权应用数(流程列表的授权摘要列;仅本校范围) */
|
||||
@GetMapping("/usage-grant/summary")
|
||||
public java.util.Map<String, Long> usageGrantSummary(BoaUser user) {
|
||||
return usageGrantAdminService.grantedAppCounts(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
/** 授予/重申使用授权(appKey 当场解析为 UserBase 稳定主体落库;enforce 开启时校学校开通) */
|
||||
@PostMapping("/usage-grant/grant")
|
||||
public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantGrant(
|
||||
BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return usageGrantAdminService.grant(schId,
|
||||
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")),
|
||||
body.get("appKey") == null ? null : String.valueOf(body.get("appKey")),
|
||||
body.get("remark") == null ? null : String.valueOf(body.get("remark")),
|
||||
user.getName());
|
||||
}
|
||||
|
||||
/** 撤销使用授权(enabled=0 行保留;只拦新发起,在途与已受理命令不受影响) */
|
||||
@PostMapping("/usage-grant/revoke")
|
||||
public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantRevoke(
|
||||
BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return usageGrantAdminService.revoke(schId,
|
||||
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")),
|
||||
body.get("appId") == null ? null : Integer.valueOf(String.valueOf(body.get("appId"))),
|
||||
body.get("remark") == null ? null : String.valueOf(body.get("remark")),
|
||||
user.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/def/get")
|
||||
@@ -62,14 +169,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}")
|
||||
@@ -93,6 +246,34 @@ public class FlowAdminController {
|
||||
return defAdminService.versions(adminGuard.requireAdmin(user, null), flowGuid);
|
||||
}
|
||||
|
||||
// ==================== 线级停发/恢复(T03) ====================
|
||||
|
||||
/** 线级停发:整条 flowGuid 停止新发起/预览;在途实例按原版本跑完不受影响 */
|
||||
@PostMapping("/def/offlineLine")
|
||||
public int defOfflineLine(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return defAdminService.offlineLine(schId,
|
||||
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
|
||||
}
|
||||
|
||||
/** 线级恢复:只恢复线可用性,不改写任何版本状态或发布内容 */
|
||||
@PostMapping("/def/enableLine")
|
||||
public int defEnableLine(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
return defAdminService.enableLine(schId,
|
||||
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
|
||||
}
|
||||
|
||||
/** 在途办件数(议题二④):flowGuids 逗号分隔,去重后最多 100;返回 flowGuid → 审批中办件数(无在途也返 0 键) */
|
||||
@GetMapping("/def/inflight")
|
||||
public Map<String, Long> defInflight(BoaUser user, @RequestParam("flowGuids") String flowGuids) {
|
||||
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,
|
||||
@@ -117,35 +298,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) {
|
||||
@@ -153,21 +399,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));
|
||||
}
|
||||
|
||||
// ==================== 选人组件数据源 ====================
|
||||
|
||||
/** 选人组件树:学校组树(组→组内用户叶子),管理端「选择用户」弹窗共用 */
|
||||
@@ -205,6 +436,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,
|
||||
@@ -241,20 +482,135 @@ 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 下拉与管理页共用) */
|
||||
@GetMapping("/router/list")
|
||||
public List<com.yunhai.flowcenter.entity.FlwRouter> routerList(BoaUser user) {
|
||||
adminGuard.requireAdmin(user, null);
|
||||
return routerAdminService.list();
|
||||
}
|
||||
|
||||
/** 保存注册(id 空=新建;serviceKey=服务目录键,path=判断端点,basePath=对端 context-path 生产必核对) */
|
||||
@PostMapping("/router/save")
|
||||
public com.yunhai.flowcenter.entity.FlwRouter routerSave(BoaUser user,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
adminGuard.requireAdmin(user, null);
|
||||
return routerAdminService.save(
|
||||
body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id"))),
|
||||
(String) body.get("routerKey"), (String) body.get("name"),
|
||||
(String) body.get("serviceKey"), (String) body.get("path"),
|
||||
(String) body.get("basePath"));
|
||||
}
|
||||
|
||||
/** 启用/停用注册(停用后发布校验拦截新引用) */
|
||||
@PostMapping("/router/status/{id}")
|
||||
public void routerStatus(BoaUser user, @PathVariable("id") Long id,
|
||||
@RequestParam("enabled") boolean enabled) {
|
||||
adminGuard.requireAdmin(user, null);
|
||||
routerAdminService.setStatus(id, enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重跑路由(判断失败挂起的恢复入口,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,
|
||||
@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,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yunhai.flowcenter.controller.api;
|
||||
|
||||
import com.yunhai.flowcenter.service.FlowAdminGuard;
|
||||
import com.yunhai.flowcenter.service.UserBaseDepartmentDirectory;
|
||||
import com.yunhai.platform.openapi.base.model.DepartmentEntry;
|
||||
import com.yunhai.platform.openapi.base.model.DepartmentGroup;
|
||||
import com.yunhai.platform.openapi.base.model.DepartmentRef;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 部门目录读端点(C1/C4,2026-10-29):流程编辑器"选择部门"、候选人预览与已存引用的名称回显
|
||||
* 统一以 UserBase 新部门目录为权威来源,不再读写 FL 本地科室。
|
||||
*
|
||||
* <p>校域一律由 {@link FlowAdminGuard#requireAdmin} 从 CAS 会话派生,前端没有可越界的 schId 入口;
|
||||
* 全部只读,不接受写操作,也不回落本地 {@code flw_dept}。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/flow/admin/department")
|
||||
@RequiredArgsConstructor
|
||||
public class FlowDepartmentController {
|
||||
|
||||
private final FlowAdminGuard adminGuard;
|
||||
private final UserBaseDepartmentDirectory directory;
|
||||
|
||||
/** 第一级「部门组」:系统组织(SYSTEM)+ 该校自定义分类(CUSTOM) */
|
||||
@GetMapping("/groups")
|
||||
public List<DepartmentGroup> groups(BoaUser user) {
|
||||
return directory.groups(adminGuard.requireAdmin(user, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 第二级可选部门。
|
||||
*
|
||||
* @param type SYSTEM / CUSTOM(可空=两类都返回)
|
||||
* @param categoryId 仅 CUSTOM 有效
|
||||
* @param withChildren 仅 SYSTEM 有效,true 时扁平带回下级(前端按 parentId 重建层级)
|
||||
*/
|
||||
@GetMapping("/options")
|
||||
public List<DepartmentEntry> options(BoaUser user,
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "categoryId", required = false) Integer categoryId,
|
||||
@RequestParam(value = "withChildren", required = false) Boolean withChildren) {
|
||||
return directory.options(adminGuard.requireAdmin(user, null), type, categoryId, withChildren);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已持久化引用的名称/状态回显(含停用与软删,走 UserBase 的 resolve-batch 历史口径)。
|
||||
* 编辑已有流程时用,避免"停用部门"在编辑器里显示成空白。
|
||||
*/
|
||||
@GetMapping("/resolve")
|
||||
public List<DepartmentEntry> resolve(BoaUser user,
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "departmentId", required = false) Integer departmentId) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
List<DepartmentRef> refs = new ArrayList<>();
|
||||
if (departmentId != null) {
|
||||
refs.add(new DepartmentRef(schId.intValue(), type, departmentId));
|
||||
}
|
||||
return directory.resolve(schId, refs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 部门成员预览(C4:只读预览,不是发起授权)。
|
||||
*
|
||||
* @param scope select(缺省,新指派严档)/ history(既有实例历史解析宽档)
|
||||
*/
|
||||
@GetMapping("/members")
|
||||
public List<BoaUser> members(BoaUser user,
|
||||
@RequestParam("type") String type,
|
||||
@RequestParam("departmentId") Integer departmentId,
|
||||
@RequestParam(value = "scope", required = false) String scope) {
|
||||
Long school = adminGuard.requireAdmin(user, null);
|
||||
return directory.membersAtSchool(school, type, departmentId, scope);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.yunhai.flowcenter.controller.api;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.service.FlowAccessGuard;
|
||||
import com.yunhai.flowcenter.service.FlowDiagramService;
|
||||
import com.yunhai.flowcenter.service.FlowInstanceService;
|
||||
@@ -41,29 +42,57 @@ public class FlowUserController {
|
||||
private final FlowDiagramService diagramService;
|
||||
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
|
||||
|
||||
/** oapi 预览服务复用(T07 托管选人:候选+版本锚同源,字段注入防构造钉) */
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.yunhai.flowcenter.service.OapiFlowService oapiFlowService;
|
||||
|
||||
/**
|
||||
* 发起时指派预览(2026-10-25 T07 托管选人 iframe 数据源,F6 预置件点亮):
|
||||
* CAS 会话取学校+提交人,复用 oapi applyPreview(含 T03 线级停发守卫)——
|
||||
* 返回 {flowGuid, defVersion, nodes:[{nodeId,nodeName,countersign,selection,candidates}]}。
|
||||
* <b>只读预览</b>:不建实例、不建任务、不落任何流程事实;正式提交仍走 apply 终审
|
||||
* (前端候选不是授权凭据)。
|
||||
*/
|
||||
@GetMapping("/apply/preview")
|
||||
public OapiModels.ApplyPreviewVo applyPreview(BoaUser user, @RequestParam("flowGuid") String flowGuid) {
|
||||
return oapiFlowService.applyPreview(requireSchId(user), flowGuid, user.getId());
|
||||
}
|
||||
|
||||
/** 我提交的办件 */
|
||||
@GetMapping("/my")
|
||||
public BasePageResult<FlowModels.InstanceItem> my(BoaUser user,
|
||||
@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(), appKey, page, size);
|
||||
}
|
||||
|
||||
/** 办件详情(提交人/办理人/本校管理员可见) */
|
||||
@@ -121,6 +150,32 @@ public class FlowUserController {
|
||||
reason == null ? null : String.valueOf(reason), user.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* D14 退回发起人修改(审批人动作;opinion 必填)。
|
||||
* 与普通退回({@code /returnBack},退回上游审批节点续办)分开:本动作让办件进入
|
||||
* {@code 50 退回待修改},等待发起人本人重提。
|
||||
*/
|
||||
@PostMapping("/task/{id}/returnToSubmitter")
|
||||
public void returnToSubmitter(BoaUser user, @PathVariable("id") Long id,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
String opinion = body == null ? null : body.get("opinion");
|
||||
taskService.returnToSubmitter(requireSchId(user), id, opinion, user.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* D14 发起人重提(仅发起人本人、仅 50 退回待修改状态)。
|
||||
* 同一实例、同一申报轮次,从第一级审批节点重新生成待办;服务端复核身份与归属,
|
||||
* 前端不猜状态——只有本接口成功才更新页面。
|
||||
*/
|
||||
@PostMapping("/instance/{id}/resubmit")
|
||||
public void resubmit(BoaUser user, @PathVariable("id") Long id) {
|
||||
FlwInstance instance = instanceService.requireInstance(requireSchId(user), id);
|
||||
if (instance.getSubmitterId() == null || !instance.getSubmitterId().equals(user.getId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅提交人本人可重新提交");
|
||||
}
|
||||
instanceService.resubmitAfterReturn(requireSchId(user), id, user.getId(), null);
|
||||
}
|
||||
|
||||
/** 撤回(提交人;仅尚无任务被办理时) */
|
||||
@PostMapping("/instance/{id}/withdraw")
|
||||
public void withdraw(BoaUser user, @PathVariable("id") Long id) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yunhai.flowcenter.controller.oapi;
|
||||
import com.yunhai.flowcenter.model.FormalApplyModels;
|
||||
import com.yunhai.flowcenter.service.FormalApplyCommandService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController @RequestMapping("/oapi/flow/commands") @RequiredArgsConstructor
|
||||
public class FormalApplyCommandController {
|
||||
private final FormalApplyCommandService commands;
|
||||
@PostMapping
|
||||
public FormalApplyModels.Receipt submit(@RequestParam("schId") Long school,
|
||||
@RequestAttribute("yh.app.auth") String appKey, @RequestBody FormalApplyModels.Request request) {
|
||||
return commands.submit(school, appKey, request);
|
||||
}
|
||||
@GetMapping("/{requestId}")
|
||||
public FormalApplyModels.Receipt query(@RequestParam("schId") Long school,
|
||||
@RequestAttribute("yh.app.auth") String appKey, @PathVariable String requestId,
|
||||
@RequestParam(value="expectedHash", required=false) String expectedHash) {
|
||||
return commands.query(school, appKey, requestId, expectedHash);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
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;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -24,9 +29,18 @@ import java.util.List;
|
||||
* - POST /oapi/flow/instance/{instanceId}/withdraw?schId=&submitterId= → data=null(提交人复核后撤回)
|
||||
* - GET /oapi/flow/progress?instanceId=|bizType=&bizId=&schId= → data={status,steps:[…]}
|
||||
* - 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}
|
||||
* 刻意不提供审批动作(同意/拒绝/退回/移交/催办):那是"人"的决策,必须带登录身份走 CAS 用户面,
|
||||
* 应用间签名不得代签——谁办理谁负责,审计链不能断在应用上。
|
||||
* - 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 必填)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/oapi/flow")
|
||||
@@ -34,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) {
|
||||
@@ -48,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")
|
||||
@@ -84,6 +125,21 @@ public class OapiFlowController {
|
||||
return flowService.diagramUrl(schId, instanceId, flowGuid, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 办件进度嵌入页换址(iframe 场景,token 模式与换图同构):pageUrl 为 flow-center 前端
|
||||
* 绝对地址,子应用直接放 {@code <iframe src>}。用户级可见性(本人/办理人/管理员)由
|
||||
* 子应用后端在调本端点前完成——oapi 层只认 schId,应用签名不替代"谁能看"。
|
||||
*/
|
||||
@GetMapping("/embed/url")
|
||||
public OapiModels.EmbedUrlVo embedUrl(@RequestParam(value = "instanceId", required = false) Long instanceId,
|
||||
@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, taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回(提交人本人;仅尚无任务被办理时)。submitterId 由业务应用按其办件归属传入,
|
||||
* 本端与办件提交人复核——应用签名不替代"谁在撤"的身份证明。
|
||||
@@ -95,6 +151,21 @@ public class OapiFlowController {
|
||||
flowService.withdraw(schId, instanceId, submitterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* D14 发起人重提(同一实例、同一申报轮次,从第一级重新审批)。
|
||||
* 来源归属由 {@code FlowOapiSourceScope} 拦截器核验(非本应用发起的办件直接拒),
|
||||
* 提交人身份由业务应用按其 CAS 会话传入并与办件提交人逐字复核——应用签名不替代"谁在提"。
|
||||
*/
|
||||
@PostMapping("/instance/{instanceId}/resubmit")
|
||||
public void resubmit(@RequestParam("schId") Long schId,
|
||||
@RequestParam("submitterId") Integer submitterId,
|
||||
@PathVariable("instanceId") Long instanceId,
|
||||
@RequestBody(required = false) java.util.Map<String, Object> body) {
|
||||
Object formData = body == null ? null : body.get("formData");
|
||||
flowService.resubmit(schId, instanceId, submitterId,
|
||||
formData == null ? null : String.valueOf(formData));
|
||||
}
|
||||
|
||||
@PostMapping("/callback/reg")
|
||||
public OapiModels.CallbackRegResultVo registerCallback(
|
||||
@RequestParam("schId") Long schId,
|
||||
@@ -102,4 +173,104 @@ public class OapiFlowController {
|
||||
@RequestBody OapiModels.CallbackRegBody body) {
|
||||
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 可选过滤 */
|
||||
@GetMapping("/tasks/todo")
|
||||
public BasePageResult<OapiModels.TodoTaskVo> todoTasks(
|
||||
@RequestParam("schId") Long schId,
|
||||
@RequestParam("assigneeId") Integer assigneeId,
|
||||
@RequestParam(value = "bizType", required = false) String bizType,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
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, com.yunhai.flowcenter.service.FlowOapiSourceScope.current(), 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, com.yunhai.flowcenter.service.FlowOapiSourceScope.current(), page, size);
|
||||
}
|
||||
|
||||
/** 办件分页(oapi):bizType/status 可选过滤 */
|
||||
@GetMapping("/instances")
|
||||
public BasePageResult<OapiModels.InstanceItemVo> instances(
|
||||
@RequestParam("schId") Long schId,
|
||||
@RequestParam(value = "bizType", required = false) String bizType,
|
||||
@RequestParam(value = "status", required = false) Integer status,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "size", required = false) Integer size) {
|
||||
return flowService.pageInstances(schId, bizType, status, page, size);
|
||||
}
|
||||
|
||||
/** 同意(oapi):operatorId 按任务办理人逐字复核,非办理人→业务码拒绝 */
|
||||
@PostMapping("/task/{taskId}/approve")
|
||||
public void approve(@RequestParam("schId") Long schId,
|
||||
@RequestParam("operatorId") Integer operatorId,
|
||||
@RequestParam("operatorName") String operatorName,
|
||||
@PathVariable("taskId") Long taskId,
|
||||
@RequestBody(required = false) OapiModels.HandleBody body) {
|
||||
flowService.approveTask(schId, taskId, operatorId, operatorName,
|
||||
body == null ? null : body.getOpinion(),
|
||||
body == null ? null : body.getEdgeName());
|
||||
}
|
||||
|
||||
/** 拒绝(oapi):opinion 必填,非办理人→业务码拒绝 */
|
||||
@PostMapping("/task/{taskId}/reject")
|
||||
public void reject(@RequestParam("schId") Long schId,
|
||||
@RequestParam("operatorId") Integer operatorId,
|
||||
@RequestParam("operatorName") String operatorName,
|
||||
@PathVariable("taskId") Long taskId,
|
||||
@RequestBody(required = false) OapiModels.HandleBody body) {
|
||||
flowService.rejectTask(schId, taskId, operatorId, operatorName,
|
||||
body == null ? null : body.getOpinion());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.yunhai.flowcenter.controller.open;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.service.DiagramTokenService;
|
||||
import com.yunhai.flowcenter.service.FlowQueryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 免签嵌入页数据端点(/open/flow/embed/**,与图片流 /open/flow/diagram/img 同纪律)。
|
||||
* <p>
|
||||
* {@code <iframe src>} 进不了 /api(CAS)也进不了 /oapi(app-sign):免签资格由 10 分钟
|
||||
* 短时 token 承担(与换图共用 DiagramTokenService 同一 token 池,token 由 app-sign 的
|
||||
* GET /oapi/flow/embed/url 签发)。token 分发点之外,本端点不接受任何业务参数直接定位——
|
||||
* 免签面收窄到"只认 token",定位与可见性全在签发侧(OapiFlowService.embedUrl)完成。
|
||||
* 出参复用管理面办件详情 InstanceDetail 整体(viewerId=null,本人待办字段自然为空),
|
||||
* 不新造 VO——嵌入页信息量与管理面一致,单点维护。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/open/flow/embed")
|
||||
@RequiredArgsConstructor
|
||||
public class OpenEmbedController {
|
||||
|
||||
private final DiagramTokenService tokenService;
|
||||
private final FlowQueryService flowQueryService;
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
|
||||
@GetMapping("/data")
|
||||
public ResponseEntity<?> embedData(@RequestParam(value = "token", required = false) String token) {
|
||||
DiagramTokenService.TokenEntry entry = tokenService.resolve(token);
|
||||
if (entry == null) {
|
||||
return notFound("链接已过期,请重新打开");
|
||||
}
|
||||
// 办件口径收窄:defId 口径的换图 token(flowGuid 预览)没有办件,不在此消费
|
||||
if (entry.getInstanceId() == null) {
|
||||
return notFound("链接已过期,请重新打开");
|
||||
}
|
||||
// token 已收 schId,出数据前再二次比对防漂移(同图片流口径,token 无法跨校探数据)
|
||||
FlwInstance instance = instanceRepository.findById(entry.getInstanceId()).orElse(null);
|
||||
if (instance == null || !entry.getSchId().equals(instance.getSchId())) {
|
||||
return notFound("办件不存在");
|
||||
}
|
||||
FlowModels.InstanceDetail detail =
|
||||
flowQueryService.detail(entry.getSchId(), entry.getInstanceId(), null);
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(org.springframework.http.CacheControl.noStore())
|
||||
.body(detail);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> notFound(String msg) {
|
||||
// 显式 UTF-8:text/plain 缺 charset 时浏览器/客户端按 ISO-8859-1 解码中文必乱码
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.contentType(new MediaType("text", "plain", java.nio.charset.StandardCharsets.UTF_8))
|
||||
.body(msg);
|
||||
}
|
||||
}
|
||||
@@ -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,8 @@
|
||||
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.flowcenter.service.UserBaseDepartmentDirectory;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.base.OpenApiUserClient;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
@@ -11,29 +12,208 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 办理人解析(概览 §六):DEPT_KEY 查显式映射表(零跨服务依赖);USERS 定义直配,
|
||||
* 姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。SUBMITTER_DEPT/FORM_FIELD 二期。
|
||||
* 办理人解析(概览 §六 + C1/C2 收口,2026-10-29)。
|
||||
*
|
||||
* <p>三种主体口径:</p>
|
||||
* <ul>
|
||||
* <li>{@code DEPT}(<b>新配置唯一口径</b>):UserBase 新部门目录(SYSTEM/CUSTOM 类型化引用)
|
||||
* 解析出候选人员,再由节点策略决定一人办理/会签/或签;</li>
|
||||
* <li>{@code USERS}:定义直配固定人员;同样支持会签/或签;</li>
|
||||
* <li>{@code DEPT_KEY}(历史只读):FL 本地 {@code flw_dept}/{@code flw_dept_member}。
|
||||
* 仅为不破坏既有在途定义而保留读取,<b>新配置不再写入、也不是静默兜底</b>:
|
||||
* {@code DEPT} 解析失败照抛,不回退本地科室表。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>审批责任人统一走 D11 资格终审(存在+启用+校域);仅 NOTIFY 抄送接收人保留宽容口径
|
||||
* (解析不出人只记日志继续走——抄送是知会不是审批)。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AssigneeResolver {
|
||||
|
||||
private final FlwAssigneeMapRepository mapRepository;
|
||||
private final FlowDeptAdminService deptAdminService;
|
||||
private final OpenApiUserClient userClient;
|
||||
|
||||
/**
|
||||
* UserBase 部门目录(字段注入:构造被 {@code AssigneeResolverEligibilityTest} 逐位钉住,
|
||||
* 加构造参数会让整棵测试树编译失败——同 {@code OapiFlowService#usageGrantGuard} 先例)。
|
||||
* 未装配时 {@code DEPT} 主体解析按 fail-closed 拒绝,不降级成本地科室。
|
||||
*/
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
public void setDepartmentDirectory(UserBaseDepartmentDirectory directory) {
|
||||
this.departmentDirectory = directory;
|
||||
}
|
||||
|
||||
private UserBaseDepartmentDirectory departmentDirectory;
|
||||
|
||||
public List<AssigneeRef> resolve(Long schId, FlowDefModel.Node node) {
|
||||
FlowDefModel.Assignee a = node.assignee;
|
||||
if (a == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少办理人配置:" + node.id);
|
||||
}
|
||||
List<AssigneeRef> refs = new ArrayList<>();
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
List<FlwAssigneeMap> maps = mapRepository.findEnabled(schId, a.deptKey);
|
||||
for (FlwAssigneeMap m : maps) {
|
||||
if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) {
|
||||
// 部门是授权主体,候选人仍逐人过 D11 资格终审:不合格者剔除并留痕(部门里有停用账号
|
||||
// 不该卡死整条流程),但权威源不可达(UNAUTHORIZED)照抛,不伪装成"无人可用"。
|
||||
List<String> ineligible = new ArrayList<>();
|
||||
for (AssigneeRef ref : resolveDepartmentCandidates(schId, node, a, false)) {
|
||||
try {
|
||||
BoaUser user = requireEligibleAssignee(schId, ref.getUserId());
|
||||
refs.add(new AssigneeRef(ref.getUserId(),
|
||||
user.getName() == null ? ref.getUserName() : user.getName()));
|
||||
} catch (BizException e) {
|
||||
if (e.getErrorCode() == ErrorCode.UNAUTHORIZED) {
|
||||
throw e;
|
||||
}
|
||||
ineligible.add(ref.getUserId() + "=" + e.getMessage());
|
||||
log.warn("[flow-center] 部门成员不具备办理资格,跳过:schId={} node={} userId={} reason={}",
|
||||
schId, node.id, ref.getUserId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
if (refs.isEmpty() && !ineligible.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"节点[" + node.id + "]无可用办理人:部门成员均不具备办理资格("
|
||||
+ String.join(";", ineligible) + ")");
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
// D11:科室启用 ≠ 成员都可办理——逐人走同一资格终审;不合格成员按既有
|
||||
// “只取启用成员”口径剔除(local status 已在 resolveEnabled 过滤),并留日志。
|
||||
List<String> rejected = new ArrayList<>();
|
||||
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
|
||||
try {
|
||||
requireEligibleAssignee(schId, m.getUserId());
|
||||
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
|
||||
} catch (BizException e) {
|
||||
if (e.getErrorCode() == ErrorCode.INTERNAL_SERVER_ERROR) {
|
||||
throw e; // 权威源不可达≠成员不合格,不能降级成“无人可用”
|
||||
}
|
||||
rejected.add(m.getUserId() + "=" + e.getMessage());
|
||||
log.warn("[flow-center] 科室成员资格校验不通过,跳过:schId={} deptKey={} userId={} reason={}",
|
||||
schId, a.deptKey, m.getUserId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
if (refs.isEmpty() && !rejected.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"节点[" + node.id + "]无可用办理人:科室成员均不具备办理资格("
|
||||
+ String.join(";", rejected) + ")");
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||||
if (a.userIds != null) {
|
||||
for (Integer uid : a.userIds) {
|
||||
refs.add(new AssigneeRef(uid, requireEligibleAssigneeName(schId, uid)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "一期不支持办理人类型:" + a.type);
|
||||
}
|
||||
if (refs.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"节点[" + node.id + "]无可用办理人(部门/科室可能已被停用或成员为空)");
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserBase 部门候选解析(C2:部门是审批授权主体,不是"前端筛选人员的临时集合")。
|
||||
*
|
||||
* <p>一个用户可属多个部门,多部门/多引用解析后<b>按用户去重</b>,避免同一人收到两条待办。
|
||||
* 目录不可用照抛(空候选不等于成功)。{@code tolerant=true}(抄送)时把"某部门查不到人"
|
||||
* 记日志跳过,但通道整体不可用仍然拒绝——不能把故障伪装成"没人需要知会"。</p>
|
||||
*/
|
||||
private List<AssigneeRef> resolveDepartmentCandidates(Long schId, FlowDefModel.Node node,
|
||||
FlowDefModel.Assignee a, boolean tolerant) {
|
||||
if (departmentDirectory == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED,
|
||||
"UserBase 部门目录通道未装配,节点[" + node.id + "]不能解析部门办理人");
|
||||
}
|
||||
if (a.deptRefs == null || a.deptRefs.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "节点[" + node.id + "] DEPT 办理人缺少 deptRefs");
|
||||
}
|
||||
List<AssigneeRef> out = new ArrayList<>();
|
||||
Set<Integer> seen = new LinkedHashSet<>();
|
||||
for (FlowDefModel.DeptRef ref : a.deptRefs) {
|
||||
if (ref == null || ref.departmentId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"节点[" + node.id + "] 部门引用缺少 departmentType/departmentId");
|
||||
}
|
||||
List<BoaUser> members;
|
||||
try {
|
||||
members = departmentDirectory.membersAtSchool(schId, ref.departmentType, ref.departmentId,
|
||||
UserBaseDepartmentDirectory.SCOPE_SELECT);
|
||||
} catch (BizException e) {
|
||||
if (tolerant && e.getErrorCode() != ErrorCode.UNAUTHORIZED) {
|
||||
log.warn("[flow-center] 抄送部门解析失败(不阻塞推进):node={} ref={}:{}",
|
||||
node.id, ref.directoryKey(), e.getMessage());
|
||||
continue;
|
||||
}
|
||||
throw new BizException(e.getErrorCode(),
|
||||
"节点[" + node.id + "] 部门 " + ref.directoryKey() + " 解析失败:" + e.getMessage());
|
||||
}
|
||||
if (members == null) {
|
||||
continue;
|
||||
}
|
||||
for (BoaUser user : members) {
|
||||
if (user == null || user.getId() == null || !seen.add(user.getId())) {
|
||||
continue; // 多部门归属同一人:只保留一条
|
||||
}
|
||||
out.add(new AssigneeRef(user.getId(), user.getName() == null ? "" : user.getName()));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 抄送接收人用的部门解析(宽容口径,见 {@link #resolveDepartmentCandidates}) */
|
||||
private List<AssigneeRef> resolveDepartmentReceivers(Long schId, FlowDefModel.Node node,
|
||||
FlowDefModel.Assignee a) {
|
||||
List<AssigneeRef> out = new ArrayList<>();
|
||||
for (AssigneeRef ref : resolveDepartmentCandidates(schId, node, a, true)) {
|
||||
out.add(new AssigneeRef(ref.getUserId(), ref.getUserName().isEmpty()
|
||||
? fetchName(ref.getUserId()) : ref.getUserName()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* D11 固定 USERS 整体预检:流程启动前逐节点核验全部固定审批人,任何一人不存在/停用/跨校
|
||||
* 即整体拒绝——不允许“过滤掉非法人后继续”。运行时建任务仍复用 {@link #resolve} 兜底。
|
||||
*/
|
||||
public void requireEligibleFixedAssignees(Long schId, FlowDefModel model) {
|
||||
for (FlowDefModel.Node node : model.nodes) {
|
||||
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignee == null
|
||||
|| !FlowDefModel.ASSIGNEE_USERS.equals(node.assignee.type)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
resolve(schId, node);
|
||||
} catch (BizException e) {
|
||||
throw new BizException(e.getErrorCode(),
|
||||
"节点[" + node.id + "]固定审批人配置非法:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义),
|
||||
* 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
|
||||
* 绝不能因抄送对象缺失卡住流程推进。D11 的资格终审只约束审批责任人,本方法刻意不校验
|
||||
* 存在/启用/校域,直接复用既有姓名补全容错。
|
||||
*/
|
||||
public List<AssigneeRef> resolveTolerant(Long schId, FlowDefModel.Node node) {
|
||||
FlowDefModel.Assignee a = node.receivers;
|
||||
if (a == null || a.type == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "节点缺少抄送对象配置:" + node.id);
|
||||
}
|
||||
List<AssigneeRef> refs = new ArrayList<>();
|
||||
if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) {
|
||||
refs.addAll(resolveDepartmentReceivers(schId, node, a));
|
||||
} else if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
|
||||
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||||
@@ -43,11 +223,7 @@ public class AssigneeResolver {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "一期不支持办理人类型:" + a.type);
|
||||
}
|
||||
if (refs.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"节点[" + node.id + "]无可用办理人(映射可能已被停用)");
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "不支持抄送对象类型:" + a.type);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
@@ -63,24 +239,52 @@ public class AssigneeResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 移交目标校验(严格口径,与 {@link #fetchName} 的降级容错相反):
|
||||
* 查不到就必须拒绝——否则会建出一条谁在待办里都看不到的僵尸任务。
|
||||
* UserBase 不可达与"人不存在"分别报错,便于排查。
|
||||
* 审批责任人资格终审(D11 唯一权威入口):存在 + 启用(status=1,null fail-closed)
|
||||
* + 属于当前学校。UserBase 不可达与“人不合格”分别报错,前者不可降级为过滤。
|
||||
*/
|
||||
public String requireUserName(Integer userId) {
|
||||
public BoaUser requireEligibleAssignee(Long schId, Integer userId) {
|
||||
BoaUser user = requireValidUser(userId);
|
||||
Long userSchId = user.getSchool() == null || user.getSchool().getId() == null
|
||||
? null : Long.valueOf(user.getSchool().getId());
|
||||
if (schId == null || !schId.equals(userSchId)) {
|
||||
log.warn("[flow-center] 办理人不属于本校,拒绝:userId={} userSchool={} schId={}",
|
||||
userId, userSchId, schId);
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"办理人不属于该学校,不能办理任务:" + userId
|
||||
+ "(所属学校 " + (userSchId == null ? "未知" : userSchId)
|
||||
+ ",要求 " + schId + ")");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 资格终审通过后返回姓名快照;姓名缺失保留空串(既有口径)。 */
|
||||
public String requireEligibleAssigneeName(Long schId, Integer userId) {
|
||||
BoaUser user = requireEligibleAssignee(schId, userId);
|
||||
return user.getName() == null ? "" : user.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* 存在 + 启用子检查。只供本类资格终审复用;外部审批责任人必须走
|
||||
* {@link #requireEligibleAssignee}(含校域),避免漏掉学校范围。
|
||||
*/
|
||||
private BoaUser requireValidUser(Integer userId) {
|
||||
if (userId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象");
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "缺少办理人");
|
||||
}
|
||||
BoaUser user;
|
||||
try {
|
||||
user = userClient.getUserById(String.valueOf(userId));
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 移交对象校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "移交对象校验失败,请稍后重试:" + userId);
|
||||
log.warn("[flow-center] 办理人资格校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "办理人资格校验失败,请稍后重试:" + userId);
|
||||
}
|
||||
if (user == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "移交对象不存在:" + userId);
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理人不存在:" + userId);
|
||||
}
|
||||
return user.getName() == null ? "" : user.getName();
|
||||
if (!Integer.valueOf(1).equals(user.getStatus())) {
|
||||
log.warn("[flow-center] 办理人为禁用/未知状态账号,拒绝:userId={} status={}", userId, user.getStatus());
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "办理人为禁用账号,不能办理任务:" + userId);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,50 +2,45 @@ package com.yunhai.flowcenter.engine;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 分支归属判定:节点属于哪个并行分支(branchKey=PARALLEL_SPLIT 出边目标节点 id),
|
||||
* 主线节点与汇聚点之后属 null。运行时用于推导分支上下文({@link FlowEngine}),
|
||||
* 分支归属判定(2026-09-24 §4.6 升级为路径前缀编码):节点属于哪个并行分支——
|
||||
* 顶层分支键=分支头节点 id(无点号,与存量单层完全一致),内层分支键=「外层键.分支头id」
|
||||
* (如 tb.tc,外层是内层的前缀),主线与汇聚链之后属 null。
|
||||
* 运行时用于推导分支上下文({@link FlowEngine}:建行/到达标记/scope 级放行),
|
||||
* 发布期用于拒绝跨分支连线({@link DefValidator})——两处必须同口径,故提取为静态工具。
|
||||
*
|
||||
* <p>嵌套口径(内层 JOIN 先于外层放行的关键):上溯遇 JOIN 视为透明块——跳到该 JOIN
|
||||
* 匹配 SPLIT 的上游继续走(内层块整体属外层某分支,块的出口链沿用外层上下文)。
|
||||
*/
|
||||
public final class BranchScope {
|
||||
|
||||
/** 上溯递归深度上限:发布期已拦环,此处仅对未校验脏图防御性兜底 */
|
||||
private static final int MAX_WALK_DEPTH = 64;
|
||||
|
||||
private BranchScope() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 沿入边反向 BFS 走到最近的上游 PARALLEL_SPLIT,返回该 split 指向本链的 outEdge.to(即分支键);
|
||||
* 遇 START/PARALLEL_JOIN 边界即停(join 之后属汇聚链,分支键已消耗,返回 null)。
|
||||
* 节点的完整分支键(路径前缀编码)。沿入边反向 BFS 到最近的上游 PARALLEL_SPLIT,
|
||||
* 返回「该 split 自身所在分支键.本链分支头id」;顶层 split 的分支头即键本身(存量兼容)。
|
||||
* 遇 START 或(透明跳越后的)耗尽即停——主线节点返回 null。
|
||||
*/
|
||||
public static String keyOf(FlowDefModel model, String nodeId) {
|
||||
if (model == null || nodeId == null) {
|
||||
return null;
|
||||
}
|
||||
Set<String> visited = new HashSet<>();
|
||||
Deque<String> queue = new ArrayDeque<>();
|
||||
queue.add(nodeId);
|
||||
visited.add(nodeId);
|
||||
while (!queue.isEmpty()) {
|
||||
String cur = queue.poll();
|
||||
for (FlowDefModel.Edge e : model.inEdges(cur)) {
|
||||
FlowDefModel.Node from = model.node(e.from);
|
||||
if (from == null) {
|
||||
continue;
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) {
|
||||
return e.to;
|
||||
}
|
||||
if (FlowDefModel.TYPE_START.equals(from.type)
|
||||
|| FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type)
|
||||
|| !visited.add(e.from)) {
|
||||
continue;
|
||||
}
|
||||
queue.add(e.from);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return keyOf(model, nodeId, new HashMap<String, String>(), new HashMap<String, FlowDefModel.Node>(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* JOIN 的匹配分发节点:沿入边上溯(内层 JOIN 块透明跳越)首个 PARALLEL_SPLIT 即匹配者。
|
||||
* 嵌套结构下内层 JOIN 必然先命中内层 split(内层先放行),外层 JOIN 透过内层块命中外层 split。
|
||||
* 找不到(如主线节点直连 JOIN 的历史脏图)返回 null,由调用方决定兜底口径。
|
||||
*/
|
||||
public static FlowDefModel.Node matchedSplitOf(FlowDefModel model, String joinNodeId) {
|
||||
return matchedSplitOf(model, joinNodeId, new HashMap<String, String>(), new HashMap<String, FlowDefModel.Node>(), 0);
|
||||
}
|
||||
|
||||
/** 两节点是否同属一个分支上下文(含"都属主线"=都属于 null) */
|
||||
@@ -54,4 +49,96 @@ public final class BranchScope {
|
||||
String kb = keyOf(model, b);
|
||||
return ka == null ? kb == null : ka.equals(kb);
|
||||
}
|
||||
|
||||
private static String keyOf(FlowDefModel model, String nodeId,
|
||||
Map<String, String> keyMemo, Map<String, FlowDefModel.Node> splitMemo, int depth) {
|
||||
if (model == null || nodeId == null || depth > MAX_WALK_DEPTH) {
|
||||
return null;
|
||||
}
|
||||
if (keyMemo.containsKey(nodeId)) {
|
||||
return keyMemo.get(nodeId);
|
||||
}
|
||||
keyMemo.put(nodeId, null); // 占位防环:递归回访时按主线(null)处理并终止
|
||||
String result = null;
|
||||
Set<String> visited = new HashSet<>();
|
||||
visited.add(nodeId);
|
||||
Deque<String> queue = new ArrayDeque<>();
|
||||
queue.add(nodeId);
|
||||
while (!queue.isEmpty() && result == null) {
|
||||
String cur = queue.poll();
|
||||
for (FlowDefModel.Edge e : model.inEdges(cur)) {
|
||||
FlowDefModel.Node from = model.node(e.from);
|
||||
if (from == null) {
|
||||
continue;
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) {
|
||||
// 本链分支头确定:再取该 split 自身所在的外层上下文,拼成路径前缀
|
||||
String outer = keyOf(model, from.id, keyMemo, splitMemo, depth + 1);
|
||||
result = outer == null ? e.to : outer + "." + e.to;
|
||||
break;
|
||||
}
|
||||
if (FlowDefModel.TYPE_START.equals(from.type)) {
|
||||
continue;
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type)) {
|
||||
// 内层汇聚块透明:从其匹配 split 的上游继续上溯(块出口链沿用外层上下文)
|
||||
FlowDefModel.Node innerSplit = matchedSplitOf(model, from.id, keyMemo, splitMemo, depth + 1);
|
||||
if (innerSplit != null && visited.add(innerSplit.id)) {
|
||||
queue.add(innerSplit.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (visited.add(e.from)) {
|
||||
queue.add(e.from);
|
||||
}
|
||||
}
|
||||
}
|
||||
keyMemo.put(nodeId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static FlowDefModel.Node matchedSplitOf(FlowDefModel model, String joinNodeId,
|
||||
Map<String, String> keyMemo,
|
||||
Map<String, FlowDefModel.Node> splitMemo, int depth) {
|
||||
if (model == null || joinNodeId == null || depth > MAX_WALK_DEPTH) {
|
||||
return null;
|
||||
}
|
||||
if (splitMemo.containsKey(joinNodeId)) {
|
||||
return splitMemo.get(joinNodeId);
|
||||
}
|
||||
splitMemo.put(joinNodeId, null); // 占位防环
|
||||
FlowDefModel.Node result = null;
|
||||
Set<String> visited = new HashSet<>();
|
||||
visited.add(joinNodeId);
|
||||
Deque<String> queue = new ArrayDeque<>();
|
||||
queue.add(joinNodeId);
|
||||
while (!queue.isEmpty() && result == null) {
|
||||
String cur = queue.poll();
|
||||
for (FlowDefModel.Edge e : model.inEdges(cur)) {
|
||||
FlowDefModel.Node from = model.node(e.from);
|
||||
if (from == null) {
|
||||
continue;
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(from.type)) {
|
||||
result = from;
|
||||
break;
|
||||
}
|
||||
if (FlowDefModel.TYPE_START.equals(from.type)) {
|
||||
continue;
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(from.type)) {
|
||||
FlowDefModel.Node innerSplit = matchedSplitOf(model, from.id, keyMemo, splitMemo, depth + 1);
|
||||
if (innerSplit != null && visited.add(innerSplit.id)) {
|
||||
queue.add(innerSplit.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (visited.add(e.from)) {
|
||||
queue.add(e.from);
|
||||
}
|
||||
}
|
||||
}
|
||||
splitMemo.put(joinNodeId, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
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.entity.FlwRouter;
|
||||
import com.yunhai.flowcenter.notify.UserMsgClient;
|
||||
import com.yunhai.flowcenter.repository.FlwRouterRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 条件路由判断编排(2026-09-24 §4.4):查 flw_router 注册 → 组装判断契约体(variables 透传
|
||||
* form_data_json)→ {@link RouteJudgeClient} 同步调用 → 结果落 flw_route_log(一次一行)。
|
||||
*
|
||||
* <p><b>失败策略(已定,勿改):挂起 + 管理端重跑,禁止配默认出口</b>——超时/非 2xx/
|
||||
* outlet 未命中/注册缺失一律返回 null(route_log 落 outlet=NULL 行 + 告警日志与站内信),
|
||||
* 节点保持在途(currentNodes 不动、办件仍 10),管理端 reroute 重新调判断。
|
||||
* "校长室免审"这类判断失败默认放行是安全事故,宁挂起。
|
||||
* 退回重走 CONDITION 必然重新调判断(单据可能已改):attempt 递增,留痕只增不改。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ConditionRouteService {
|
||||
|
||||
private final FlwRouterRepository routerRepository;
|
||||
private final RouteLogStore routeLogStore;
|
||||
private final RouteJudgeClient judgeClient;
|
||||
private final UserMsgClient userMsgClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 判断并留痕。
|
||||
*
|
||||
* @return 命中的出口名(必属 outlets);失败返回 null(已落 outlet=NULL 留痕行 + 告警)
|
||||
*/
|
||||
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={}",
|
||||
node.routerKey, instance.getId(), node.id);
|
||||
return persistFailure(instance, node, outlets, "routerKey 未注册或已停用:" + node.routerKey);
|
||||
}
|
||||
RouteJudgeClient.JudgeBody body = new RouteJudgeClient.JudgeBody(
|
||||
instance.getSchId(), instance.getId(), instance.getVersion(),
|
||||
instance.getBizType(), instance.getBizId(), instance.getSubmitterId(),
|
||||
node.id, node.name,
|
||||
node.routerKey, outlets, parseVariables(instance.getFormDataJson()));
|
||||
try {
|
||||
RouteJudgeClient.JudgeResult result = judgeClient.call(router, body);
|
||||
if (!outlets.contains(result.outlet)) {
|
||||
log.error("[flow-center] 路由判断返回出口未命中 outlets(挂起):instance={} node={} outlet={} outlets={}",
|
||||
instance.getId(), node.id, result.outlet, outlets);
|
||||
return persistFailure(instance, node, outlets,
|
||||
"返回出口未命中 outlets:" + result.outlet + ";raw=" + 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;
|
||||
} catch (Exception e) {
|
||||
log.error("[flow-center] 路由判断调用失败(挂起):instance={} node={} router={} {}",
|
||||
instance.getId(), node.id, node.routerKey, e.toString());
|
||||
return persistFailure(instance, node, outlets, "调用失败:" + e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<>();
|
||||
for (FlowDefModel.Edge e : model.outEdges(node.id)) {
|
||||
names.add(e.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** form_data_json 原样透传为 variables(SDK 发起参数不加新字段;解析失败=空对象) */
|
||||
private Map<String, Object> parseVariables(String formDataJson) {
|
||||
if (formDataJson == null || formDataJson.trim().isEmpty()) {
|
||||
return new java.util.HashMap<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(formDataJson,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] form_data_json 非 JSON 对象,variables 透传空:{}", e.toString());
|
||||
return new java.util.HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String persistFailure(FlwInstance instance, FlowDefModel.Node node, List<String> outlets,
|
||||
String 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 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(routerKeyLabel);
|
||||
logRow.setOutlet(outlet);
|
||||
logRow.setRawResponse(raw);
|
||||
return routeLogStore.append(logRow).getAttempt();
|
||||
}
|
||||
|
||||
/** 挂起告警本校管理员(复用回调挂起告警接收人配置;UserMsg 不可达只记日志,不影响挂起落定) */
|
||||
private void alertSuspended(FlwInstance instance, FlowDefModel.Node node, List<String> outlets,
|
||||
String error, int attempt) {
|
||||
try {
|
||||
List<Integer> admins = alertUserIds();
|
||||
if (admins.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
userMsgClient.sendUserMsg(instance.getSchId().intValue(), admins, 1,
|
||||
"flow.route", String.valueOf(instance.getId()),
|
||||
"条件路由挂起告警",
|
||||
"办件 " + instance.getId() + "(" + instance.getBizTitle() + ")在节点「"
|
||||
+ (node.name == null || node.name.isEmpty() ? node.id : node.name)
|
||||
+ "」路由判断失败已挂起,请在流程管理端重跑路由。出口候选:"
|
||||
+ outlets + "。最近错误:" + truncate(error),
|
||||
null, null, "route-hang:" + instance.getId() + ":" + node.id + ":" + attempt, null);
|
||||
} catch (Exception e) {
|
||||
log.warn("[flow-center] 路由挂起告警发送失败:instance={} {}", instance.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Integer> alertUserIds() {
|
||||
List<Integer> userIds = new ArrayList<>();
|
||||
String configured = alertUserIdsConfig;
|
||||
if (configured == null || configured.trim().isEmpty()) {
|
||||
return userIds;
|
||||
}
|
||||
for (String s : configured.split(",")) {
|
||||
if (!s.trim().isEmpty()) {
|
||||
userIds.add(Integer.valueOf(s.trim()));
|
||||
}
|
||||
}
|
||||
return userIds;
|
||||
}
|
||||
|
||||
/** 告警接收人(与回调挂起告警同源配置,@Value 注入见构造) */
|
||||
@org.springframework.beans.factory.annotation.Value("${flow-center.callback.alert-user-ids:}")
|
||||
private String alertUserIdsConfig;
|
||||
|
||||
private static String truncate(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
return s.length() > 1000 ? s.substring(0, 1000) : s;
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,31 @@ public final class DefValidator {
|
||||
private DefValidator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param enabledDeptKeys 本校 flw_assignee_map 中启用映射的 dept_key 全集
|
||||
*/
|
||||
/** 兼容旧调用(无路由注册上下文):CONDITION 只校验 routerKey 非空,不查注册表 */
|
||||
public static void validate(FlowDefModel model, Set<String> enabledDeptKeys) {
|
||||
validate(model, enabledDeptKeys, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param enabledDeptKeys 本校 flw_assignee_map 中启用映射的 dept_key 全集(历史 DEPT_KEY 口径)
|
||||
* @param enabledRouterKeys flw_router 启用注册键全集(CONDITION routerKey 白名单;null=跳过注册检查)
|
||||
*/
|
||||
public static void validate(FlowDefModel model, Set<String> enabledDeptKeys, Set<String> enabledRouterKeys) {
|
||||
validate(model, enabledDeptKeys, enabledRouterKeys, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* C1 发布校验(2026-10-29):新增 UserBase 新部门目录白名单。
|
||||
*
|
||||
* @param selectableDeptRefs 本校<b>可被新选中</b>的部门引用键({@code TYPE:id})全集;
|
||||
* null=跳过(单测与不带目录上下文的调用面)。目录不可用时调用方应
|
||||
* 直接抛错阻断发布,而不是传 null 放行——见
|
||||
* {@code FlowDefAdminService#selectableDeptRefs}。
|
||||
*/
|
||||
public static void validate(FlowDefModel model, Set<String> enabledDeptKeys, Set<String> enabledRouterKeys,
|
||||
Set<String> selectableDeptRefs) {
|
||||
List<String> problems = new ArrayList<>();
|
||||
VariableContract.validateDefinition(model, false);
|
||||
|
||||
if (model.nodes == null || model.nodes.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点");
|
||||
@@ -54,7 +74,13 @@ public final class DefValidator {
|
||||
ends++;
|
||||
break;
|
||||
case FlowDefModel.TYPE_APPROVAL:
|
||||
validateApproval(n, enabledDeptKeys, problems);
|
||||
validateApproval(n, enabledDeptKeys, selectableDeptRefs, problems);
|
||||
break;
|
||||
case FlowDefModel.TYPE_NOTIFY:
|
||||
validateNotify(n, enabledDeptKeys, selectableDeptRefs, problems);
|
||||
break;
|
||||
case FlowDefModel.TYPE_CONDITION:
|
||||
validateCondition(model, n, enabledRouterKeys, problems);
|
||||
break;
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT:
|
||||
case FlowDefModel.TYPE_PARALLEL_JOIN:
|
||||
@@ -88,7 +114,8 @@ public final class DefValidator {
|
||||
int out = model.outEdges(n.id).size();
|
||||
int in = model.inEdges(n.id).size();
|
||||
if (FlowDefModel.TYPE_START.equals(n.type)
|
||||
|| FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type)) {
|
||||
|| FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type)
|
||||
|| FlowDefModel.TYPE_NOTIFY.equals(n.type)) {
|
||||
if (out != 1) {
|
||||
problems.add(nodeLabel(n) + " 出边必须恰为 1 条(当前 " + out + ")");
|
||||
}
|
||||
@@ -100,6 +127,14 @@ public final class DefValidator {
|
||||
validateOutlets(model, n, problems);
|
||||
}
|
||||
}
|
||||
// CONDITION 互斥分支:出边≥2(一出口无判断意义),出口命名/同分支上下文复用多出口口径
|
||||
if (FlowDefModel.TYPE_CONDITION.equals(n.type)) {
|
||||
if (out < 2) {
|
||||
problems.add(nodeLabel(n) + " 出边至少 2 条(当前 " + out + "):条件分支必须给出可判断的出口集合");
|
||||
} else {
|
||||
validateOutlets(model, n, problems);
|
||||
}
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type) && out < 1) {
|
||||
problems.add(nodeLabel(n) + " 出边至少 1 条");
|
||||
}
|
||||
@@ -114,11 +149,43 @@ public final class DefValidator {
|
||||
problems.add(nodeLabel(n) + " waitFor 一期仅支持 ALL");
|
||||
}
|
||||
}
|
||||
// 汇聚点必须有上游分发(分支进度行由 split 创建)
|
||||
boolean hasSplit = model.nodes.stream().anyMatch(n -> FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type));
|
||||
boolean hasJoin = model.nodes.stream().anyMatch(n -> FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type));
|
||||
if (hasJoin && !hasSplit) {
|
||||
problems.add("存在 PARALLEL_JOIN 但无 PARALLEL_SPLIT");
|
||||
// 嵌套并行配对与键长守卫(2026-09-24 §4.6):
|
||||
// ① 每个 JOIN 必须上溯找到唯一匹配 SPLIT,且每个 SPLIT 恰被一个 JOIN 匹配(分支全部汇入同一汇聚点);
|
||||
// ② SPLIT 的出边目标不得是 JOIN(分支头=汇聚点是无意义结构,键归属会错乱);
|
||||
// ③ 分支键(路径前缀)不得超 64——flw_branch.branch_key 列宽。
|
||||
java.util.Map<String, Integer> splitMatchCount = new java.util.HashMap<>();
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(n.type)) {
|
||||
FlowDefModel.Node split = BranchScope.matchedSplitOf(model, n.id);
|
||||
if (split == null) {
|
||||
problems.add(nodeLabel(n) + " 找不到匹配的分发节点(PARALLEL_SPLIT)");
|
||||
} else {
|
||||
splitMatchCount.merge(split.id, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type)) {
|
||||
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(model.node(e.to) == null ? "" : model.node(e.to).type)) {
|
||||
problems.add(nodeLabel(n) + " 的分支不能直接是汇聚点(" + e.to + ")");
|
||||
continue;
|
||||
}
|
||||
String key = BranchScope.keyOf(model, e.to);
|
||||
if (key != null && key.length() > 64) {
|
||||
problems.add(nodeLabel(n) + " 分支键超长(" + key.length() + " 字符 > 列宽 64):"
|
||||
+ "缩短节点 id 或减少并行嵌套层级(" + key + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (FlowDefModel.TYPE_PARALLEL_SPLIT.equals(n.type)) {
|
||||
int joins = splitMatchCount.getOrDefault(n.id, 0);
|
||||
if (joins == 0) {
|
||||
problems.add(nodeLabel(n) + " 的分支未汇聚到唯一 PARALLEL_JOIN(内层 JOIN 必须先于外层配对)");
|
||||
} else if (joins > 1) {
|
||||
problems.add(nodeLabel(n) + " 的分支汇聚到了 " + joins + " 个 PARALLEL_JOIN(不支持多汇聚点)");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 可达性(无孤儿)+ 无环:从 start 出发 DFS,环检测用着色
|
||||
if (startNode != null) {
|
||||
@@ -164,24 +231,161 @@ public final class DefValidator {
|
||||
}
|
||||
}
|
||||
|
||||
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 配置");
|
||||
/**
|
||||
* NOTIFY 抄送节点校验:receivers 必填且类型合法(复用 Assignee 结构),DEPT_KEY 须有启用映射
|
||||
* ——运行时解析是宽容口径(无人只记日志不阻塞),发布期拦一道防"配错科室静默抄送空"。
|
||||
*/
|
||||
private static void validateNotify(FlowDefModel.Node n, Set<String> enabledDeptKeys,
|
||||
Set<String> selectableDeptRefs, List<String> problems) {
|
||||
FlowDefModel.Assignee r = n.receivers;
|
||||
if (r == null || r.type == null) {
|
||||
problems.add(nodeLabel(n) + " 缺少抄送对象(receivers)配置");
|
||||
return;
|
||||
}
|
||||
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
validateSubject(n, r, "抄送对象", enabledDeptKeys, selectableDeptRefs, problems);
|
||||
}
|
||||
|
||||
/**
|
||||
* CONDITION 条件分支节点校验(2026-09-24 §4):SERVICE 模式 routerKey 必填且已注册启用——
|
||||
* 运行时才发现无效只会挂起,发布期就拦;出边=互斥分支,出口名即判断接口的 outlets 契约
|
||||
* (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
|
||||
*
|
||||
* <p>EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦
|
||||
* 新契约的变量引用/类型/值域由 VariableContract 统一检查;历史无声明版本保持兼容。
|
||||
*/
|
||||
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())) {
|
||||
problems.add(nodeLabel(n) + " 的 routerKey=" + n.routerKey
|
||||
+ " 未注册或已停用(流程管理端 > 条件路由维护)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表达式内置变量白名单(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);
|
||||
|
||||
/**
|
||||
* 审批主体校验(C2:部门与人员是两种主体,部门不能降级成"前端筛选出来的一堆人")。
|
||||
* DEPT=UserBase 新部门目录的类型化引用(新配置唯一口径);DEPT_KEY=历史本地科室(只读兼容);
|
||||
* USERS=固定人员。会签/或签由 {@code countersign} 独立决定,对 DEPT 与 USERS 同样有效。
|
||||
*/
|
||||
private static void validateSubject(FlowDefModel.Node n, FlowDefModel.Assignee a, String what,
|
||||
Set<String> enabledDeptKeys, Set<String> selectableDeptRefs,
|
||||
List<String> problems) {
|
||||
if (FlowDefModel.ASSIGNEE_DEPT.equals(a.type)) {
|
||||
if (a.deptRefs == null || a.deptRefs.isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " DEPT " + what + "缺少 deptRefs");
|
||||
return;
|
||||
}
|
||||
Set<String> duplicates = new HashSet<>();
|
||||
for (FlowDefModel.DeptRef ref : a.deptRefs) {
|
||||
if (ref == null || ref.departmentId == null || ref.departmentId <= 0) {
|
||||
problems.add(nodeLabel(n) + " DEPT " + what + "引用缺少 departmentId");
|
||||
continue;
|
||||
}
|
||||
String type;
|
||||
try {
|
||||
type = ref.departmentType == null ? "" : ref.departmentType.trim().toUpperCase();
|
||||
} catch (RuntimeException invalid) {
|
||||
problems.add(nodeLabel(n) + " DEPT " + what + "来源类型非法:" + ref.departmentType);
|
||||
continue;
|
||||
}
|
||||
if (!"SYSTEM".equals(type) && !"CUSTOM".equals(type)) {
|
||||
problems.add(nodeLabel(n) + " DEPT " + what + "来源类型必须是 SYSTEM/CUSTOM:"
|
||||
+ ref.departmentType);
|
||||
continue;
|
||||
}
|
||||
String key = type + ":" + ref.departmentId;
|
||||
if (!duplicates.add(key)) {
|
||||
problems.add(nodeLabel(n) + " " + what + "重复引用同一部门:" + key);
|
||||
}
|
||||
if (selectableDeptRefs != null && !selectableDeptRefs.contains(key)) {
|
||||
problems.add(nodeLabel(n) + " 的部门 " + key + " 不在本校可选部门目录内(已停用/已删除/跨校)");
|
||||
}
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
|
||||
if (a.deptKey == null || a.deptKey.trim().isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " DEPT_KEY 办理人缺少 deptKey");
|
||||
problems.add(nodeLabel(n) + " DEPT_KEY " + what + "缺少 deptKey");
|
||||
} else if (enabledDeptKeys != null && !enabledDeptKeys.contains(a.deptKey)) {
|
||||
problems.add(nodeLabel(n) + " 的 DEPT_KEY=" + a.deptKey + " 无启用中的办理人映射");
|
||||
}
|
||||
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
|
||||
if (a.userIds == null || a.userIds.isEmpty()) {
|
||||
problems.add(nodeLabel(n) + " USERS 办理人缺少 userIds");
|
||||
problems.add(nodeLabel(n) + " USERS " + what + "缺少 userIds");
|
||||
}
|
||||
} else {
|
||||
problems.add(nodeLabel(n) + " 未知办理人类型:" + a.type + "(一期支持 DEPT_KEY/USERS)");
|
||||
problems.add(nodeLabel(n) + " 未知" + what + "类型:" + a.type + "(支持 DEPT/DEPT_KEY/USERS)");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateApproval(FlowDefModel.Node n, Set<String> enabledDeptKeys,
|
||||
Set<String> selectableDeptRefs, List<String> problems) {
|
||||
FlowDefModel.Assignee a = n.assignee;
|
||||
if (a == null || a.type == null) {
|
||||
problems.add(nodeLabel(n) + " 缺少 assignee 配置");
|
||||
} else {
|
||||
validateSubject(n, a, "办理人", enabledDeptKeys, selectableDeptRefs, problems);
|
||||
}
|
||||
// C3 展示规则:主体与显示方式分开配置。按部门展示必须有独立名称(不靠遍历人员部门猜)
|
||||
if (n.displayMode != null && !FlowDefModel.DISPLAY_MODE_USER.equals(n.displayMode)
|
||||
&& !FlowDefModel.DISPLAY_MODE_DEPT.equals(n.displayMode)) {
|
||||
problems.add(nodeLabel(n) + " displayMode 取值非法:" + n.displayMode + "(支持 USER/DEPT)");
|
||||
}
|
||||
if (n.displayByDept() && (n.displayLabel == null || n.displayLabel.trim().isEmpty())) {
|
||||
problems.add(nodeLabel(n) + " 选择「显示部门」时必须填写展示名称");
|
||||
}
|
||||
if (n.countersign != null && !FlowDefModel.COUNTERSIGN_ALL.equals(n.countersign)
|
||||
&& !FlowDefModel.COUNTERSIGN_ANY.equals(n.countersign)) {
|
||||
@@ -190,6 +394,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();
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,201 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.entity.*;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import com.yunhai.flowcenter.repository.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
|
||||
/**
|
||||
* 节点放行推进(AFTER_COMMIT 监听器经代理调用本 bean,REQUIRES_NEW 生效——
|
||||
* 事务已提交后的同步监听阶段没有活动事务,@Modifying 批量语句必须有新事务承载):
|
||||
* 该节点待办清零且办件在途才推进;并发冲突由 flw_instance.version 乐观锁暴露,监听器层负责重试。
|
||||
*/
|
||||
@Slf4j
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/** D09: durable activation membership, serialized last-handler payload, and revision-scoped settlement. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FlowAdvanceService {
|
||||
|
||||
private final FlwInstanceRepository instanceRepository;
|
||||
private final FlwDefRepository defRepository;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlowEngine flowEngine;
|
||||
private final FlwAdvanceIntentRepository intentRepository;
|
||||
@PersistenceContext private EntityManager entityManager;
|
||||
|
||||
/** The listener groups the existing totalTasks TASK_CREATED events within their creation transaction. */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public Long registerTask(FlowEvent event, Long activationIntentId) {
|
||||
String nodeId = (String) event.getData().get("nodeId");
|
||||
FlwAdvanceIntent intent;
|
||||
if (activationIntentId == null) {
|
||||
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElseThrow(IllegalStateException::new);
|
||||
intent = new FlwAdvanceIntent();
|
||||
intent.setInstanceId(instance.getId());intent.setNodeId(nodeId);
|
||||
intent.setActivationTaskId(event.getTaskId());
|
||||
intent.setActivationDefId(instance.getDefId());intent.setActivationDefVersion(instance.getDefVersion());
|
||||
intent.setActivationApplyRound(instance.getApplyRound());
|
||||
intent.setStatus(FlwAdvanceIntent.STATE_DONE); // Registration only; no advance work before handling.
|
||||
intent.setNextRetryAt(LocalDateTime.now());
|
||||
} else {
|
||||
intent = intentRepository.findById(activationIntentId).orElseThrow(IllegalStateException::new);
|
||||
if (!Objects.equals(intent.getInstanceId(), event.getInstanceId()) || !Objects.equals(intent.getNodeId(), nodeId)) {
|
||||
throw new IllegalStateException("Activation registration identity mismatch");
|
||||
}
|
||||
}
|
||||
intent.addTask(event.getTaskId());
|
||||
return intentRepository.save(intent).getId();
|
||||
}
|
||||
|
||||
/** TRANSFERRED lacks the parent task ID. Accept only a uniquely proven parent activation, never guess by max ID. */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void registerTransfer(FlowEvent event) {
|
||||
String nodeId = (String) event.getData().get("fromNodeId");
|
||||
List<FlwAdvanceIntent> generations = intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId);
|
||||
Set<Long> parents = new HashSet<>();
|
||||
List<Long> candidates = taskRepository.findTransferParentCandidates(event.getTaskId(), event.getOperatorId());
|
||||
for (Long taskId : candidates) {
|
||||
FlwAdvanceIntent parent = memberOf(generations, taskId);
|
||||
if (parent == null) throw new IllegalStateException("D09 transfer parent requires audited activation backfill");
|
||||
parents.add(parent.getId());
|
||||
}
|
||||
if (parents.size() != 1) throw new IllegalStateException("D09 ambiguous transfer activation; parent identity is required");
|
||||
FlwAdvanceIntent parent = lockIntent(parents.iterator().next());
|
||||
parent.addTask(event.getTaskId());
|
||||
intentRepository.save(parent);
|
||||
}
|
||||
|
||||
/** Called synchronously in the task transaction, after claimAndFinish. The head lock orders commits, not clocks/IDs. */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public void recordHandled(FlowEvent event) {
|
||||
String nodeId = (String) event.getData().get("nodeId");
|
||||
FlwAdvanceIntent candidate = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId), event.getTaskId());
|
||||
if (candidate == null) throw new IllegalStateException("D09 task has no proven activation; audited backfill required");
|
||||
FlwAdvanceIntent intent = lockIntent(candidate.getId());
|
||||
if (intent.handledTask(event.getTaskId())) return; // Duplicate/late publication cannot rewind the last handler.
|
||||
intent.addHandledTask(event.getTaskId());intent.setTriggerTaskId(event.getTaskId());
|
||||
intent.setEdgeName((String) event.getData().get("edgeName"));
|
||||
intent.setAssigneeId((Integer) event.getData().get("assigneeId"));
|
||||
intent.setAssigneeName((String) event.getData().get("assigneeName"));
|
||||
intent.setStatus(FlwAdvanceIntent.STATE_PENDING);intent.setAttempts(0);
|
||||
intent.setNextRetryAt(LocalDateTime.now());intent.setLastError(null);
|
||||
intentRepository.save(intent);
|
||||
}
|
||||
|
||||
public static final class IntentRef {
|
||||
public final Long id;
|
||||
public final Long revision;
|
||||
public IntentRef(Long id, Long revision) { this.id = id; this.revision = revision; }
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
|
||||
public IntentRef pendingForTask(Long instanceId, String nodeId, Long taskId) {
|
||||
FlwAdvanceIntent intent = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(instanceId, nodeId), taskId);
|
||||
return intent == null || !FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) ? null
|
||||
: new IntentRef(intent.getId(), intent.getRevision());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nodeId 待放行节点
|
||||
* @param edgeName 多出口节点所选出口(办理事件携带);可空——兜底从该节点
|
||||
* 最近一条已同意任务的 edge_name 列重放(推进在 AFTER_COMMIT 新事务重查,
|
||||
* 事件传参只是快路径,落库的值才是权威)
|
||||
* @return 是否实际推进了
|
||||
* One transaction owns validation, engine effects and settlement. RC sees the last committed task status;
|
||||
* the head lock serializes against last-handler refresh, and instance @Version fences reactivation writes.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean tryAdvance(Long instanceId, String nodeId, String edgeName) {
|
||||
FlwInstance instance = instanceRepository.findById(instanceId).orElse(null);
|
||||
if (instance == null
|
||||
|| instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
|
||||
public boolean tryAdvance(Long intentId, Long observedRevision) {
|
||||
FlwAdvanceIntent intent = lockIntent(intentId);
|
||||
if (!ownsRevision(intent, observedRevision)) return false;
|
||||
FlwAdvanceIntent latest = intentRepository.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(
|
||||
intent.getInstanceId(), intent.getNodeId()).orElse(null);
|
||||
if (latest != null && !Objects.equals(latest.getId(), intent.getId())) {
|
||||
done(intent, "D09_STALE_ACTIVATION: successor=" + latest.getId());
|
||||
handoff(latest.getId());
|
||||
return false;
|
||||
}
|
||||
// 已推进过的节点不再放行(currentNodes 由引擎在放行时移除,乐观锁保证互斥)
|
||||
String current = instance.getCurrentNodes();
|
||||
boolean active = current != null && java.util.Arrays.stream(current.split(","))
|
||||
.anyMatch(nodeId::equals);
|
||||
if (!active) {
|
||||
if (intent.getActivationTaskId() == null || !intent.containsTask(intent.getActivationTaskId())
|
||||
|| intent.getTriggerTaskId() == null || !intent.handledTask(intent.getTriggerTaskId())) {
|
||||
hold(intent, "D09_UNANCHORED: audited cohort/last-handler backfill required; not executed");
|
||||
return false;
|
||||
}
|
||||
long pending = taskRepository.findByInstanceIdAndStatus(instanceId,
|
||||
FlwEnums.TaskStatus.PENDING.getCode()).stream()
|
||||
.filter(t -> nodeId.equals(t.getNodeId()))
|
||||
.count();
|
||||
if (pending > 0) {
|
||||
return false;
|
||||
FlwInstance instance = instanceRepository.findById(intent.getInstanceId()).orElse(null);
|
||||
if (instance == null || instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()
|
||||
|| !active(instance, intent.getNodeId())) {
|
||||
done(intent, null);return false;
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
|
||||
if (def == null) {
|
||||
log.error("[flow-center] 办件定义缺失,推进中止:instance={} defId={}",
|
||||
instanceId, instance.getDefId());
|
||||
return false;
|
||||
if (!Objects.equals(instance.getDefId(), intent.getActivationDefId())
|
||||
|| !Objects.equals(instance.getDefVersion(), intent.getActivationDefVersion())
|
||||
|| !Objects.equals(instance.getApplyRound(), intent.getActivationApplyRound())) {
|
||||
hold(intent, "D09_IDENTITY_MISMATCH: no proven successor; audited backfill required");return false;
|
||||
}
|
||||
String chosen = edgeName;
|
||||
if (chosen == null) {
|
||||
chosen = taskRepository.findByInstanceIdAndStatus(instanceId,
|
||||
FlwEnums.TaskStatus.APPROVED.getCode()).stream()
|
||||
.filter(t -> nodeId.equals(t.getNodeId()) && t.getEdgeName() != null)
|
||||
.max(java.util.Comparator.comparing(
|
||||
FlwTask::getHandleTime, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder())))
|
||||
.map(FlwTask::getEdgeName)
|
||||
.orElse(null);
|
||||
boolean pending = taskRepository.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode())
|
||||
.stream().anyMatch(t -> intent.getNodeId().equals(t.getNodeId()));
|
||||
if (pending) {
|
||||
// A future handler holds/obtains this same head lock and must re-arm it in its task transaction.
|
||||
done(intent, null);return false;
|
||||
}
|
||||
flowEngine.continueAdvance(instance, def, nodeId, chosen);
|
||||
instanceRepository.save(instance);
|
||||
FlwTask trigger = taskRepository.findById(intent.getTriggerTaskId()).orElse(null);
|
||||
if (trigger == null || !intent.containsTask(trigger.getId())
|
||||
|| !Objects.equals(trigger.getInstanceId(), instance.getId())
|
||||
|| !Objects.equals(trigger.getNodeId(), intent.getNodeId())
|
||||
|| !Integer.valueOf(FlwEnums.TaskStatus.APPROVED.getCode()).equals(trigger.getStatus())) {
|
||||
hold(intent, "D09_INVALID_LAST_HANDLER: not executed");return false;
|
||||
}
|
||||
FlwDef def = defRepository.findById(instance.getDefId()).orElseThrow(() -> new IllegalStateException("Missing activation definition"));
|
||||
flowEngine.continueAdvance(instance, def, intent.getNodeId(), trigger.getEdgeName(),
|
||||
FlowOperator.of(trigger.getAssigneeId(), trigger.getAssigneeName()));
|
||||
if (active(instance, intent.getNodeId())) throw new IllegalStateException("Activation did not advance; retaining recoverable intent");
|
||||
instanceRepository.saveAndFlush(instance); // An optimistic conflict rolls back engine writes AND settlement.
|
||||
done(intent, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Never merge a worker's detached row. A refreshed revision owns its own retry budget. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void defer(Long intentId, Long observedRevision, String error) {
|
||||
FlwAdvanceIntent intent = lockIntent(intentId);
|
||||
if (!ownsRevision(intent, observedRevision)) return;
|
||||
intent.setAttempts(intent.getAttempts() + 1);intent.setLastError(truncate(error));
|
||||
if (intent.getAttempts() >= 6) intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED);
|
||||
else intent.setNextRetryAt(LocalDateTime.now().plusSeconds(Math.min(1800, 30L * (1L << (intent.getAttempts() - 1)))));
|
||||
intentRepository.save(intent);
|
||||
}
|
||||
|
||||
private FlwAdvanceIntent lockIntent(Long id) {
|
||||
// Queries in a handling transaction may already have loaded a stale RR snapshot into the identity map.
|
||||
// A lock upgrade on that managed row checks its obsolete @Version before refresh can run.
|
||||
// Flush our own changes, detach ONLY this row, then load a new managed row with a locking current read.
|
||||
entityManager.flush();
|
||||
FlwAdvanceIntent cached = entityManager.find(FlwAdvanceIntent.class, id);
|
||||
if (cached != null) entityManager.detach(cached);
|
||||
return intentRepository.findByIdForUpdate(id).orElse(null);
|
||||
}
|
||||
|
||||
private void handoff(Long successorId) {
|
||||
FlwAdvanceIntent successor = lockIntent(successorId);
|
||||
// A stale worker must not consume its successor. Re-arm a previously waiting head, retaining its own payload.
|
||||
if (successor != null && successor.getTriggerTaskId() != null && FlwAdvanceIntent.STATE_DONE.equals(successor.getStatus())) {
|
||||
successor.setStatus(FlwAdvanceIntent.STATE_PENDING);successor.setAttempts(0);
|
||||
successor.setNextRetryAt(LocalDateTime.now());successor.setLastError(null);intentRepository.save(successor);
|
||||
}
|
||||
}
|
||||
|
||||
private static FlwAdvanceIntent memberOf(List<FlwAdvanceIntent> intents, Long taskId) {
|
||||
FlwAdvanceIntent found = null;
|
||||
for (FlwAdvanceIntent intent : intents) if (intent.containsTask(taskId)) {
|
||||
if (found != null && !Objects.equals(found.getId(), intent.getId())) throw new IllegalStateException("Task belongs to multiple activations");
|
||||
found = intent;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
private static boolean ownsRevision(FlwAdvanceIntent intent, Long revision) {
|
||||
return intent != null && FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) && Objects.equals(intent.getRevision(), revision);
|
||||
}
|
||||
private static boolean active(FlwInstance instance, String nodeId) {
|
||||
return instance.getCurrentNodes() != null && Arrays.asList(instance.getCurrentNodes().split(",")).contains(nodeId);
|
||||
}
|
||||
private void done(FlwAdvanceIntent intent, String reason) {
|
||||
intent.setStatus(FlwAdvanceIntent.STATE_DONE);intent.setLastError(reason);intentRepository.save(intent);
|
||||
}
|
||||
private void hold(FlwAdvanceIntent intent, String reason) {
|
||||
intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED);intent.setLastError(reason);intentRepository.save(intent);
|
||||
}
|
||||
private static String truncate(String s) { return s == null || s.length() <= 255 ? s : s.substring(0,255); }
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@ public class FlowDefModel {
|
||||
public static final String TYPE_APPROVAL = "APPROVAL";
|
||||
public static final String TYPE_PARALLEL_SPLIT = "PARALLEL_SPLIT";
|
||||
public static final String TYPE_PARALLEL_JOIN = "PARALLEL_JOIN";
|
||||
/** 抄送节点(2026-09-23 二期第一批):瞬时放行,逐人落 flw_cc + CC_SENT,不建任务不阻塞 */
|
||||
public static final String TYPE_NOTIFY = "NOTIFY";
|
||||
/**
|
||||
* 条件分支节点(2026-09-24 二期第二批 §4):激活时同步调外部判断服务(flw_router 注册,
|
||||
* routerKey 寻址)按返回出口名选路;失败挂起待管理端重跑,禁止配默认出口。
|
||||
* 出边=互斥分支,name 必填且唯一(出口名即判断接口的 outlets 契约)。
|
||||
*/
|
||||
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";
|
||||
@@ -32,6 +45,22 @@ public class FlowDefModel {
|
||||
/** 办理人类型(概览 §六:一期 DEPT_KEY/USERS;SUBMITTER_DEPT/FORM_FIELD 二期) */
|
||||
public static final String ASSIGNEE_DEPT_KEY = "DEPT_KEY";
|
||||
public static final String ASSIGNEE_USERS = "USERS";
|
||||
/**
|
||||
* UserBase 新部门目录引用(C1,2026-10-29)。{@link Assignee#deptRefs} 承载,
|
||||
* 学校范围由定义行的 schId 租户键决定(不由客户端提交)。
|
||||
* <b>新配置一律写本类型</b>;{@link #ASSIGNEE_DEPT_KEY} 只作历史定义的读取口径,
|
||||
* 不再作为新流程的权威来源,也不存在"新目录失败就静默退回本地科室"的双权威兜底。
|
||||
*/
|
||||
public static final String ASSIGNEE_DEPT = "DEPT";
|
||||
|
||||
/** 节点展示方式:按人员(缺省) */
|
||||
public static final String DISPLAY_MODE_USER = "USER";
|
||||
/** 节点展示方式:按部门(独立展示名,见 {@link Node#displayLabel}) */
|
||||
public static final String DISPLAY_MODE_DEPT = "DEPT";
|
||||
|
||||
/** 发起时指派选择模式:SINGLE=恰 1 人;MULTI=多选(min~max) */
|
||||
public static final String ASSIGN_MODE_SINGLE = "SINGLE";
|
||||
public static final String ASSIGN_MODE_MULTI = "MULTI";
|
||||
|
||||
public String code;
|
||||
public String name;
|
||||
@@ -39,6 +68,18 @@ public class FlowDefModel {
|
||||
public List<Node> nodes;
|
||||
public List<Edge> edges;
|
||||
public Settings settings;
|
||||
/** null = 历史无契约版本;新发布必须显式声明(无业务变量时为 [])。 */
|
||||
public List<Variable> variables;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Variable {
|
||||
public String key;
|
||||
/** string/number/boolean/date/datetime/money/stringArray/fileArray/userArray */
|
||||
public String type;
|
||||
public Boolean required;
|
||||
@com.fasterxml.jackson.annotation.JsonProperty("enum")
|
||||
public List<String> enumValues;
|
||||
}
|
||||
/**
|
||||
* 画布坐标(nodeId → 左上角),仅前端设计器/查看器复用;引擎不消费。
|
||||
* 缺失时前端自行做自动布局,故可选、可空。
|
||||
@@ -51,20 +92,88 @@ public class FlowDefModel {
|
||||
public String type;
|
||||
public String name;
|
||||
public Assignee assignee;
|
||||
/** NOTIFY 抄送接收人(复用 Assignee 结构:DEPT_KEY/USERS 两档,解析走 AssigneeResolver) */
|
||||
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;
|
||||
/**
|
||||
* 节点展示方式(C3,2026-10-29,可选):{@link #DISPLAY_MODE_USER}=按人员(缺省,
|
||||
* 含所有历史定义)、{@link #DISPLAY_MODE_DEPT}=按部门。
|
||||
* 与"审批主体"是两件事:本字段只改显示口径,不改变候选人范围与会签/或签规则。
|
||||
*/
|
||||
public String displayMode;
|
||||
/** 展示部门时的独立名称;主体是部门时前端默认填入所选部门名,管理员可改;主体是人员时必须手工填 */
|
||||
public String displayLabel;
|
||||
|
||||
/** 展示口径是否为"按部门"(null/其它值一律按人员,旧 contentJson 零行为变化) */
|
||||
public boolean displayByDept() {
|
||||
return DISPLAY_MODE_DEPT.equals(displayMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 落进任务/抄送/轨迹的节点名(C3 收口):按部门展示且有独立名称时用该名称,
|
||||
* 否则沿用节点名——当前节点与历史记录同口径,消除"配置 A、显示 B"的错位。
|
||||
*/
|
||||
public String displayName() {
|
||||
if (displayByDept() && displayLabel != null && !displayLabel.trim().isEmpty()) {
|
||||
return displayLabel.trim();
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UserBase 部门引用项(C1):类型化三要素中的两要素落定义 JSON,学校范围取定义/实例的 schId。
|
||||
* {@code departmentType}=SYSTEM/CUSTOM 是<b>必带消歧位</b>——同值 id 在两类目录里不等价,
|
||||
* 不再把 UserBase 组 ID 当 FL 本地自增 deptKey 用。
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class DeptRef {
|
||||
/** SYSTEM | CUSTOM */
|
||||
public String departmentType;
|
||||
/** SYSTEM=sys_group.id;CUSTOM=ub_custom_department.id */
|
||||
public Integer departmentId;
|
||||
|
||||
/** 目录消歧键:{@code TYPE:id} */
|
||||
public String directoryKey() {
|
||||
return (departmentType == null ? "" : departmentType.trim().toUpperCase())
|
||||
+ ":" + departmentId;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Assignee {
|
||||
/** DEPT_KEY | USERS */
|
||||
/** DEPT_KEY(历史只读)| USERS | DEPT(UserBase 新部门目录,新配置唯一写入口径) */
|
||||
public String type;
|
||||
public String deptKey;
|
||||
public List<Integer> userIds;
|
||||
/** {@link #type}=DEPT 时的部门引用集合(可多选;一个用户可属多个部门,解析后按人去重) */
|
||||
public List<DeptRef> deptRefs;
|
||||
}
|
||||
|
||||
/** 发起时指派策略:mode 必填(容错 null=MULTI);min/max 可空(缺省 1/候选全量数);required 仅子应用 UI 用 */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class AssignPolicy {
|
||||
/** SINGLE | MULTI */
|
||||
public String mode;
|
||||
public Integer min;
|
||||
public Integer max;
|
||||
public Boolean required;
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
||||
@@ -2,11 +2,13 @@ package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.flowcenter.entity.FlwBranch;
|
||||
import com.yunhai.flowcenter.entity.FlwCc;
|
||||
import com.yunhai.flowcenter.entity.FlwDef;
|
||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||
import com.yunhai.flowcenter.entity.FlwTask;
|
||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||
import com.yunhai.flowcenter.repository.FlwBranchRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwCcRepository;
|
||||
import com.yunhai.flowcenter.repository.FlwTaskRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -35,14 +37,19 @@ public class FlowEngine {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FlwTaskRepository taskRepository;
|
||||
private final FlwBranchRepository branchRepository;
|
||||
private final FlwCcRepository ccRepository;
|
||||
private final AssigneeResolver assigneeResolver;
|
||||
private final ConditionRouteService conditionRouteService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/** 发起后首推:从 START 激活(instance 已建好、status=10) */
|
||||
/** 发起后首推:从 START 激活(instance 已建好、status=10)。operator=null:TASK_CREATED/COMPLETED 的操作人回退发起人 */
|
||||
public void startAdvance(FlwInstance instance, FlwDef def) {
|
||||
FlowDefModel model = parseModel(def);
|
||||
// D11:整份定义的固定 USERS 先整体预检,非法人不得等到后续节点才暴露(会留下卡单)。
|
||||
// 校验失败抛业务异常,由发起事务整体回滚(零实例零任务)。
|
||||
assigneeResolver.requireEligibleFixedAssignees(instance.getSchId(), model);
|
||||
setCurrentNodes(instance, null);
|
||||
advance(instance, model, model.start, null);
|
||||
advance(instance, model, model.start, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,8 +57,10 @@ public class FlowEngine {
|
||||
* 单出边走唯一出边(旧定义逐字节兼容);多出边按办理人选的出口名(edgeName,即办理按钮文案)
|
||||
* 选路——未命中时记录错误并保持节点在途,绝不静默走第一条边。
|
||||
* 分支上下文由 {@link BranchScope#keyOf} 从图上回溯推导(分支内串行链的中间节点无任务级分支标记)。
|
||||
* operator=本节点办理人(TASK_CREATED/COMPLETED 操作人信封字段来源),由推进监听器从事件 data 穿透传入。
|
||||
*/
|
||||
public void continueAdvance(FlwInstance instance, FlwDef def, String fromNodeId, String edgeName) {
|
||||
public void continueAdvance(FlwInstance instance, FlwDef def, String fromNodeId, String edgeName,
|
||||
FlowOperator operator) {
|
||||
FlowDefModel model = parseModel(def);
|
||||
FlowDefModel.Node node = model.nodeById(fromNodeId);
|
||||
List<FlowDefModel.Edge> outs = model.outEdges(fromNodeId);
|
||||
@@ -73,26 +82,29 @@ public class FlowEngine {
|
||||
FlowDefModel.Node nextNode = model.nodeById(next);
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(nextNode.type)) {
|
||||
// 会签合并守门:标记本分支到达、全部到达才放行(直通无分支键时 JOIN 直通放行)
|
||||
advance(instance, model, next, branchKey);
|
||||
advance(instance, model, next, branchKey, operator);
|
||||
} else {
|
||||
activate(instance, model, nextNode, branchKey);
|
||||
activate(instance, model, nextNode, branchKey, operator);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退回目标激活(FlowTaskService.returnBack 调用):从 currentNodes 摘除来源节点,
|
||||
* 重新激活目标审批节点建新任务。沿用统一激活入口,split 幂等化后重入并行段不撞分支唯一约束。
|
||||
* operator=退回人(重件 TASK_CREATED 操作人信封字段来源)。
|
||||
*/
|
||||
public void returnTo(FlwInstance instance, FlwDef def, String fromNodeId, String targetNodeId) {
|
||||
public void returnTo(FlwInstance instance, FlwDef def, String fromNodeId, String targetNodeId,
|
||||
FlowOperator operator) {
|
||||
FlowDefModel model = parseModel(def);
|
||||
removeCurrentNode(instance, fromNodeId);
|
||||
FlowDefModel.Node target = model.nodeById(targetNodeId);
|
||||
String branchKey = BranchScope.keyOf(model, targetNodeId);
|
||||
advance(instance, model, target.id, branchKey);
|
||||
advance(instance, model, target.id, branchKey, operator);
|
||||
}
|
||||
|
||||
/** 统一激活入口:含 JOIN 的到达标记与放行判断 */
|
||||
private void advance(FlwInstance instance, FlowDefModel model, String nodeId, String branchKey) {
|
||||
private void advance(FlwInstance instance, FlowDefModel model, String nodeId, String branchKey,
|
||||
FlowOperator operator) {
|
||||
FlowDefModel.Node node = model.nodeById(nodeId);
|
||||
if (FlowDefModel.TYPE_PARALLEL_JOIN.equals(node.type)) {
|
||||
if (branchKey != null) {
|
||||
@@ -103,74 +115,123 @@ public class FlowEngine {
|
||||
branchRepository.save(branch);
|
||||
}
|
||||
}
|
||||
long running = branchRepository.countByInstanceIdAndStatus(
|
||||
instance.getId(), FlwEnums.BranchStatus.RUNNING.getCode());
|
||||
if (running > 0) {
|
||||
log.debug("[flow-center] JOIN {} 等待分支汇聚:instance={} remaining={}",
|
||||
nodeId, instance.getId(), running);
|
||||
// scope 级放行(2026-09-24 §4.6):只等本块——匹配 split 的直接分支——全部到达。
|
||||
// 内层块分支键带本块前缀,由内层 JOIN 消费,内层先放行、外层后放行,嵌套并行不再死锁。
|
||||
FlowDefModel.Node matchedSplit = BranchScope.matchedSplitOf(model, nodeId);
|
||||
if (waitingForScope(instance, model, matchedSplit)) {
|
||||
log.debug("[flow-center] JOIN {} 等待分支汇聚:instance={}", nodeId, instance.getId());
|
||||
return;
|
||||
}
|
||||
List<FlowDefModel.Edge> outs = model.outEdges(nodeId);
|
||||
if (outs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
activate(instance, model, model.nodeById(outs.get(0).to), null);
|
||||
// 续走本块所在的外层分支上下文(顶层块放行后回主线 null)
|
||||
String continuation = matchedSplit == null ? null : BranchScope.keyOf(model, matchedSplit.id);
|
||||
advance(instance, model, outs.get(0).to, continuation, operator);
|
||||
return;
|
||||
}
|
||||
activate(instance, model, node, branchKey);
|
||||
activate(instance, model, node, branchKey, operator);
|
||||
}
|
||||
|
||||
/**
|
||||
* JOIN 是否仍在等待:scope 的任一直接分支(或以其为前缀的内层分支,内层未汇聚=该分支未完成)
|
||||
* 仍有进行中行即等待。匹配不到 split 的历史直连图退回实例级口径(与修复前行为一致)。
|
||||
*/
|
||||
private boolean waitingForScope(FlwInstance instance, FlowDefModel model, FlowDefModel.Node matchedSplit) {
|
||||
List<FlwBranch> running = branchRepository.findByInstanceIdAndStatus(
|
||||
instance.getId(), FlwEnums.BranchStatus.RUNNING.getCode());
|
||||
if (running.isEmpty() || matchedSplit == null) {
|
||||
return !running.isEmpty();
|
||||
}
|
||||
for (FlowDefModel.Edge e : model.outEdges(matchedSplit.id)) {
|
||||
String key = BranchScope.keyOf(model, e.to);
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
for (FlwBranch b : running) {
|
||||
if (key.equals(b.getBranchKey()) || b.getBranchKey().startsWith(key + ".")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 非 JOIN 节点激活 */
|
||||
private void activate(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node, String branchKey) {
|
||||
private void activate(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node, String branchKey,
|
||||
FlowOperator operator) {
|
||||
switch (node.type) {
|
||||
case FlowDefModel.TYPE_START: {
|
||||
List<FlowDefModel.Edge> outs = model.outEdges(node.id);
|
||||
if (!outs.isEmpty()) {
|
||||
advance(instance, model, outs.get(0).to, branchKey);
|
||||
advance(instance, model, outs.get(0).to, branchKey, operator);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FlowDefModel.TYPE_APPROVAL:
|
||||
createTasks(instance, model, node);
|
||||
createTasks(instance, model, node, operator);
|
||||
addCurrentNode(instance, node.id);
|
||||
break;
|
||||
case FlowDefModel.TYPE_NOTIFY: {
|
||||
notifyReceivers(instance, model, node, operator);
|
||||
List<FlowDefModel.Edge> outs = model.outEdges(node.id);
|
||||
if (!outs.isEmpty()) {
|
||||
advance(instance, model, outs.get(0).to, branchKey, operator);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FlowDefModel.TYPE_CONDITION:
|
||||
activateCondition(instance, model, node, operator);
|
||||
break;
|
||||
case FlowDefModel.TYPE_PARALLEL_SPLIT: {
|
||||
for (FlowDefModel.Edge edge : model.outEdges(node.id)) {
|
||||
// 分支键=路径前缀编码(2026-09-24 §4.6):顶层=分支头 id(存量不变),内层=「外层键.分支头id」
|
||||
String edgeKey = BranchScope.keyOf(model, edge.to);
|
||||
// 幂等 upsert:退回重入并行段时分支行已存在(uk instance+branchKey),重置进行中即可
|
||||
FlwBranch branch = branchRepository
|
||||
.findByInstanceIdAndBranchKey(instance.getId(), edge.to).orElse(null);
|
||||
.findByInstanceIdAndBranchKey(instance.getId(), edgeKey).orElse(null);
|
||||
if (branch == null) {
|
||||
branch = new FlwBranch();
|
||||
branch.setInstanceId(instance.getId());
|
||||
branch.setBranchKey(edge.to);
|
||||
branch.setBranchKey(edgeKey);
|
||||
branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode());
|
||||
} else {
|
||||
branch.setStatus(FlwEnums.BranchStatus.RUNNING.getCode());
|
||||
}
|
||||
branchRepository.save(branch);
|
||||
advance(instance, model, edge.to, edge.to);
|
||||
advance(instance, model, edge.to, edgeKey, operator);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FlowDefModel.TYPE_END:
|
||||
complete(instance);
|
||||
complete(instance, operator);
|
||||
break;
|
||||
default:
|
||||
log.warn("[flow-center] 未知节点类型 {}(节点 {}),推进中止", node.type, node.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件 */
|
||||
private void createTasks(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node) {
|
||||
List<AssigneeRef> assignees = assigneeResolver.resolve(instance.getSchId(), node);
|
||||
/** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件。operator=null 回退发起人。
|
||||
* 命中发起时指派快照则用指派子集替代全量候选(不变量 I1 收窄不改变 countersign 放行语义、I4 信任快照) */
|
||||
private void createTasks(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node,
|
||||
FlowOperator operator) {
|
||||
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
|
||||
: FlowOperator.of(instance.getSubmitterId(), instance.getSubmitterName());
|
||||
// C3 节点展示口径在建任务时定格:显示部门就用独立名称,当前节点与后续轨迹/记录同口径
|
||||
String displayName = node.displayName();
|
||||
for (AssigneeRef ref : assignees) {
|
||||
FlwTask task = new FlwTask();
|
||||
task.setInstanceId(instance.getId());
|
||||
task.setSchId(instance.getSchId());
|
||||
task.setNodeId(node.id);
|
||||
task.setNodeName(node.name);
|
||||
task.setNodeName(displayName);
|
||||
task.setAssigneeId(ref.getUserId());
|
||||
task.setAssigneeName(ref.getUserName());
|
||||
task.setStatus(FlwEnums.TaskStatus.PENDING.getCode());
|
||||
@@ -180,25 +241,132 @@ public class FlowEngine {
|
||||
taskRepository.save(task);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("nodeId", node.id);
|
||||
data.put("nodeName", node.name);
|
||||
data.put("nodeName", displayName);
|
||||
data.put("nodeSummary", nodeSummary(model));
|
||||
data.put("totalTasks", assignees.size());
|
||||
data.put("doneTasks", 0);
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.TASK_CREATED,
|
||||
instance.getId(), task.getId(), data));
|
||||
instance.getId(), task.getId(), op.getUserId(), op.getUserName(), data));
|
||||
}
|
||||
}
|
||||
|
||||
/** END 激活:办件完成、残留任务/分支失效、完成事件 */
|
||||
private void complete(FlwInstance instance) {
|
||||
/**
|
||||
* NOTIFY 激活(2026-09-23 二期):逐接收人落 flw_cc + 抛 CC_SENT 事件,随后由 activate
|
||||
* 推进唯一出边——不建任务、不进 currentNodes(瞬时放行,放在并行分支内也不影响 JOIN 计数)。
|
||||
* 接收人解析走宽容口径:解析不出人只记日志继续走(抄送是知会不是审批)。
|
||||
* operator=把流程推进到本节点的人(发起直达=发起人),CC_SENT 信封的操作人来源。
|
||||
*/
|
||||
private void notifyReceivers(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node,
|
||||
FlowOperator operator) {
|
||||
List<AssigneeRef> receivers = assigneeResolver.resolveTolerant(instance.getSchId(), node);
|
||||
if (receivers.isEmpty()) {
|
||||
log.warn("[flow-center] NOTIFY 节点无可用抄送对象(不阻塞推进):instance={} node={}",
|
||||
instance.getId(), node.id);
|
||||
return;
|
||||
}
|
||||
FlowOperator op = operator != null ? operator
|
||||
: FlowOperator.of(instance.getSubmitterId(), instance.getSubmitterName());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String displayName = node.displayName();
|
||||
for (AssigneeRef ref : receivers) {
|
||||
FlwCc cc = new FlwCc();
|
||||
cc.setSchId(instance.getSchId());
|
||||
cc.setInstanceId(instance.getId());
|
||||
cc.setNodeId(node.id);
|
||||
cc.setNodeName(displayName);
|
||||
cc.setUserId(ref.getUserId());
|
||||
cc.setUserName(ref.getUserName());
|
||||
cc.setSentTime(now);
|
||||
ccRepository.save(cc);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("nodeId", node.id);
|
||||
data.put("nodeName", displayName);
|
||||
data.put("nodeSummary", nodeSummary(model));
|
||||
data.put("ccId", cc.getId());
|
||||
data.put("ccUserId", ref.getUserId());
|
||||
data.put("ccUserName", ref.getUserName());
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.CC_SENT,
|
||||
instance.getId(), null, op.getUserId(), op.getUserName(), data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CONDITION 激活(2026-09-24 §4.4):先占位(进 currentNodes,判断失败=挂起的在途标记),
|
||||
* 同步调外部判断,按返回出口名走 edgeByName 选路(复用多出口纪律:未命中保持节点在途)。
|
||||
* 失败不配默认出口:宁挂起待管理端重跑(rerouteCondition),落 outlet=NULL 留痕见
|
||||
* {@link ConditionRouteService}。operator=推进到本节点的人(判断无人工动作,事件操作人沿用)。
|
||||
*/
|
||||
private void activateCondition(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node,
|
||||
FlowOperator operator) {
|
||||
addCurrentNode(instance, node.id);
|
||||
String outlet = conditionRouteService.decide(instance, model, node);
|
||||
if (outlet == null) {
|
||||
return;
|
||||
}
|
||||
advanceByOutlet(instance, model, node, outlet, operator);
|
||||
}
|
||||
|
||||
/** 按出口名选路放行:命中出边即移出在途并推进;未命中保持节点在途(防御口径,正常不会发生) */
|
||||
private void advanceByOutlet(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node,
|
||||
String outlet, FlowOperator operator) {
|
||||
FlowDefModel.Edge chosen = model.edgeByName(node.id, outlet);
|
||||
if (chosen == null) {
|
||||
log.error("[flow-center] CONDITION 出口未命中出边,保持节点在途:instance={} node={} outlet={}",
|
||||
instance.getId(), node.id, outlet);
|
||||
return;
|
||||
}
|
||||
removeCurrentNode(instance, node.id);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("nodeId", node.id);
|
||||
data.put("nodeName", node.name);
|
||||
data.put("routerKey", node.routerKey);
|
||||
data.put("outlet", outlet);
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.ROUTE_DECIDED,
|
||||
instance.getId(), null, operator == null ? null : operator.getUserId(),
|
||||
operator == null ? null : operator.getUserName(), data));
|
||||
advance(instance, model, chosen.to, BranchScope.keyOf(model, node.id), operator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端重跑路由(挂起恢复唯一入口):仅对仍在途(currentNodes 含之)的 CONDITION 节点生效,
|
||||
* 重新调判断,成功则按返回出口续走推进。失败抛 BizException(挂起不变,留痕已落)。
|
||||
*/
|
||||
public void rerouteCondition(FlwInstance instance, FlwDef def, String nodeId) {
|
||||
FlowDefModel model = parseModel(def);
|
||||
FlowDefModel.Node node = model.nodeById(nodeId);
|
||||
if (!FlowDefModel.TYPE_CONDITION.equals(node.type)) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "节点不是条件分支:" + nodeId);
|
||||
}
|
||||
String cur = instance.getCurrentNodes();
|
||||
boolean parked = cur != null && java.util.Arrays.stream(cur.split(",")).anyMatch(nodeId::equals);
|
||||
if (!parked) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
|
||||
"该条件分支节点不在途(可能已放行),无需重跑");
|
||||
}
|
||||
String outlet = conditionRouteService.decide(instance, model, node);
|
||||
if (outlet == null) {
|
||||
throw new com.yunhai.platform.apistd.BizException(
|
||||
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
|
||||
"判断仍失败,办件继续挂起(详见路由留痕)");
|
||||
}
|
||||
advanceByOutlet(instance, model, node, outlet, null);
|
||||
}
|
||||
|
||||
/** END 激活:办件完成、残留任务/分支失效、完成事件。operator=null 回退发起人 */
|
||||
private void complete(FlwInstance instance, FlowOperator operator) {
|
||||
instance.setStatus(FlwEnums.InstanceStatus.COMPLETED.getCode());
|
||||
instance.setFinishTime(LocalDateTime.now());
|
||||
setCurrentNodes(instance, null);
|
||||
taskRepository.invalidatePendingByInstance(instance.getId());
|
||||
branchRepository.invalidateRunningByInstance(instance.getId());
|
||||
FlowOperator op = operator != null ? operator
|
||||
: FlowOperator.of(instance.getSubmitterId(), instance.getSubmitterName());
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("result", "APPROVED");
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.COMPLETED, instance.getId(), null, data));
|
||||
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.COMPLETED, instance.getId(), null,
|
||||
op.getUserId(), op.getUserName(), data));
|
||||
}
|
||||
|
||||
public FlowDefModel parseModel(FlwDef def) {
|
||||
|
||||
@@ -32,6 +32,10 @@ public class FlowEvent {
|
||||
RETURNED("flow.returned"),
|
||||
/** 办理人移交办理权给他人(任务换人,办件仍在途) */
|
||||
TRANSFERRED("flow.transferred"),
|
||||
/** NOTIFY 抄送(逐接收人一条,瞬时放行不建任务;data 带 ccId/ccUserId/ccUserName) */
|
||||
CC_SENT("flow.cc.sent"),
|
||||
/** CONDITION 判断落定(订阅制,供业务侧审计"为什么走了这条线";data 带 nodeId/nodeName/routerKey/outlet) */
|
||||
ROUTE_DECIDED("flow.route.decided"),
|
||||
/** 催办(仅 UserMsg 通道,非回调事件枚举) */
|
||||
URGE("flow.urge");
|
||||
|
||||
@@ -59,14 +63,73 @@ public class FlowEvent {
|
||||
private final Long instanceId;
|
||||
/** 任务事件带 taskId(SUBMITTED 等办件级事件为 null) */
|
||||
private final Long taskId;
|
||||
/** 操作人 id(谁导致本事件;回调信封提升为 operatorUserId)。可空:语义见《子应用接入流程中心规范.md》§6.4 */
|
||||
private final Integer operatorId;
|
||||
/** 操作人姓名快照。可空 */
|
||||
private final String operatorName;
|
||||
/** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */
|
||||
private final Map<String, Object> data;
|
||||
private Map<String, Object> roundIdentity;
|
||||
|
||||
// ===== T05 稳定事件身份(2026-10-25) =====
|
||||
/** 事实行 event_id:发布线程同步监听器在状态事务内落事实行时一次性赋值,全订阅方/重试复用 */
|
||||
private String stableEventId;
|
||||
/** 事实行 event_seq(实例内单调序号) */
|
||||
private Integer stableEventSeq;
|
||||
/**
|
||||
* R2(Codex D04)冻结信封:事实落库时一次性构建的完整投递载荷 JSON(含 eventId/eventSeq/
|
||||
* flowGuid 等全部信封字段)。正常分发与崩溃恢复分发共用同一份冻结体——恢复消息体不再缺
|
||||
* eventId/eventSeq,两条路径字节级同构。事实行缺席(存量路径)为 null,由分发器现场构建。
|
||||
*/
|
||||
private String stablePayloadJson;
|
||||
|
||||
public void assignStableIdentity(String eventId, Integer eventSeq) {
|
||||
if (this.stableEventId != null) {
|
||||
return; // 只赋一次:幂等重入(同事件多监听器)不换身份
|
||||
}
|
||||
this.stableEventId = eventId;
|
||||
this.stableEventSeq = eventSeq;
|
||||
}
|
||||
|
||||
public String getStableEventId() {
|
||||
return stableEventId;
|
||||
}
|
||||
|
||||
public Integer getStableEventSeq() {
|
||||
return stableEventSeq;
|
||||
}
|
||||
|
||||
/** 随稳定身份一并冻结完整投递信封(只赋一次;身份与信封同源同事务) */
|
||||
public void assignStablePayload(String payloadJson) {
|
||||
if (this.stablePayloadJson != null) {
|
||||
return;
|
||||
}
|
||||
this.stablePayloadJson = payloadJson;
|
||||
}
|
||||
|
||||
public String getStablePayloadJson() {
|
||||
return stablePayloadJson;
|
||||
}
|
||||
|
||||
public void captureRoundIdentity(Integer round, String requestToken) {
|
||||
if (roundIdentity != null) return;
|
||||
Map<String, Object> identity = new HashMap<>();
|
||||
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, new HashMap<>());
|
||||
return new FlowEvent(type, instanceId, null, null, null, new HashMap<>());
|
||||
}
|
||||
|
||||
public static FlowEvent of(Type type, Long instanceId, Long taskId, Map<String, Object> data) {
|
||||
return new FlowEvent(type, instanceId, taskId, data == null ? new HashMap<>() : data);
|
||||
return new FlowEvent(type, instanceId, taskId, null, null, data == null ? new HashMap<>() : data);
|
||||
}
|
||||
|
||||
public static FlowEvent of(Type type, Long instanceId, Long taskId,
|
||||
Integer operatorId, String operatorName, Map<String, Object> data) {
|
||||
return new FlowEvent(type, instanceId, taskId, operatorId, operatorName,
|
||||
data == null ? new HashMap<>() : data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,102 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAdvanceIntent;
|
||||
import com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.orm.ObjectOptimisticLockingFailureException;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* 节点放行推进监听器:TASK_HANDLED 事件 AFTER_COMMIT 后在新事务里重查——
|
||||
* 该节点待办是否清零、办件是否仍在途,全部成立才继续推进(下一节点/JOIN 合并/END)。
|
||||
* 会签并发办理时各方提交的快照互相看不到对方未提交的办理行,事务内判断
|
||||
* "节点是否全部办结"必假,提交后重查才是唯一可靠时机;推进串行化由办件乐观锁兜底
|
||||
* (重试循环在监听器侧,跨 bean 调用 {@link FlowAdvanceService#tryAdvance} 保证 REQUIRES_NEW 生效)。
|
||||
*/
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** TASK_CREATED establishes membership; TASK_HANDLED refreshes that same activation in the handling transaction. */
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FlowNodeAdvanceListener {
|
||||
|
||||
private final FlowAdvanceService advanceService;
|
||||
private final FlwAdvanceIntentRepository intentRepository;
|
||||
|
||||
@EventListener
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 2)
|
||||
public void persistIntent(FlowEvent event) {
|
||||
if (event.getData() == null) return;
|
||||
if (event.getType() == FlowEvent.Type.TASK_CREATED) {
|
||||
ActivationBatch batch = batch();
|
||||
if (!batch.tasks.add(event.getTaskId())) return;
|
||||
String key = event.getInstanceId() + ":" + event.getData().get("nodeId");
|
||||
int total = ((Number) event.getData().get("totalTasks")).intValue();
|
||||
if (total <= 0) throw new IllegalStateException("Missing activation batch size");
|
||||
Registration registration = batch.nodes.get(key);
|
||||
if (registration == null) { registration = new Registration(total);batch.nodes.put(key, registration); }
|
||||
if (registration.total != total) throw new IllegalStateException("Activation batch size changed");
|
||||
registration.intentId = advanceService.registerTask(event, registration.intentId);
|
||||
if (--registration.remaining == 0) batch.nodes.remove(key);
|
||||
} else if (event.getType() == FlowEvent.Type.TRANSFERRED) {
|
||||
advanceService.registerTransfer(event);
|
||||
} else if (event.getType() == FlowEvent.Type.TASK_HANDLED && event.getData().get("nodeId") != null) {
|
||||
advanceService.recordHandled(event);
|
||||
}
|
||||
}
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onTaskHandled(FlowEvent event) {
|
||||
if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) {
|
||||
return;
|
||||
}
|
||||
if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) return;
|
||||
String nodeId = (String) event.getData().get("nodeId");
|
||||
if (nodeId == null) {
|
||||
return;
|
||||
}
|
||||
String edgeName = (String) event.getData().get("edgeName");
|
||||
if (nodeId == null) return;
|
||||
FlowAdvanceService.IntentRef ref = advanceService.pendingForTask(event.getInstanceId(), nodeId, event.getTaskId());
|
||||
if (ref == null) return;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
advanceService.tryAdvance(event.getInstanceId(), nodeId, edgeName);
|
||||
return;
|
||||
} catch (ObjectOptimisticLockingFailureException e) {
|
||||
log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}",
|
||||
attempt, event.getInstanceId(), nodeId);
|
||||
try { advanceService.tryAdvance(ref.id, ref.revision);return; }
|
||||
catch (ObjectOptimisticLockingFailureException conflict) {
|
||||
log.warn("[flow-center] D09 advance conflict {}/3: intent={} revision={}", attempt, ref.id, ref.revision);
|
||||
} catch (Exception failure) {
|
||||
advanceService.defer(ref.id, ref.revision, failure.toString());return;
|
||||
}
|
||||
}
|
||||
log.error("[flow-center] 推进重试耗尽(请人工检查):instance={} node={}",
|
||||
event.getInstanceId(), nodeId);
|
||||
advanceService.defer(ref.id, ref.revision, "AFTER_COMMIT retries exhausted; sweep will resume");
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 30_000)
|
||||
public void sweepIntents() {
|
||||
for (FlwAdvanceIntent detached : intentRepository.findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
|
||||
FlwAdvanceIntent.STATE_PENDING, LocalDateTime.now())) {
|
||||
Long id = detached.getId(), revision = detached.getRevision();
|
||||
try { advanceService.tryAdvance(id, revision); }
|
||||
catch (Exception failure) { advanceService.defer(id, revision, failure.toString()); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronizations are suspended with REQUIRES_NEW; an outer activation batch cannot leak into its child. */
|
||||
private static ActivationBatch batch() {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) throw new IllegalStateException("Activation requires its creation transaction");
|
||||
for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
|
||||
if (sync instanceof ActivationBatch) return (ActivationBatch) sync;
|
||||
}
|
||||
ActivationBatch created = new ActivationBatch();
|
||||
TransactionSynchronizationManager.registerSynchronization(created);return created;
|
||||
}
|
||||
private static final class ActivationBatch implements TransactionSynchronization {
|
||||
final Map<String, Registration> nodes = new HashMap<>();
|
||||
final java.util.Set<Long> tasks = new java.util.HashSet<>();
|
||||
@Override public void beforeCommit(boolean readOnly) {
|
||||
if (!nodes.isEmpty()) throw new IllegalStateException("Incomplete activation membership; creation transaction must roll back");
|
||||
}
|
||||
}
|
||||
private static final class Registration {
|
||||
final int total;
|
||||
int remaining;
|
||||
Long intentId;
|
||||
Registration(int total) { this.total = total;this.remaining = total; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* 推进链携带的操作人上下文(2026-09-20 信封升级):任务办理后的放行推进在
|
||||
* AFTER_COMMIT 新事务里由 FlowNodeAdvanceListener 重查执行,"是谁办的"只存在于
|
||||
* TASK_HANDLED 事件 data 里,必须显式穿透 listener → tryAdvance → continueAdvance
|
||||
* → activate → createTasks/complete,否则 TASK_CREATED/COMPLETED 事件拿不到操作人。
|
||||
* null 语义=未穿透(发起直达路径),事件构建点回退办件发起人。
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class FlowOperator {
|
||||
|
||||
private final Integer userId;
|
||||
private final String userName;
|
||||
|
||||
public static FlowOperator of(Integer userId, String userName) {
|
||||
return new FlowOperator(userId, userName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.openapi.core.TargetSpec;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.flowcenter.entity.FlwRouter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 条件路由判断调用客户端(2026-09-24 §4.3):POST {目录解析 host}{basePath}{path}?schId=,
|
||||
* 身份即本应用 appKey(100019)——签名拦截器由 {@link YschOpenCall} 装配链注入,与回调推送同构。
|
||||
* 寻址:service_key 走服务目录三级解析(targets > services > 枚举兜底);
|
||||
* flw_router.base_path 非空时覆盖目录条目的 context-path(网关形态目录无前缀、实际服务有的场景),
|
||||
* 漏填必 401(规范 §4.2 同款坑)。<b>direct_host 非空的自注册行跳过目录解析直连</b>(2026-10-12 收口)。
|
||||
* 信封解析复用 SDK({code,msg,data})。
|
||||
*
|
||||
* <p>重试纪律:同步 3s×3 次退避重试(500ms/1500ms),业务方义务=幂等纯读轻实现。
|
||||
* 全部失败抛 {@link JudgeUnavailableException}(由调用方落 outlet=NULL 留痕并挂起)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RouteJudgeClient {
|
||||
|
||||
/** 判断接口单次超时(毫秒)与重试次数/退避(设计 §4.4:总预算 3s×3 退避重试) */
|
||||
static final int TIMEOUT_MS = 3_000;
|
||||
static final int MAX_ATTEMPTS = 3;
|
||||
private static final long[] BACKOFF_MS = {500L, 1500L};
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RouteJudgeClient(YschOpenCall openCall, ObjectMapper objectMapper) {
|
||||
this.openCall = openCall;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** 判断请求体(契约冻结: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;
|
||||
public final List<String> outlets;
|
||||
public final Map<String, Object> variables;
|
||||
|
||||
public JudgeBody(Long schId, Long instanceId, Integer instanceVersion, String bizType, String bizId,
|
||||
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;
|
||||
this.outlets = outlets;
|
||||
this.variables = variables;
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断结果:出口名 + 原始返回(data 节点序列化,审计落 flw_route_log.raw_response) */
|
||||
public static class JudgeResult {
|
||||
public final String outlet;
|
||||
public final String rawResponse;
|
||||
|
||||
public JudgeResult(String outlet, String rawResponse) {
|
||||
this.outlet = outlet;
|
||||
this.rawResponse = rawResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断服务不可用(重试耗尽/网络/信封错误);outlet 未命中由调用方判定,不走本异常 */
|
||||
public static class JudgeUnavailableException extends RuntimeException {
|
||||
public JudgeUnavailableException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public JudgeResult call(FlwRouter router, JudgeBody body) {
|
||||
TargetSpec spec = resolveTarget(router);
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("schId", String.valueOf(body.schId));
|
||||
RuntimeException last = null;
|
||||
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
Map<String, Object> data = openCall.post(spec, router.getPath(), query, body, Map.class);
|
||||
String raw = objectMapper.writeValueAsString(data);
|
||||
Object outlet = data == null ? null : data.get("outlet");
|
||||
if (outlet == null || String.valueOf(outlet).trim().isEmpty()) {
|
||||
throw new IllegalStateException("判断接口未返回 outlet:" + raw);
|
||||
}
|
||||
return new JudgeResult(String.valueOf(outlet).trim(), raw);
|
||||
} catch (RuntimeException e) {
|
||||
last = e;
|
||||
log.warn("[flow-center] 路由判断调用失败(第 {}/{} 次):router={} {}",
|
||||
attempt, MAX_ATTEMPTS, router.getRouterKey(), e.toString());
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
sleep(BACKOFF_MS[attempt - 1]);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
last = new IllegalStateException(e.toString(), e);
|
||||
log.warn("[flow-center] 路由判断调用异常(第 {}/{} 次):router={} {}",
|
||||
attempt, MAX_ATTEMPTS, router.getRouterKey(), e.toString());
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
sleep(BACKOFF_MS[attempt - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new JudgeUnavailableException("判断服务调用失败(已重试 " + MAX_ATTEMPTS + " 次)", last);
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标解析:
|
||||
* <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();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
private static void sleep(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwRouteLog;
|
||||
import com.yunhai.flowcenter.repository.FlwRouteLogRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 路由留痕落库(REQUIRES_NEW,与回调 CallbackLogStore 同纪律):判断调用是真实外部副作用,
|
||||
* 留痕不随外层事务回滚消失——挂起重跑失败(reroute 抛业务错回滚办件推进)时失败行仍保留;
|
||||
* attempt 在同一新事务里"查最大+1"再落行,避免并发双判撞号。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RouteLogStore {
|
||||
|
||||
private final FlwRouteLogRepository routeLogRepository;
|
||||
|
||||
/** 落一行留痕并提交(独立事务),返回带 attempt 的落库行 */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public FlwRouteLog append(FlwRouteLog row) {
|
||||
row.setAttempt(routeLogRepository.maxAttempt(row.getInstanceId(), row.getNodeId()) + 1);
|
||||
row.setDecideTime(LocalDateTime.now());
|
||||
return routeLogRepository.save(row);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.yunhai.flowcenter.engine;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** D10 定义与发起共用的纯校验器;不转换输入,不依赖 FC/数据库。 */
|
||||
public final class VariableContract {
|
||||
private VariableContract() { }
|
||||
private static final Set<String> TYPES = new HashSet<>(Arrays.asList(
|
||||
"string", "number", "boolean", "date", "datetime", "money", "stringArray", "fileArray", "userArray"));
|
||||
private static final String TOKEN = "(?:'(?:[^'\\\\]|\\\\.)*'|\"(?:[^\"\\\\]|\\\\.)*\"|-?\\d+(?:\\.\\d+)?|[A-Za-z_][A-Za-z0-9_]*)";
|
||||
// 先分词以跳过完整字符串;不把函数参数字符串中的 "amount > 3" 当成比较。
|
||||
private static final Pattern TOKENS = Pattern.compile(TOKEN + "|==|!=|>=|<=|>|<|\\S");
|
||||
private static final Set<String> COMPARISONS = new HashSet<>(Arrays.asList("==", "!=", ">=", "<=", ">", "<"));
|
||||
|
||||
public static Set<String> referencedKeys(FlowDefModel model) {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (n == null || !n.expressionJudge()) continue;
|
||||
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
|
||||
if (e.condition == null || e.condition.trim().isEmpty()) continue;
|
||||
try {
|
||||
for (String key : ExpressionEvaluator.variableNames(e.condition.trim())) {
|
||||
if (!DefValidator.EXPRESSION_BUILTINS.contains(key)) keys.add(key);
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
// 历史版本保持原有语法异常跳过口径;新契约必须 fail closed。
|
||||
if (model.variables != null) fail("表达式不合法:" + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
public static void validateDefinition(FlowDefModel model, boolean publishing) {
|
||||
if (model.variables == null) {
|
||||
if (publishing) fail("新发布版本必须声明 variables(无变量请显式声明 [])");
|
||||
return;
|
||||
}
|
||||
Map<String, FlowDefModel.Variable> fields = new LinkedHashMap<>();
|
||||
for (FlowDefModel.Variable v : model.variables) {
|
||||
if (v == null || v.key == null || !v.key.matches("[A-Za-z0-9_]{1,64}")) fail("变量 key 不合法");
|
||||
if (DefValidator.EXPRESSION_BUILTINS.contains(v.key)) fail("变量不能占用内置键:" + v.key);
|
||||
if (fields.put(v.key, v) != null) fail("变量 key 重复:" + v.key);
|
||||
if (!TYPES.contains(v.type)) fail("变量类型不支持:" + v.key + " / " + v.type);
|
||||
if (v.enumValues != null) {
|
||||
if (!"string".equals(v.type) && !"stringArray".equals(v.type)) fail("enum 仅适用于字符串及字符串数组:" + v.key);
|
||||
if (v.enumValues.isEmpty() || v.enumValues.contains(null)
|
||||
|| new HashSet<>(v.enumValues).size() != v.enumValues.size()) fail("enum 值域为空或重复:" + v.key);
|
||||
for (String option : v.enumValues) {
|
||||
if (blank(option)) fail("enum 值域不能含空白:" + v.key);
|
||||
validateValue(v, "stringArray".equals(v.type) ? Collections.singletonList(option) : option, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String key : referencedKeys(model)) {
|
||||
if (!fields.containsKey(key)) fail("表达式变量 " + key + " 未声明(不在关联表单字段集内或 variables 中)");
|
||||
}
|
||||
for (FlowDefModel.Node n : model.nodes) {
|
||||
if (n == null || !n.expressionJudge()) continue;
|
||||
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
|
||||
if (e.condition == null) continue;
|
||||
Matcher m = TOKENS.matcher(e.condition);
|
||||
List<String> tokens = new ArrayList<>();
|
||||
while (m.find()) tokens.add(m.group());
|
||||
for (int i = 1; i + 1 < tokens.size(); i++) {
|
||||
if (!COMPARISONS.contains(tokens.get(i))) continue;
|
||||
checkLiteral(fields.get(tokens.get(i-1)), tokens.get(i+1));
|
||||
checkLiteral(fields.get(tokens.get(i+1)), tokens.get(i-1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkLiteral(FlowDefModel.Variable field, String token) {
|
||||
if (field == null || "null".equals(token)) return;
|
||||
Object value;
|
||||
if (token.startsWith("'") || token.startsWith("\"")) {
|
||||
// Aviator 的转义交由编译器;带转义字面量不做正则域推断。
|
||||
if (token.indexOf('\\') >= 0) return;
|
||||
value = token.substring(1, token.length() - 1);
|
||||
} else if ("true".equals(token) || "false".equals(token)) value = Boolean.valueOf(token);
|
||||
else if (token.matches("-?\\d+(\\.\\d+)?")) value = new BigDecimal(token);
|
||||
else return;
|
||||
try { validateValue(field, value, true); }
|
||||
catch (BizException invalid) { fail("类型桶查②/域查③:" + invalid.getMessage()); }
|
||||
}
|
||||
|
||||
public static void validateInput(FlowDefModel model, Map<String, Object> submitted) {
|
||||
Set<String> refs = referencedKeys(model);
|
||||
if (model.variables == null) {
|
||||
for (String key : refs) {
|
||||
if (!submitted.containsKey(key) || submitted.get(key) == null) fail("流程条件所需变量缺失或为空(key=" + key + ")");
|
||||
}
|
||||
return;
|
||||
}
|
||||
validateDefinition(model, false);
|
||||
Set<String> declared = new HashSet<>();
|
||||
for (FlowDefModel.Variable v : model.variables) {
|
||||
declared.add(v.key);
|
||||
validateValue(v, submitted.get(v.key), Boolean.TRUE.equals(v.required) || refs.contains(v.key));
|
||||
}
|
||||
for (String key : submitted.keySet()) if (!declared.contains(key)) fail("未声明的变量:" + key);
|
||||
}
|
||||
|
||||
/** 必填/表达式引用均需实值;可选 null 可省略,其他非 null 值始终按类型与值域检查。 */
|
||||
private static void validateValue(FlowDefModel.Variable v, Object value, boolean required) {
|
||||
if (value == null) {
|
||||
if (required) fail("变量缺失或为空:" + v.key);
|
||||
return;
|
||||
}
|
||||
if (required && ((value instanceof String && blank((String) value))
|
||||
|| (value instanceof Collection && ((Collection<?>) value).isEmpty()))) fail("变量未填写:" + v.key);
|
||||
// FC 文本/单选和日期控件的清空值为 "";仅非必填且未被表达式引用时视作未填。
|
||||
// 数值控件空值是 null,不能借空字符串绕过 number/money/boolean/数组的类型检查。
|
||||
if (!required && "".equals(value) && Arrays.asList("string", "date", "datetime").contains(v.type)) return;
|
||||
boolean valid = false;
|
||||
switch (v.type) {
|
||||
case "string": valid = value instanceof String; break;
|
||||
case "number": case "money":
|
||||
if (value instanceof Number) {
|
||||
try { new BigDecimal(value.toString()); valid = true; } catch (NumberFormatException ignored) { }
|
||||
}
|
||||
break;
|
||||
case "boolean": valid = value instanceof Boolean; break;
|
||||
case "date":
|
||||
if (value instanceof String && ((String)value).matches("\\d{4}-\\d{2}-\\d{2}")) {
|
||||
try { LocalDate.parse((String)value); valid = true; } catch (RuntimeException ignored) { }
|
||||
}
|
||||
break;
|
||||
case "datetime":
|
||||
if (value instanceof String && ((String)value).matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})?")) {
|
||||
try { LocalDateTime.parse((String)value); valid = true; }
|
||||
catch (RuntimeException local) {
|
||||
try { OffsetDateTime.parse((String)value); valid = true; } catch (RuntimeException ignored) { }
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "stringArray": case "fileArray": case "userArray":
|
||||
valid = value instanceof List;
|
||||
if (valid) for (Object item : (List<?>)value) {
|
||||
if ("stringArray".equals(v.type)) {
|
||||
if (!(item instanceof String) || (v.enumValues != null && !v.enumValues.contains(item))) valid = false;
|
||||
} else if (!validRecord(item, "fileArray".equals(v.type))) valid = false;
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
if (!valid) fail("变量类型/格式不合法:" + v.key + "(应为 " + v.type + ")");
|
||||
if (value instanceof String && v.enumValues != null && !v.enumValues.contains(value)) fail("变量 " + v.key + " 不在枚举值域 " + v.enumValues + ":" + value);
|
||||
}
|
||||
|
||||
private static boolean validRecord(Object value, boolean file) {
|
||||
if (!(value instanceof Map)) return false;
|
||||
Map<?, ?> row = (Map<?, ?>)value;
|
||||
Object id = row.get("id");
|
||||
try {
|
||||
if (!(id instanceof Number) || new BigDecimal(id.toString()).scale() > 0 || ((Number)id).longValue() <= 0) return false;
|
||||
} catch (NumberFormatException invalid) { return false; }
|
||||
return file ? nonblank(row.get("fileName")) && nonblank(row.get("key")) : nonblank(row.get("name"));
|
||||
}
|
||||
private static boolean nonblank(Object value) { return value instanceof String && !blank((String)value); }
|
||||
private static boolean blank(String value) {
|
||||
return value.codePoints().allMatch(c -> Character.isWhitespace(c) || Character.isSpaceChar(c));
|
||||
}
|
||||
|
||||
/** FC 快照是唯一派生来源,保存时传入的 variables 不能覆盖绑定表单的契约。 */
|
||||
public static List<FlowDefModel.Variable> fromSnapshot(Map<String, Object> snapshot) {
|
||||
Object fields = snapshot.get("fields");
|
||||
if (!(fields instanceof List)) fail("formContract.fields 必须为数组");
|
||||
List<FlowDefModel.Variable> variables = new ArrayList<>();
|
||||
for (Object item : (List<?>)fields) {
|
||||
if (!(item instanceof Map)) fail("formContract 字段不合法");
|
||||
Map<?, ?> field = (Map<?, ?>)item;
|
||||
FlowDefModel.Variable v = new FlowDefModel.Variable();
|
||||
v.key = (String)field.get("key");
|
||||
v.required = Boolean.TRUE.equals(field.get("required"));
|
||||
String type = String.valueOf(field.get("type"));
|
||||
switch (type) {
|
||||
case "text": case "textarea": case "phone": case "select": case "radio": v.type = "string"; break;
|
||||
case "number": case "date": v.type = type; break;
|
||||
case "checkbox": case "multiselect": case "address": v.type = "stringArray"; break;
|
||||
case "image": case "upload": v.type = "fileArray"; break;
|
||||
case "user": case "users": v.type = "userArray"; break;
|
||||
default: fail("FC 字段类型不支持:" + v.key + " / " + type);
|
||||
}
|
||||
if (Arrays.asList("select", "radio", "checkbox", "multiselect").contains(type)) {
|
||||
Object enums = field.get("enum");
|
||||
if (!(enums instanceof List) || ((List<?>)enums).isEmpty()) fail("FC 枚举值域缺失:" + v.key);
|
||||
v.enumValues = new ArrayList<>();
|
||||
for (Object option : (List<?>)enums) {
|
||||
if (!(option instanceof String)) fail("FC 枚举必须为字符串:" + v.key);
|
||||
v.enumValues.add((String)option);
|
||||
}
|
||||
}
|
||||
variables.add(v);
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
private static void fail(String message) { throw new BizException(ErrorCode.PARAM_ERROR, message); }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.Version;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 待推进意图(2026-10-25 T05):任务办理事务内持久化"这个节点该继续推进了"的事实,
|
||||
* 事务外 worker 领取执行——办理事务提交成功 ⇔ 推进意图已持久化,进程崩溃/重启后
|
||||
* 扫描续跑(旧链路 AFTER_COMMIT 三次内存重试,耗尽只 log.error,重启即丢)。
|
||||
*
|
||||
* <p>D09:每个激活批次记录精确任务成员,未办理时以 DONE 等待;办理同事务串行刷新
|
||||
* 本批次的末笔任务及 PENDING 工作。worker 按 id+revision 锁定当前读,推进与结算同事务;
|
||||
* NULL 代次只允许明确接棒或留待有证据的人工回填,不作为本代放行。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_advance_intent",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_advance_activation", columnNames = "activation_task_id"),
|
||||
indexes = {
|
||||
@Index(name = "idx_flw_advance_intent_due", columnList = "status, next_retry_at"),
|
||||
@Index(name = "idx_flw_advance_intent_instance", columnList = "instance_id")})
|
||||
public class FlwAdvanceIntent extends BaseEntity {
|
||||
|
||||
public static final String STATE_PENDING = "PENDING";
|
||||
public static final String STATE_DONE = "DONE";
|
||||
public static final String STATE_EXHAUSTED = "EXHAUSTED";
|
||||
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private Long instanceId;
|
||||
|
||||
/** 触发推进的节点(TASK_HANDLED 的 nodeId) */
|
||||
@Column(name = "node_id", nullable = false, length = 64)
|
||||
private String nodeId;
|
||||
|
||||
/** First task CREATED in this activation batch; never the last task to finish. NULL = unanchored legacy work. */
|
||||
@Column(name = "activation_task_id")
|
||||
private Long activationTaskId;
|
||||
|
||||
/** Exact membership, including transfers; comma-delimited positive task IDs, e.g. ,12,14,. */
|
||||
@Column(name = "activation_task_ids", columnDefinition = "LONGTEXT")
|
||||
private String activationTaskIds;
|
||||
|
||||
@Column(name = "handled_task_ids", columnDefinition = "LONGTEXT")
|
||||
private String handledTaskIds;
|
||||
|
||||
@Column(name = "trigger_task_id")
|
||||
private Long triggerTaskId;
|
||||
|
||||
@Column(name = "activation_def_id")
|
||||
private Long activationDefId;
|
||||
|
||||
@Column(name = "activation_def_version")
|
||||
private Integer activationDefVersion;
|
||||
|
||||
@Column(name = "activation_apply_round")
|
||||
private Integer activationApplyRound;
|
||||
|
||||
/** A detached worker may act/defer only the revision it actually observed. */
|
||||
@Version
|
||||
@Column(name = "revision", nullable = false)
|
||||
private Long revision = 0L;
|
||||
|
||||
public boolean containsTask(Long id) { return contains(activationTaskIds, id); }
|
||||
public boolean handledTask(Long id) { return contains(handledTaskIds, id); }
|
||||
public void addTask(Long id) { if (!containsTask(id)) activationTaskIds = append(activationTaskIds, id); }
|
||||
public void addHandledTask(Long id) { if (!handledTask(id)) handledTaskIds = append(handledTaskIds, id); }
|
||||
private static boolean contains(String ids, Long id) { return ids != null && id != null && ids.contains("," + id + ","); }
|
||||
private static String append(String ids, Long id) {
|
||||
if (id == null || id <= 0) throw new IllegalArgumentException("Missing activation task identity");
|
||||
return (ids == null ? "," : ids) + id + ",";
|
||||
}
|
||||
|
||||
/** 出边名(可空=引擎默认路由) */
|
||||
@Column(name = "edge_name", length = 128)
|
||||
private String edgeName;
|
||||
|
||||
/** 办理人随行(推进链操作人信封,可空) */
|
||||
@Column(name = "assignee_id")
|
||||
private Integer assigneeId;
|
||||
|
||||
@Column(name = "assignee_name", length = 64)
|
||||
private String assigneeName;
|
||||
|
||||
/** PENDING=待执行;DONE=已执行(含推进判定为无需推进的幂等结论);EXHAUSTED=重试耗尽留人工 */
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private String status = STATE_PENDING;
|
||||
|
||||
@Column(name = "attempts", nullable = false)
|
||||
private Integer attempts = 0;
|
||||
|
||||
/** 退避到期时间(耗尽前按指数退避续扫) */
|
||||
@Column(name = "next_retry_at")
|
||||
private LocalDateTime nextRetryAt;
|
||||
|
||||
@Column(name = "last_error", length = 255)
|
||||
private String lastError;
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import javax.persistence.*;
|
||||
/** Immutable settled command fact: ACCEPTED or REJECTED. Never repurpose a request ID. */
|
||||
@Entity @Getter @Setter
|
||||
@Table(name="flw_apply_command", uniqueConstraints=@UniqueConstraint(name="uk_flw_command_request",columnNames={"sch_id","source_principal_id","request_id"}),indexes=@Index(name="idx_flw_command_instance",columnList="instance_id"))
|
||||
public class FlwApplyCommand extends BaseEntity {
|
||||
@Column(name="sch_id",nullable=false) private Long schId;
|
||||
@Column(name="source_principal_id",nullable=false,length=36) private String sourcePrincipalId;
|
||||
@Column(name="request_id",nullable=false,length=64) private String requestId;
|
||||
@Column(name="canonical_hash",nullable=false,length=64) private String canonicalHash;
|
||||
@Column(name="canonical_version",nullable=false) private Integer canonicalVersion;
|
||||
@Column(name="biz_type",nullable=false,length=32) private String bizType;
|
||||
@Column(name="biz_id",nullable=false,length=64) private String bizId;
|
||||
@Column(name="flow_guid",nullable=false,length=32) private String flowGuid;
|
||||
@Column(name="state",nullable=false,length=16) private String state;
|
||||
@Column(name="instance_id") private Long instanceId;
|
||||
@Column(name="apply_round") private Integer round;
|
||||
@Column(name="def_version") private Integer defVersion;
|
||||
@Column(name="def_id") private Long defId;
|
||||
@Column(name="reject_code",length=64) private String rejectCode;
|
||||
@Column(name="reject_detail",length=500) private String rejectDetail;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import javax.persistence.*;
|
||||
@Entity @Getter @Setter
|
||||
@Table(name="flw_biz_coordinate", uniqueConstraints=@UniqueConstraint(name="uk_flw_coord", columnNames={"sch_id","source_principal_id","biz_type","biz_id"}))
|
||||
public class FlwBizCoordinate extends BaseEntity {
|
||||
@Column(name="sch_id",nullable=false) private Long schId;
|
||||
@Column(name="source_principal_id",nullable=false,length=36) private String sourcePrincipalId;
|
||||
@Column(name="biz_type",nullable=false,length=32) private String bizType;
|
||||
@Column(name="biz_id",nullable=false,length=64) private String bizId;
|
||||
@Column(name="last_round",nullable=false) private Integer lastRound = 0;
|
||||
@Column(name="last_instance_id") private Long lastInstanceId;
|
||||
@Column(name="active_instance_id") private Long activeInstanceId;
|
||||
}
|
||||
@@ -12,14 +12,16 @@ import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 回调推送日志 = 重试队列(概览 §4.9):eventId 唯一(重试不变,业务方按它幂等去重);
|
||||
* 回调推送日志 = 重试队列(概览 §4.9;2026-10-25 R2 幂等键改复合):eventId 全订阅方复用
|
||||
* (重试不变,业务方按它幂等去重),(event_id, reg_id) 复合唯一——同一事件对同一订阅方只建
|
||||
* 一行,多订阅方各一行;并发 exists→insert 竞态由复合 uk 兜底(DuplicateKeyException=已建行)。
|
||||
* payload_json 完整快照可重放;索引 (status, next_retry_time) 是扫描主路径。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_callback_log",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event", columnNames = "event_id"),
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event_reg", columnNames = {"event_id", "reg_id"}),
|
||||
indexes = {
|
||||
@Index(name = "idx_flw_log_scan", columnList = "status, next_retry_time"),
|
||||
@Index(name = "idx_flw_log_inst", columnList = "instance_id")})
|
||||
@@ -76,6 +78,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;
|
||||
|
||||
@@ -21,6 +21,9 @@ import javax.persistence.UniqueConstraint;
|
||||
columnNames = {"sch_id", "app_key", "flow_guid"}),
|
||||
indexes = @Index(name = "idx_flw_reg_flow", columnList = "sch_id, flow_guid, status"))
|
||||
public class FlwCallbackReg extends BaseEntity {
|
||||
@Column(name="source_principal_id",length=36)
|
||||
private String sourcePrincipalId;
|
||||
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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 java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 抄送留痕(2026-09-23 二期第一批):NOTIFY 节点激活时逐接收人一行,瞬时落库后立即推进
|
||||
* 下一步(不建任务、不占待办)。"抄送我的"列表与办件轨迹的"抄送"行都以本表为数据源;
|
||||
* 同一办件多节点抄送/退回重走重复抄送各成一行(只增不改,轨迹按时间归位)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_cc",
|
||||
indexes = {
|
||||
@Index(name = "idx_flw_cc_user", columnList = "user_id, sent_time"),
|
||||
@Index(name = "idx_flw_cc_inst", columnList = "instance_id")})
|
||||
public class FlwCc extends BaseEntity {
|
||||
|
||||
/** 归属办件 */
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private Long instanceId;
|
||||
|
||||
/** 租户键(冗余,抄送列表主路径免 join) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 定义节点 id(JSON 里的 node.id) */
|
||||
@Column(name = "node_id", nullable = false, length = 64)
|
||||
private String nodeId;
|
||||
|
||||
/** 节点名快照 */
|
||||
@Column(name = "node_name", length = 128)
|
||||
private String nodeName;
|
||||
|
||||
/** 接收人 id */
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Integer userId;
|
||||
|
||||
/** 接收人姓名快照(免跨服务查名) */
|
||||
@Column(name = "user_name", length = 128)
|
||||
private String userName;
|
||||
|
||||
/** 抄送时间(=节点激活时刻)。毫秒精度(DATETIME(3)),与 flw_task 时间列同口径,轨迹交织排序用 */
|
||||
@Column(name = "sent_time", nullable = false, columnDefinition = "DATETIME(3)")
|
||||
private LocalDateTime sentTime;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import javax.persistence.*;
|
||||
/** Persistent admission mutex. Rows survive application deletion; never recycle. */
|
||||
@Entity @Table(name="flw_command_source") @Getter @Setter
|
||||
public class FlwCommandSource {
|
||||
@Id @Column(length=36) private String id;
|
||||
@Column(nullable=false) private Integer appId;
|
||||
}
|
||||
@@ -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,32 @@ 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;
|
||||
|
||||
/**
|
||||
* 线级停发标志(2026-10-25 T03):1=该 flowGuid 整线停发——阻止新发起/预览,不影响在途实例
|
||||
* (在途按实例固定 defId 原版推进)。与版本级 status(DISABLED) 正交:线级停发后 enable()
|
||||
* 恢复的只是版本状态,线仍不可新发起;enableLine 才恢复线可用性,且不改写任何发布内容字节。
|
||||
* 批量 UPDATE 全版本行同值,读任一行即读线状态。
|
||||
*/
|
||||
@Column(name = "line_offline", nullable = false, columnDefinition = "INT NOT NULL DEFAULT 0")
|
||||
private Integer lineOffline = 0;
|
||||
|
||||
/** §5.1 流程定义 JSON */
|
||||
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
|
||||
@@ -64,4 +94,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;
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存
|
||||
* biz_title/biz_url/biz_snapshot 快照与关联键;uk(sch_id, biz_type, biz_id) 幂等——
|
||||
* 同一业务对象一条办件,重复代发起返回原办件;驳回/撤回后重提复用同一行(R4 重启语义)。
|
||||
* biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为
|
||||
* (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。
|
||||
* sourcePrincipalId=NULL / legacySlot=1 仅保留迁移前旧入口的一业务一行语义;正式模式关闭旧写入口。
|
||||
*
|
||||
* <p>version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件),
|
||||
* 由 JPA @Version 在 flush 时自增。
|
||||
@@ -24,12 +25,19 @@ import java.time.LocalDateTime;
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_instance",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_instance_biz",
|
||||
columnNames = {"sch_id", "biz_type", "biz_id"}),
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_flw_instance_legacy", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "legacy_slot"}),
|
||||
@UniqueConstraint(name = "uk_flw_instance_round", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "apply_round"})},
|
||||
indexes = {
|
||||
@Index(name = "idx_flw_inst_submitter", columnList = "sch_id, submitter_id, status"),
|
||||
@Index(name = "idx_flw_inst_status_time", columnList = "sch_id, status, submit_time")})
|
||||
public class FlwInstance extends BaseEntity {
|
||||
@Column(name="source_principal_id",length=36,updatable=false)
|
||||
private String sourcePrincipalId;
|
||||
/** 1 for legacy writers, NULL for formal commands. Migration must drop the old three-column UK. */
|
||||
@Column(name="legacy_slot")
|
||||
private Integer legacySlot = 1;
|
||||
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
@@ -51,6 +59,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 +86,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 +136,48 @@ 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 起)。正式命令由服务端坐标锁内分配,不接受客户端 attempt 分轮。
|
||||
*
|
||||
* <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
|
||||
* 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p>
|
||||
*/
|
||||
@Column(name = "apply_round")
|
||||
private Integer applyRound;
|
||||
|
||||
/**
|
||||
* 受理时规范载荷指纹(2026-10-25 T04,SHA-256 十六进制):同 requestToken 必须同指纹——
|
||||
* 同 ID 不同载荷是两份命令,撞在同一个 token 上按冲突拒绝,不伪装成幂等成功。
|
||||
* 存量行(指纹列为 NULL)跳过该核对(守卫缺省是关,与 token/round 存量纪律一致)。
|
||||
*/
|
||||
@Column(name = "apply_payload_hash", length = 64)
|
||||
private String applyPayloadHash;
|
||||
|
||||
/**
|
||||
* 事件序号计数器(2026-10-25 T05):实例内事件事实 eventSeq 的发号器。发号时在事务内
|
||||
* 悲观锁住本行(findByIdForUpdate 当前读)再 +1——REPEATABLE READ 下对聚合做快照读会
|
||||
* 拿到陈旧 max(FlowEngineIT 会签并发实证),行上计数器+行锁才是串行安全的。
|
||||
*/
|
||||
@Column(name = "event_seq_counter")
|
||||
private Integer eventSeqCounter;
|
||||
|
||||
/**
|
||||
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
|
||||
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
|
||||
* 随实体序列化给前端画红标;业务代码不得读写该字段。
|
||||
*/
|
||||
@javax.persistence.Transient
|
||||
private Boolean routeHung;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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 java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 条件路由决策留痕(2026-09-24 二期第二批 §4.5):一次判断调用一行,只增不改——
|
||||
* 退回重走 CONDITION / 管理端重跑都是新行(attempt 递增),轨迹可看多轮判断。
|
||||
* outlet=NULL 表示本次调用失败(超时/非 2xx/未命中出口),办件挂起在该节点。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_route_log",
|
||||
indexes = @Index(name = "idx_flw_route_log_inst", columnList = "instance_id, node_id"))
|
||||
public class FlwRouteLog extends BaseEntity {
|
||||
|
||||
/** 租户键(冗余) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 归属办件 */
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private Long instanceId;
|
||||
|
||||
/** 定义节点 id(CONDITION) */
|
||||
@Column(name = "node_id", nullable = false, length = 64)
|
||||
private String nodeId;
|
||||
|
||||
/** 节点名快照 */
|
||||
@Column(name = "node_name", length = 128)
|
||||
private String nodeName;
|
||||
|
||||
/** 判断服务注册键快照 */
|
||||
@Column(name = "router_key", nullable = false, length = 64)
|
||||
private String routerKey;
|
||||
|
||||
/** 命中出口名;NULL=本次调用失败(挂起) */
|
||||
@Column(name = "outlet", length = 64)
|
||||
private String outlet;
|
||||
|
||||
/** 判断接口原始返回(审计;失败时为错误摘要) */
|
||||
@Column(name = "raw_response", columnDefinition = "TEXT")
|
||||
private String rawResponse;
|
||||
|
||||
/** 第几次判断(同 instance+node 从 1 递增:退回重走/管理端重跑都 +1) */
|
||||
@Column(name = "attempt", nullable = false)
|
||||
private Integer attempt = 1;
|
||||
|
||||
/** 判断完成时刻。DATETIME(3) 与 flw_task/flw_cc 时间列同口径(轨迹交织排序) */
|
||||
@Column(name = "decide_time", nullable = false, columnDefinition = "DATETIME(3)")
|
||||
private LocalDateTime decideTime;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 条件路由判断服务注册(2026-09-24 二期第二批 §4.2):CONDITION 节点按 routerKey 寻址
|
||||
* 外部判断接口——判断逻辑归业务方,引擎不做表达式求值,走"开放接口问结果"模式。
|
||||
* 全局一行(不分校):调用时带 schId,校内数据隔离由业务方查库自理。
|
||||
* 发布校验引用本表:CONDITION 节点的 routerKey 必须已注册且启用(status=1),
|
||||
* 运行时才发现无效只会挂起,发布期就拦。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_router",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_router_key", columnNames = "router_key"))
|
||||
public class FlwRouter extends BaseEntity {
|
||||
|
||||
/** 注册键(CONDITION 节点 routerKey 引用它,如 notice.dept-judge) */
|
||||
@Column(name = "router_key", nullable = false, length = 64)
|
||||
private String routerKey;
|
||||
|
||||
/** 展示名(编辑器下拉) */
|
||||
@Column(name = "name", nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
/** 服务目录键(如 notice):寻址 yh.call.targets > yh.open.services > 枚举兜底 */
|
||||
@Column(name = "service_key", nullable = false, length = 64)
|
||||
private String serviceKey;
|
||||
|
||||
/** 判断端点路径(如 /oapi/notice/flow/route/dept-judge) */
|
||||
@Column(name = "path", nullable = false, length = 255)
|
||||
private String path;
|
||||
|
||||
/**
|
||||
* 接收方 context-path(生产 /notice):签名拼串要剥,漏填必 401(规范 §4.2 同款坑)。
|
||||
* 空=沿用服务目录条目自带的前缀(目录完整 URL 形态已含 context-path)。
|
||||
*/
|
||||
@Column(name = "base_path", length = 64)
|
||||
private String basePath;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 流程状态事件事实(2026-10-25 T05):状态/审批动作与不可变事件事实<b>同一数据库事务</b>落库。
|
||||
*
|
||||
* <p>关闭审计 E24 的丢失窗口:旧链路 AFTER_COMMIT 才按订阅生成 eventId、另事务落回调行——
|
||||
* 提交到分发之间进程被杀,事件永久丢失(订阅方靠对账兜底)。本事实行在发布线程的同一事务内
|
||||
* 写入:状态提交成功 ⇔ 事件事实已持久化;回调分发只是它的消费,崩溃后由 sweep 扫描补分发。</p>
|
||||
*
|
||||
* <p>身份:{@code eventId} 在事实创建时一次性生成,<b>全部订阅方/全部投递重试复用同一 eventId</b>
|
||||
* (消费端 (event_id, consumer_app_key) 唯一键去重);{@code eventSeq} 为实例内<b>尽力单调</b>序号
|
||||
* (FOR UPDATE 发号器;REQUIRES_NEW 子事务推进拓扑下极端并发窗口可能同值——有序性以
|
||||
* eventSeq+发生时间为准,同实例严格全序需专用发号器,列入后续;不承诺全局副作用严格有序)。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_state_event",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_state_event_id", columnNames = {"event_id"}),
|
||||
indexes = {
|
||||
@Index(name = "idx_flw_state_event_instance", columnList = "instance_id"),
|
||||
@Index(name = "idx_flw_state_event_dispatch", columnList = "dispatched, create_time"),
|
||||
@Index(name = "idx_flw_state_event_seq", columnList = "instance_id, event_seq")})
|
||||
public class FlwStateEvent extends BaseEntity {
|
||||
|
||||
/** 稳定事件身份(32 位 uuid 去横线;投递与重试全链复用) */
|
||||
@Column(name = "event_id", nullable = false, length = 32)
|
||||
private String eventId;
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 实例身份 */
|
||||
@Column(name = "instance_id", nullable = false)
|
||||
private Long instanceId;
|
||||
|
||||
/** 发布时冻结的申报轮次(旧事件不得被 AFTER_COMMIT 重读贴上新轮标签) */
|
||||
@Column(name = "apply_round")
|
||||
private Integer applyRound;
|
||||
|
||||
/** 实例内单调序号(1 起;同实例状态行锁内取 max+1) */
|
||||
@Column(name = "event_seq", nullable = false)
|
||||
private Integer eventSeq;
|
||||
|
||||
/** 事件类型码(FlowEvent.Type#getCode,与回调订阅枚举同值域) */
|
||||
@Column(name = "event_type", nullable = false, length = 32)
|
||||
private String eventType;
|
||||
|
||||
/** 关联任务(办件级事件为 null) */
|
||||
@Column(name = "task_id")
|
||||
private Long taskId;
|
||||
|
||||
/** 操作人(可空) */
|
||||
@Column(name = "operator_id")
|
||||
private Integer operatorId;
|
||||
|
||||
/** 完整载荷快照(与回调信封同构 JSON;发生时状态,不随后续变化) */
|
||||
@Column(name = "payload_json", nullable = false, columnDefinition = "LONGTEXT")
|
||||
private String payloadJson;
|
||||
|
||||
/**
|
||||
* 分发标记:0=待分发(含崩溃窗口恢复);1=已按当前订阅生成了全部回调行(或无可订阅方)。
|
||||
* 分发幂等:重扫前按 (eventId, regId) 查重,不重复建投递行。
|
||||
*/
|
||||
@Column(name = "dispatched", nullable = false)
|
||||
private Integer dispatched = 0;
|
||||
}
|
||||
@@ -62,10 +62,14 @@ public class FlwTask extends BaseEntity {
|
||||
@Column(name = "edge_name", length = 64)
|
||||
private String edgeName;
|
||||
|
||||
@Column(name = "arrive_time", nullable = false)
|
||||
/**
|
||||
* 到达时间。毫秒精度(DATETIME(3),2026-09-23):秒级分不出"发起→抄送→下一节点激活"
|
||||
* 这类亚秒机器链的因果序,轨迹/progress 交织排序依赖它;显示层仍截断到秒。
|
||||
*/
|
||||
@Column(name = "arrive_time", nullable = false, columnDefinition = "DATETIME(3)")
|
||||
private LocalDateTime arriveTime;
|
||||
|
||||
@Column(name = "handle_time")
|
||||
@Column(name = "handle_time", columnDefinition = "DATETIME(3)")
|
||||
private LocalDateTime handleTime;
|
||||
|
||||
/** 办理时限(一期自然时间,定义方自行折算工作日——拍板项#4) */
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.yunhai.flowcenter.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
|
||||
/**
|
||||
* 应用使用授权(2026-10-25 T01):哪个<b>稳定应用主体</b>可以在哪所学校使用哪条流程线。
|
||||
*
|
||||
* <p>与 {@link FlwApp}(定义保存白名单)和流程定义管理权(FlowAdminGuard 管理角色)三权分立:
|
||||
* 有 flw_app 行不等于能新发起;是应用管理员也不等于能编辑/发布学校流程定义。
|
||||
* 许可键是三元 {@code (schId, flowGuid, appId)}——appId 是 UserBase application_info 主键
|
||||
* (稳定主体,appKey 可重置/注销后不复用历史来源),appKey 快照仅供展示回溯。</p>
|
||||
*
|
||||
* <p>撤权语义:{@code enabled=0} 只拦截<b>新发起</b>;已受理命令的回执恢复、在途实例推进
|
||||
* 不回读本表(P0-3 收口顺序:认证 → 自有命令回执 → 未命中才查新发起资格)。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "flw_usage_grant",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_flw_usage_grant",
|
||||
columnNames = {"sch_id", "flow_guid", "app_id"}),
|
||||
indexes = {@Index(name = "idx_flw_usage_grant_sch_flow", columnList = "sch_id, flow_guid"),
|
||||
@Index(name = "idx_flw_usage_grant_app", columnList = "app_id")})
|
||||
public class FlwUsageGrant extends BaseEntity {
|
||||
@Column(name="source_principal_id",length=36)
|
||||
private String sourcePrincipalId;
|
||||
|
||||
|
||||
/** 租户键 */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Long schId;
|
||||
|
||||
/** 流程线稳定身份(跨版本不变;版本归属由 flw_def 承载) */
|
||||
@Column(name = "flow_guid", nullable = false, length = 64)
|
||||
private String flowGuid;
|
||||
|
||||
/** 稳定应用主体主键(UserBase application_info.id;非 appKey 凭据索引) */
|
||||
@Column(name = "app_id", nullable = false)
|
||||
private Integer appId;
|
||||
|
||||
/** 授权时的 appKey 快照(展示/审计回溯用;判权不读它) */
|
||||
@Column(name = "app_key_snapshot", length = 64)
|
||||
private String appKeySnapshot;
|
||||
|
||||
/** 应用名快照(授权时 UserBase 返回值;同步不自动刷新) */
|
||||
@Column(name = "app_name_snapshot", length = 128)
|
||||
private String appNameSnapshot;
|
||||
|
||||
/** 1=许可生效;0=撤销(只拦新发起) */
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private Integer enabled = 1;
|
||||
|
||||
/** 备注(谁/为何授予或撤销,人工审计线索) */
|
||||
@Column(name = "remark", length = 255)
|
||||
private String remark;
|
||||
}
|
||||
@@ -8,9 +8,18 @@ public final class FlwEnums {
|
||||
private FlwEnums() {
|
||||
}
|
||||
|
||||
/** 办件状态:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 */
|
||||
/**
|
||||
* 办件状态:10 审批中 / 20 已完成 / 30 已拒绝 / 40 已撤回 / 50 退回待修改。
|
||||
*
|
||||
* <p><b>50 是 D14「退回发起人修改后重提」的等待态</b>(2026-10-29):审批人把办件退回发起人后,
|
||||
* 实例仍在同一轮次、同一实例上等待发起人重提,<b>不是终态</b>——不能复用 20/30/40 表达,
|
||||
* 否则"退回待修改"会被 R4 重启、幂等回读与在途统计误判成新一轮申报。
|
||||
* 10 与 50 一起构成"在途"口径(见 {@link #inFlightCodes()})。</p>
|
||||
*/
|
||||
public enum InstanceStatus {
|
||||
APPROVING(10), COMPLETED(20), REJECTED(30), WITHDRAWN(40);
|
||||
APPROVING(10), COMPLETED(20), REJECTED(30), WITHDRAWN(40),
|
||||
/** 50 已退回发起人,等待修改后重提(在途,非终态) */
|
||||
RETURNED_TO_SUBMITTER(50);
|
||||
|
||||
private final int code;
|
||||
|
||||
@@ -21,6 +30,11 @@ public final class FlwEnums {
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** 在途状态集(10 审批中、50 退回待修改):撤回守卫、在途统计、幂等回读共用口径 */
|
||||
public static java.util.List<Integer> inFlightCodes() {
|
||||
return java.util.Arrays.asList(APPROVING.getCode(), RETURNED_TO_SUBMITTER.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +62,10 @@ 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";
|
||||
/** D14 保留节点 id:退回发起人修改的去向(定义模型里不存在,示意图着色/退回目标解析天然跳过) */
|
||||
public static final String NODE_RETURN_SUBMITTER = "submitter";
|
||||
|
||||
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,91 @@
|
||||
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;
|
||||
/**
|
||||
* 期望的前一轮次(T04 服务端轮次,可选):显式重提必须声明基于第 N 轮;
|
||||
* 服务端在坐标锁内核对并只分配 N+1,不信客户端 attempt。null=既有语义。
|
||||
*/
|
||||
private Integer expectedPrevRound;
|
||||
}
|
||||
|
||||
/** 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";
|
||||
/**
|
||||
* 冲突(2026-10-25 T04):同 token 异载荷、或带 token 撞上他人/其他轮次的在途与通过件——
|
||||
* 调用方(OapiFlowService)把该分支翻译成业务异常,不伪装幂等成功。
|
||||
*/
|
||||
public static final String CONFLICT = "CONFLICT";
|
||||
|
||||
private Branch() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,153 +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;
|
||||
}
|
||||
|
||||
/** 办件详情(办理页/详情页数据源) */
|
||||
@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;
|
||||
/** 画布坐标(nodeId→{x,y},设计器保存时透传;空=查看器自行自动布局) */
|
||||
private Map<String, Map<String, Integer>> layout;
|
||||
}
|
||||
|
||||
/** 示意图节点(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yunhai.flowcenter.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Service-owned v3 contract. No shared SDK DTO changes. */
|
||||
public final class FormalApplyModels {
|
||||
private FormalApplyModels() { }
|
||||
@Getter @Setter
|
||||
public static class Request extends OapiModels.ApplyBody {
|
||||
private String requestId;
|
||||
private Long expectedPreviousInstanceId;
|
||||
private Integer expectedPreviousRound;
|
||||
}
|
||||
@Getter @Setter
|
||||
public static class Receipt {
|
||||
private String state;
|
||||
private String requestId;
|
||||
private Long schoolId;
|
||||
private String sourcePrincipalId;
|
||||
private String canonicalHash;
|
||||
private Integer canonicalVersion = 1;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String flowGuid;
|
||||
private Long instanceId;
|
||||
private Integer round;
|
||||
private Integer defVersion;
|
||||
private Integer status;
|
||||
private Integer version;
|
||||
private String rejectCode;
|
||||
private String rejectDetail;
|
||||
}
|
||||
}
|
||||
@@ -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,13 @@ public final class OapiModels {
|
||||
@Data
|
||||
public static class InstanceVo {
|
||||
private Long instanceId;
|
||||
private Integer applyRound;
|
||||
private String requestToken;
|
||||
/**
|
||||
* 来源应用快照(2026-10-25 T04):UNKNOWN 恢复链按"稳定来源+requestToken"核验命令归属的
|
||||
* 依据——查得实例但 applyAppKey 与本应用不符=他人办件,不得认领(P0-4 关闭点)。
|
||||
*/
|
||||
private String applyAppKey;
|
||||
private Integer status;
|
||||
private Integer version;
|
||||
private String nodeSummary;
|
||||
@@ -88,6 +149,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 步。
|
||||
@@ -145,4 +222,97 @@ public final class OapiModels {
|
||||
/** 相对 token 有效期(秒) */
|
||||
private long expiresIn;
|
||||
}
|
||||
|
||||
/** 办件进度嵌入页换址响应(pageUrl 可直接放 <iframe src>,SDK FlowEmbedUrl 同构) */
|
||||
@Data
|
||||
public static class EmbedUrlVo {
|
||||
/** 嵌入页绝对地址(flow-center 前端基址 + hash 路由 + 短时 token) */
|
||||
private String pageUrl;
|
||||
/** 相对 token 有效期(秒) */
|
||||
private long expiresIn;
|
||||
}
|
||||
|
||||
/** 待办任务行(tasks/todo 元素):审批列表页数据源,带办件定位与快照 */
|
||||
@Data
|
||||
public static class TodoTaskVo {
|
||||
private Long taskId;
|
||||
private Long instanceId;
|
||||
/** 办件当前版本(办理前对账用) */
|
||||
private Integer instanceVersion;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
/** 办件快照原文(JSON 字符串,发起时未传则 null) */
|
||||
private String bizSnapshot;
|
||||
private String nodeId;
|
||||
private String nodeName;
|
||||
private Integer assigneeId;
|
||||
private String assigneeName;
|
||||
private String arriveTime;
|
||||
}
|
||||
|
||||
/** 办件行(instances 元素):应用侧列表/对账数据源 */
|
||||
@Data
|
||||
public static class InstanceItemVo {
|
||||
private Long instanceId;
|
||||
private Integer status;
|
||||
private Integer version;
|
||||
private String bizType;
|
||||
private String bizId;
|
||||
private String bizTitle;
|
||||
private String bizUrl;
|
||||
private String submitterName;
|
||||
private String submitTime;
|
||||
private String finishTime;
|
||||
private String currentNodes;
|
||||
}
|
||||
|
||||
/** 办理入参(approve/reject 请求体;opinion 可空,拒绝侧服务层强制必填) */
|
||||
@Data
|
||||
public static class HandleBody {
|
||||
private String opinion;
|
||||
/** 多出口节点办理时所选出口名(单出口可省) */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,17 @@ public class UserMsgClient {
|
||||
return directoryResolved;
|
||||
}
|
||||
|
||||
/** 发送站内信(content 必填;msgNo 幂等,重发安全) */
|
||||
/**
|
||||
* 发送站内信(content 必填;msgNo 幂等,重发安全)。
|
||||
*
|
||||
* @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 msgNo,
|
||||
Integer expireDays) {
|
||||
String bizId, String title, String content, String jumpPath, String jumpApp,
|
||||
String msgNo, Integer expireDays) {
|
||||
if (!enabled || userIds == null || userIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -82,6 +89,7 @@ public class UserMsgClient {
|
||||
body.setJumpType(1);
|
||||
body.setJumpPath(jumpPath);
|
||||
}
|
||||
body.setJumpApp(jumpApp);
|
||||
body.setMsgNo(msgNo);
|
||||
body.setSchId(schId);
|
||||
body.setExpireDays(expireDays);
|
||||
|
||||
@@ -15,10 +15,14 @@ import java.util.Collections;
|
||||
|
||||
/**
|
||||
* 站内信/待办角标通知(概览 §七 契约,回调通知"业务系统"、本类通知"人",两通道互不替代):
|
||||
* - 任务到达:msgType=2、bizType=flow.task、msgNo=task:{taskId}、jumpPath=/task/{taskId}、todo +1
|
||||
* - 任务到达:msgType=2、bizType=flow.task、msgNo=task:{taskId}、jumpPath=bizUrl 优先(空回落
|
||||
* /task/{taskId})、todo +1
|
||||
* - 任务被办理: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 内部容错)。
|
||||
*/
|
||||
@@ -46,6 +50,9 @@ public class UserMsgNotifier {
|
||||
case TRANSFERRED:
|
||||
notifyTaskCreated(event);
|
||||
break;
|
||||
case CC_SENT:
|
||||
notifyCc(event);
|
||||
break;
|
||||
case COMPLETED:
|
||||
case REJECTED:
|
||||
case WITHDRAWN:
|
||||
@@ -68,10 +75,17 @@ public class UserMsgNotifier {
|
||||
String title = "您有新的待办:" + safe(instance.getBizTitle());
|
||||
String content = "「" + safe(instance.getBizTitle()) + "」等待您审批"
|
||||
+ (task.getNodeName() == null ? "" : "(" + task.getNodeName() + ")");
|
||||
// 跳转路径:办件自带 bizUrl(业务方详情页,改造后为裸相对路径)优先——应用内审批免跳
|
||||
// 流程中心,页面归属=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,
|
||||
"/task/" + task.getId(), "task:" + task.getId(), 30);
|
||||
jumpPath, jumpApp, "task:" + task.getId(), 30);
|
||||
userMsgClient.todoInc(instance.getSchId().intValue(),
|
||||
Collections.singletonList(task.getAssigneeId()), 1);
|
||||
}
|
||||
@@ -112,10 +126,37 @@ public class UserMsgNotifier {
|
||||
userMsgClient.sendUserMsg(instance.getSchId().intValue(), targets, 1,
|
||||
"flow.returned", String.valueOf(instance.getId()),
|
||||
"办件被退回:" + safe(instance.getBizTitle()), content.toString(),
|
||||
"/instance/" + instance.getId(),
|
||||
// F8 缺口①:退回消息与待办/抄送同策略——业务方详情页优先、归属申报给 apply 调用方
|
||||
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance),
|
||||
"returned:" + instance.getId() + ":" + instance.getVersion(), 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* 抄送(2026-09-23 NOTIFY 节点):msgType=1 通知类(待办才是 2),bizType=flow.cc、
|
||||
* msgNo=cc:{ccId}(逐人逐节点一行一消息,重走重复抄送不互吞);跳转与任务到达同策略
|
||||
* (bizUrl 优先,空回落办件详情)。★ 不调 todoInc——知会不占待办角标、不进待办列表。
|
||||
*/
|
||||
private void notifyCc(FlowEvent event) {
|
||||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||||
Object ccUserId = event.getData() == null ? null : event.getData().get("ccUserId");
|
||||
if (instance == null || ccUserId == null) {
|
||||
return;
|
||||
}
|
||||
Object nodeName = event.getData().get("nodeName");
|
||||
Object ccId = event.getData().get("ccId");
|
||||
String bizTitle = safe(instance.getBizTitle());
|
||||
String content = "「" + bizTitle + "」抄送给您"
|
||||
+ (nodeName == null || String.valueOf(nodeName).isEmpty()
|
||||
? "" : "(" + nodeName + ")");
|
||||
// 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=apply 调用方 applyAppCode)优先,
|
||||
// 空回落办件详情(属自己)
|
||||
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
|
||||
Collections.singletonList((Integer) ccUserId), 1,
|
||||
"flow.cc", String.valueOf(instance.getId()),
|
||||
"您有新的抄送:" + bizTitle, content,
|
||||
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance), "cc:" + ccId, 30);
|
||||
}
|
||||
|
||||
private void notifyResult(FlowEvent event) {
|
||||
FlwInstance instance = instanceOf(event.getInstanceId());
|
||||
if (instance == null) {
|
||||
@@ -147,7 +188,9 @@ public class UserMsgNotifier {
|
||||
Collections.singletonList(instance.getSubmitterId()), 1,
|
||||
"flow.result", String.valueOf(instance.getId()),
|
||||
"办件状态更新:" + bizTitle, content.toString(),
|
||||
"/instance/" + instance.getId(),
|
||||
// F8 缺口①:结果消息与待办/抄送同策略——业务方详情页优先(子应用详情页 iframe
|
||||
// 显终态进度),jumpApp 申报给 apply 调用方;bizUrl 空仍回落中心办件详情
|
||||
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance),
|
||||
"result:" + instance.getId() + ":" + instance.getVersion(), 30);
|
||||
}
|
||||
|
||||
@@ -165,7 +208,24 @@ public class UserMsgNotifier {
|
||||
Collections.singletonList(task.getAssigneeId()), 1,
|
||||
"flow.urge", String.valueOf(instance.getId()),
|
||||
"催办提醒:" + safe(instance.getBizTitle()), content,
|
||||
"/task/" + task.getId(), "urge:" + task.getId() + ":" + count, 7);
|
||||
"/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) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
import com.yunhai.flowcenter.entity.FlwAdvanceIntent;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import javax.persistence.LockModeType;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FlwAdvanceIntentRepository extends JpaRepository<FlwAdvanceIntent, Long> {
|
||||
|
||||
/** 到点待执行:PENDING 且(无退避或已到期) */
|
||||
@Query(value="select * from flw_advance_intent where status=:status "
|
||||
+ "and (next_retry_at is null or next_retry_at<=:now) order by create_time,id limit 50", nativeQuery=true)
|
||||
List<FlwAdvanceIntent> findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
|
||||
@Param("status") String status, @Param("now") LocalDateTime now);
|
||||
|
||||
List<FlwAdvanceIntent> findByInstanceIdAndNodeIdOrderByIdDesc(Long instanceId, String nodeId);
|
||||
|
||||
Optional<FlwAdvanceIntent> findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(
|
||||
Long instanceId, String nodeId);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select i from FlwAdvanceIntent i where i.id=:id")
|
||||
Optional<FlwAdvanceIntent> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
/** 幂等记账:同实例同节点是否已有未完成意图(办理事务内去重,避免并发办理堆积重复行) */
|
||||
@Query("select count(i) > 0 from FlwAdvanceIntent i where i.instanceId = :instanceId "
|
||||
+ "and i.nodeId = :nodeId and i.status = 'PENDING'")
|
||||
boolean existsPending(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
import com.yunhai.flowcenter.entity.FlwApplyCommand;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.Optional;
|
||||
public interface FlwApplyCommandRepository extends JpaRepository<FlwApplyCommand,Long> {
|
||||
boolean existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(Long school, String source, String flow, String state);
|
||||
Optional<FlwApplyCommand> findBySchIdAndSourcePrincipalIdAndRequestId(Long schId,String sourcePrincipalId,String requestId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
import com.yunhai.flowcenter.entity.FlwBizCoordinate;
|
||||
import org.springframework.data.jpa.repository.*;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import javax.persistence.LockModeType;
|
||||
import java.util.Optional;
|
||||
public interface FlwBizCoordinateRepository extends JpaRepository<FlwBizCoordinate,Long> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select c from FlwBizCoordinate c where c.schId=:sch and c.sourcePrincipalId=:source and c.bizType=:type and c.bizId=:biz")
|
||||
Optional<FlwBizCoordinate> lock(@Param("sch") Long sch,@Param("source") String source,@Param("type") String type,@Param("biz") String biz);
|
||||
@Modifying
|
||||
@Query("update FlwBizCoordinate c set c.activeInstanceId=null where c.schId=:sch and c.sourcePrincipalId=:source and c.bizType=:type and c.bizId=:biz and c.activeInstanceId=:instance and c.lastRound=:round")
|
||||
int release(@Param("sch") Long sch,@Param("source") String source,@Param("type") String type,@Param("biz") String biz,@Param("instance") Long instance,@Param("round") Integer round);
|
||||
}
|
||||
@@ -15,7 +15,8 @@ public interface FlwBranchRepository extends JpaRepository<FlwBranch, Long> {
|
||||
|
||||
Optional<FlwBranch> findByInstanceIdAndBranchKey(Long instanceId, String branchKey);
|
||||
|
||||
long countByInstanceIdAndStatus(Long instanceId, Integer status);
|
||||
/** scope 级放行判断用(§4.6):取全量进行中行后按前缀过滤,行数少无需再下钻查询 */
|
||||
List<FlwBranch> findByInstanceIdAndStatus(Long instanceId, Integer status);
|
||||
|
||||
@Modifying
|
||||
@Query("update FlwBranch b set b.status = 2 where b.instanceId = :instanceId and b.status = 0")
|
||||
|
||||
@@ -13,6 +13,9 @@ import java.util.List;
|
||||
|
||||
public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog, Long> {
|
||||
|
||||
/** T05 投递幂等:同一事件事实对同一订阅方只建一行(补分发与正常分发竞争时去重) */
|
||||
boolean existsByEventIdAndRegId(String eventId, Long regId);
|
||||
|
||||
/** 重推扫描主路径:待推/重试中且到点 */
|
||||
List<FlwCallbackLog> findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc(
|
||||
Integer status, LocalDateTime now);
|
||||
@@ -35,4 +38,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
|
||||
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 索引;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 i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) order by c.sentTime desc")
|
||||
Page<FlwCc> pageCc(@Param("schId") Long schId, @Param("userId") Integer userId,
|
||||
@Param("appKey") String appKey, Pageable pageable);
|
||||
|
||||
/** 办件轨迹/进度留痕 */
|
||||
List<FlwCc> findByInstanceIdOrderByIdAsc(Long instanceId);
|
||||
|
||||
/** 可见性守卫用(F7 前置补丁):本办件是否抄送过该人——只读可见,不授予办理权 */
|
||||
boolean existsByInstanceIdAndUserId(Long instanceId, Integer userId);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yunhai.flowcenter.repository;
|
||||
import com.yunhai.flowcenter.entity.FlwCommandSource;
|
||||
import org.springframework.data.jpa.repository.*;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import javax.persistence.LockModeType;
|
||||
import java.util.Optional;
|
||||
public interface FlwCommandSourceRepository extends JpaRepository<FlwCommandSource,String> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select s from FlwCommandSource s where s.id=:id")
|
||||
Optional<FlwCommandSource> lock(@Param("id") String id);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -35,9 +32,22 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
|
||||
|
||||
List<FlwDef> findBySchIdOrderByUpdateTimeDesc(Long schId);
|
||||
|
||||
/** 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本 */
|
||||
@Query("select d from FlwDef d where d.schId = :schId and d.status = 1 "
|
||||
/**
|
||||
* 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本。
|
||||
* 2026-10-25 T03:外层追加 {@code lineOffline=0}——线级停发的整线不出现在新发起预览源,
|
||||
* 且不存在"停发后回退旧版"的形态(内层仍是该线已发布最高版本,停发不改变版本事实)。
|
||||
*/
|
||||
@Query("select d from FlwDef d where d.schId = :schId and d.status = 1 and d.lineOffline = 0 "
|
||||
+ "and d.version = (select max(d2.version) from FlwDef d2 where d2.schId = d.schId "
|
||||
+ "and d2.flowGuid = d.flowGuid and d2.status = 1) order by d.updateTime desc")
|
||||
List<FlwDef> findPublishedLatestPerGuid(@Param("schId") Long schId);
|
||||
|
||||
/** 线级停发/恢复(T03):批量 UPDATE 全版本行同值;返回受影响行数 */
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@Query("update FlwDef d set d.lineOffline = :offline where d.schId = :schId and d.flowGuid = :flowGuid")
|
||||
int updateLineOffline(@Param("schId") Long schId, @Param("flowGuid") String flowGuid,
|
||||
@Param("offline") Integer offline);
|
||||
|
||||
/** 在途统计(议题二④):一次取回多条流程线的全部版本行 */
|
||||
List<FlwDef> findBySchIdAndFlowGuidIn(Long schId, Collection<String> flowGuids);
|
||||
}
|
||||
|
||||
@@ -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,26 +7,91 @@ 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;
|
||||
|
||||
public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long> {
|
||||
@Query(value = "select id from flw_instance where sch_id=:school and source_principal_id=:source "
|
||||
+ "and biz_type=:type and biz_id=:biz and legacy_slot is null for update", nativeQuery = true)
|
||||
List<Long> formalIdsForUpdate(@Param("school") Long school, @Param("source") String source,
|
||||
@Param("type") String type, @Param("biz") String biz);
|
||||
@org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select i from FlwInstance i where i.id=:id")
|
||||
Optional<FlwInstance> findFormalPreviousForUpdate(@Param("id") Long id);
|
||||
|
||||
/** biz uk 幂等主键 */
|
||||
Optional<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
|
||||
|
||||
/**
|
||||
* T05 事件序号发号(2026-10-25 FlowEngineIT 会签并发实证三轮修正,终形态):
|
||||
* ①FOR UPDATE 标量当前读发号(取行 X 锁,任何隔离级别都看到已提交最新值)→
|
||||
* ②按读到的值回写计数器(值已在我锁内计算,无竞态)→③事实行按该值落库。
|
||||
* 纯快照读 max+1 会撞 uk;实体 merge 会触发 @Version 冲突;自增 UPDATE 后普通 SELECT
|
||||
* 的读回在并发窗口同样不可靠——FOR UPDATE 标量读是唯一两全形态。
|
||||
*/
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
value = "select coalesce(event_seq_counter, 0) + 1 from flw_instance where id = :id for update",
|
||||
nativeQuery = true)
|
||||
Integer nextEventSeqForUpdate(@Param("id") Long id);
|
||||
|
||||
@org.springframework.data.jpa.repository.Modifying
|
||||
@org.springframework.data.jpa.repository.Query(
|
||||
value = "update flw_instance set event_seq_counter = :v where id = :id", nativeQuery = true)
|
||||
int setEventSeqCounter(@Param("id") Long id, @Param("v") Integer v);
|
||||
|
||||
Optional<FlwInstance> findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByIdDesc(
|
||||
Long school, String source, String type, String biz);
|
||||
|
||||
@Query("select i from FlwInstance i where i.schId=:school and i.sourcePrincipalId=:source and (:bizType is null or i.bizType=:bizType) and (:status is null or i.status=:status) order by i.id desc")
|
||||
Page<FlwInstance> pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable);
|
||||
|
||||
/**
|
||||
* D11/F02 业务坐标主查询:<b>可信来源应用 + 学校 + 业务类型 + 业务 ID + 入口槽位</b>。
|
||||
* 与 {@code uk_flw_instance_legacy} 逐列同构——应用 A 的在途件不会因为撞上应用 B 的同名业务键
|
||||
* 而被 B 读到、复用或拿到 A 的 instanceId。
|
||||
*
|
||||
* <p>原 3 参 {@code findBySchIdAndBizTypeAndBizId}(只按裸坐标、且限定 {@code sourcePrincipalId is null})
|
||||
* 已删除:它既可能把他人办件回给本应用,也读不到改造后的新行。</p>
|
||||
*/
|
||||
Optional<FlwInstance> findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||
Long schId, String sourcePrincipalId, String bizType, String bizId, Integer legacySlot);
|
||||
|
||||
List<FlwInstance> findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status);
|
||||
|
||||
/** 我提交的办件(分页,状态可空) */
|
||||
@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);
|
||||
|
||||
/**
|
||||
* 跨来源审计读:同一裸业务坐标在本校的全部归属行(不参与幂等复用,只用于"这个业务键被谁占了"
|
||||
* 的核查与测试断言零状态)。幂等与归属判定一律走上面的来源分区方法。
|
||||
*/
|
||||
List<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
|
||||
|
||||
/** 在途办件数(议题二④):按 def_id 分组统计在途(10 审批中 / 50 退回待修改)办件,一行一版本 */
|
||||
@Query("select i.defId, count(i) from FlwInstance i where i.schId = :schId and i.defId in :defIds "
|
||||
+ "and i.status in (10, 50) group by i.defId")
|
||||
List<Object[]> countApprovingByDefIds(@Param("schId") Long schId, @Param("defIds") Collection<Long> defIds);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user