Compare commits

8 Commits
Author SHA1 Message Date
chenxing b369527fe1 test(flow): 补齐 F01 来源主体与使用许可装配(版本守卫/按业务键契约两类的测试夹具)
build / build-push (push) Successful in 2m3s
2026-10-28 12:31:20 +08:00
chenxing 8a1294aef2 fix(security): F01 授权门禁不可被配置关闭 + F02 业务坐标按可信来源隔离(含唯一键 SQL)
build / build-push (push) Failing after 1m1s
2026-10-28 11:46:20 +08:00
chenxing 302216a07c fix(flow): 修正线级停发/恢复守卫取反缺陷 + 真实 Service 定向回归
build / build-push (push) Successful in 2m0s
requireLine() 存在性判断取反,导致已存在的流程线被拒绝、不存在的线反被放行,
线级停发/恢复线上 100% 失败。改为查询为空时拒绝。

- FlowDefAdminService.java:302 补 ! 号(唯一生产代码改动)
- FlDefLineOfflineTest 改用真实 FlowDefAdminService(删除业务副本 spy),补非法 GUID/空值/空白/错误学校/trim/在途隔离断言
- FlNewInstancePathTest 新增新实例路径定向测试
2026-10-28 03:18:43 +08:00
chenxing 8d4a0bdba8 fix(ci): 反向门禁不再写死用例总数,改钉「缺表必报 error、断言不得失败」
build / build-push (push) Successful in 3m46s
verify-failure-guard.sh 原断言固定 'Tests run: 92, Failures: 0, Errors: 11',
而本候选门禁类实跑 101 例(D10/D11 新增用例),写死总数会让正常演进直接红灯、
阻断镜像产出。改为解析最后一行 Surefire 汇总并断言 tests>0 / failures=0 / errors>0,
保留“空 schema 必须阻断打包且不产 JAR”的原意。

本地等价实跑(隔离 MySQL 8.0.45,ddl-auto=validate,空 schema):
NEGATIVE_MVN_EXIT=1,NEGATIVE_SUMMARY tests=101 failures=0 errors=11 skipped=0,
CI_EMPTY_SCHEMA_FAILURE_GUARD=PASS。
2026-10-27 12:11:10 +08:00
chenxing 500a9a2fcb release: FL 2.0 候选(C01 记账边界+P2-2 认领协议+D10 变量契约+D11 资格终审+D09 激活代次+T01/T03/T04/T05)
按《FL最终代码审计与上线阻断清单》194 项已审计工作树形成候选,基座 = origin/main 11e0778。

- 源码/测试/资源:沿用已审计工作树内容(96 项相对远端为新增或更新),含未跟踪实现
  CallbackFactStore、CallbackSourcePolicy、VariableContract、FormalApplyCommandService、
  FlowActorContextGate 等;C01 续验夹具修正一并入内。
- CI:保留远端 11e0778 的门禁强化(CI_SOURCE_SHA 核对、容器回收验证、空 schema 反向门禁、
  main ref 才推镜像);ci/flow-test-schema.sql 按当前实体重新生成(14→20 表,补 6 新表与
  6 新列,回调日志复合唯一键 uk_flw_log_event_reg),workflow 表数断言同步 14→20。
- 依据:docs/tasks/evidence/FL-final-audit-20261027(194 项哈希/版本核对)。
2026-10-27 12:05:08 +08:00
chenxing 11e077867b Run mandatory RC gates with isolated CI failure checks
build / build-push (push) Successful in 4m2s
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:52:02 +08:00
chenxing f7819b14e2 Carry actual flow state and freeze callback round identity
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:52:00 +08:00
chenxing 7eeaea55f8 Enforce exact published form version contracts
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:47 +08:00
132 changed files with 16336 additions and 774 deletions
+55 -2
View File
@@ -9,13 +9,17 @@ name: build
on: on:
push: push:
branches: [ main ] branches: [ main, codex/core-rc-20261023 ]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
cache_bust: cache_bust:
description: '构建前清空 maven m2 缓存' description: '构建前清空 maven m2 缓存'
required: false required: false
default: 'false' default: 'false'
tests_only:
description: '只验证测试和 JAR,不构建推送镜像或通知建档'
required: false
default: 'true'
jobs: jobs:
build-push: build-push:
@@ -36,6 +40,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
git -C /tmp/src fetch --depth 1 origin "$GITHUB_SHA"
git -C /tmp/src checkout --detach FETCH_HEAD
test "$(git -C /tmp/src rev-parse HEAD)" = "$GITHUB_SHA"
echo "CI_SOURCE_SHA=$GITHUB_SHA"
cp -a /tmp/src/. . cp -a /tmp/src/. .
rm -rf /tmp/src rm -rf /tmp/src
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符 # 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
@@ -64,6 +72,7 @@ jobs:
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。 # GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)" BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}" 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 rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \ docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \ --network infra-infra-net \
@@ -71,13 +80,56 @@ jobs:
-w /build maven:3.6.3-jdk-8 sleep 3600 -w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/ 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" 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"
docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package' # 为真实 MySQL 测试创建本次 job 独占的临时数据库;不映射宿主端口、不连接业务库。
DB_CONTAINER="${BUILD_CONTAINER}-db"
cleanup_ci() {
result=$?
trap - EXIT
docker rm -fv "$BUILD_CONTAINER" "$DB_CONTAINER" >/dev/null 2>&1 || true
docker info >/dev/null 2>&1 || { echo 'CI_CONTAINER_CLEANUP=UNVERIFIED' >&2; exit 1; }
if docker inspect "$BUILD_CONTAINER" >/dev/null 2>&1 || docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
echo 'CI_CONTAINER_CLEANUP=FAILED' >&2
exit 1
fi
echo 'CI_CONTAINER_CLEANUP=PASS'
exit "$result"
}
trap cleanup_ci EXIT
export MYSQL_ROOT_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
export MYSQL_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
docker create --name "$DB_CONTAINER" --network infra-infra-net \
-e MYSQL_DATABASE=flowcenter_ci -e MYSQL_USER=flow_ci \
-e MYSQL_PASSWORD -e MYSQL_ROOT_PASSWORD mysql:8.0 --skip-log-bin >/dev/null
docker cp "${PWD}/ci/flow-test-schema.sql" "$DB_CONTAINER":/docker-entrypoint-initdb.d/001-schema.sql
docker start "$DB_CONTAINER" >/dev/null
ready=0
for n in $(seq 1 60); do
if docker exec "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -h127.0.0.1 -u"$MYSQL_USER" "$MYSQL_DATABASE" -Nse "SELECT 1" >/dev/null 2>&1'; then ready=1; break; fi
sleep 2
done
[ "$ready" -eq 1 ] || { echo 'Isolated CI MySQL did not become ready' >&2; exit 1; }
export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
export CI_TEST_DB_USER=flow_ci CI_TEST_DB_PASSWORD="$MYSQL_PASSWORD"
tables=$(docker exec "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -h127.0.0.1 -u"$MYSQL_USER" "$MYSQL_DATABASE" -Nse "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()"')
[ "$tables" = 20 ] || { echo "Expected 20 CI schema tables, got $tables" >&2; exit 1; }
echo "CI_SCHEMA_TABLES=$tables"
# 先在独立空 schema 验证真实 validate 失败会阻断打包;随后再生成正向候选 JAR。
printf '%s\n' 'CREATE DATABASE flowcenter_ci_invalid;' "GRANT ALL ON flowcenter_ci_invalid.* TO 'flow_ci'@'%';" | \
docker exec -i "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot'
positive_db_url="$CI_TEST_DB_URL"
export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci_invalid?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \
"$BUILD_CONTAINER" sh -c 'cd /build && sh ci/verify-failure-guard.sh'
export CI_TEST_DB_URL="$positive_db_url"
docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \
"$BUILD_CONTAINER" sh -c 'cd /build && sh ci/test-gate.sh clean package'
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar' docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target" mkdir -p "${PWD}/target"
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar" docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f "$BUILD_CONTAINER" docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像 - name: 构建并推送镜像
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
run: | run: |
set -e set -e
TAG=$(echo "$GITHUB_SHA" | cut -c1-8) TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
@@ -88,6 +140,7 @@ jobs:
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual) # 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
- name: 通知 projman 建档(部署由运维手动触发) - name: 通知 projman 建档(部署由运维手动触发)
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
run: | run: |
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \ curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
+39
View File
@@ -0,0 +1,39 @@
# FL 指定门禁与独立 MySQL
`test-gate.sh` 是 Gitea 与本地等价 CI 共用的入口。无参运行 `test`;工作流使用
`sh ci/test-gate.sh clean package`,真实测试未通过不会生成待发布 JAR,后续镜像步骤不会运行。
所有指定类必须存在;Surefire 不允许零测试;Hibernate 固定 `ddl-auto=validate`。
工作流在 `infra-infra-net` 内新建 `${BUILD_CONTAINER}-db`,镜像 `mysql:8.0`,不映射宿主端口。
在启动之前把 `flow-test-schema.sql` 复制进初始化目录;该文件只含 20 张表的结构,不含业务数据。
镜像初始化阶段建立测试 schema,测试阶段只 validate。每个 job 的数据库容器名和随机凭据独立,
EXIT trap 回收 Maven/MySQL 容器;不访问 160 业务库。
随机凭据仅经容器环境变量传递,不写仓库或日志。Maven 子容器必须获得:
- `CI_TEST_DB_URL`:MySQL JDBC URL;schema 必须为 `flowcenter_ci` 或 `flowcenter_ci_*`。
- `CI_TEST_DB_USER`、`CI_TEST_DB_PASSWORD`:本次数据库的专用账号与密码。
脚本只允许 localhost、127.0.0.1 或 `mavenbuild-*-db` 主机,随后映射为 Spring datasource 环境变量。
`OapiFlowTasksByBizTest` 在任何 cleanDb 前检查 JDBC 实际连接的 host/schema,防止 profile 覆盖错误。
CI 场景必须实际连接 `flowcenter_ci*`,不能悄悄改用默认开发库;11 项继续为真 MySQL 测试。
本地等价运行:在独立测试库按此 schema 初始化后,通过进程环境设置上述三个变量,
再执行 `sh ci/test-gate.sh clean package`。凭据不要写入命令历史或提交到仓库。
本次已在本机 MySQL 8.0.45 做正反等价验证:完整 schema 通过,空 schema 验证失败且不生成 JAR。
三个 workflow 的手动入口新增 `tests_only=true`(默认),可以在 runner 上只验测试/JAR,不推镜像或通知建档。
本机没有 Docker,Gitea runner 的容器启动、镜像拉取和网络连通未实跑;这些仍需在 runner 上验证,
不能将等价 Maven 证据描述为实际 Gitea workflow 已通过。
实体结构变更时应显式刷新 SQL 快照并重新验证;不能把 validate 改为 update 来绕过缺表或缺列。
## 核心 RC 功能分支实跑
`codex/core-rc-20261023` 的 push 只执行测试/JAR;镜像推送与通知同时受 main ref 与 tests_only 条件约束。
检出后核验 `CI_SOURCE_SHA` 等于事件 SHA,不能使用随后更新的 main。
FL 在同一独立 MySQL 容器额外创建空 `flowcenter_ci_invalid`,先运行 `verify-failure-guard.sh`:
要求 11 项真实数据库测试因 validate 缺表报错、Maven 非零且无 JAR;随后对 20 表正常 schema 执行正向门禁。
空库检查的预期失败不属于 380 项正向用例。回收检查打印 `CI_CONTAINER_CLEANUP=PASS`,同时删除本次容器的匿名卷。
本文件是运行约定;只有取得对应远端准确 SHA 的实际日志,才能宣称 runner 已完成验证。
+471
View File
@@ -0,0 +1,471 @@
-- FL 集成测试独立 schema:当前 116 个实体映射出的全部 20 张表结构,不含任何业务数据。
-- 生成方式:2026-10-27 用当前代码在隔离 MySQL 8.0.45 以 ddl-auto=create 建库后导结构(mysqldump --no-data)。
-- 实体结构变更时必须重新生成本文件并重跑 ci/test-gate.sh;不得改 ddl-auto 来绕过缺表/缺列。
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!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_advance_intent` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`activation_apply_round` int DEFAULT NULL,
`activation_def_id` bigint DEFAULT NULL,
`activation_def_version` int DEFAULT NULL,
`activation_task_id` bigint DEFAULT NULL,
`activation_task_ids` longtext COLLATE utf8mb4_general_ci,
`assignee_id` int DEFAULT NULL,
`assignee_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`attempts` int NOT NULL,
`edge_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`handled_task_ids` longtext COLLATE utf8mb4_general_ci,
`instance_id` bigint NOT NULL,
`last_error` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`next_retry_at` datetime DEFAULT NULL,
`node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`revision` bigint NOT NULL,
`status` varchar(16) COLLATE utf8mb4_general_ci NOT NULL,
`trigger_task_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_advance_activation` (`activation_task_id`),
KEY `idx_flw_advance_intent_due` (`status`,`next_retry_at`),
KEY `idx_flw_advance_intent_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_app` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`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_apply_command` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`biz_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`canonical_hash` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`canonical_version` int NOT NULL,
`def_id` bigint DEFAULT NULL,
`def_version` int DEFAULT NULL,
`flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`instance_id` bigint DEFAULT NULL,
`reject_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`reject_detail` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`request_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`apply_round` int DEFAULT NULL,
`sch_id` bigint NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL,
`state` varchar(16) COLLATE utf8mb4_general_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_command_request` (`sch_id`,`source_principal_id`,`request_id`),
KEY `idx_flw_command_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_assignee_map` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`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_biz_coordinate` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`active_instance_id` bigint DEFAULT NULL,
`biz_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`last_instance_id` bigint DEFAULT NULL,
`last_round` int NOT NULL,
`sch_id` bigint NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_coord` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_branch` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`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,
`resp_body` text COLLATE utf8mb4_general_ci,
`retry_count` int NOT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
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,
`base_path` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`callback_url` varchar(500) COLLATE utf8mb4_general_ci NOT NULL,
`event_types` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`sch_id` bigint NOT NULL,
`secret` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL,
`status` int NOT NULL,
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,
`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,
`instance_id` bigint NOT NULL,
`node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`sent_time` datetime(3) NOT NULL,
`user_id` int NOT NULL,
`user_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_flw_cc_user` (`user_id`,`sent_time`),
KEY `idx_flw_cc_inst` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_command_source` (
`id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL,
`app_id` int NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!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,
`app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`change_log` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`content_json` longtext COLLATE utf8mb4_general_ci NOT NULL,
`flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`form_uuid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`form_version` int DEFAULT NULL,
`item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`line_offline` int NOT NULL DEFAULT '0',
`name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`publish_time` datetime DEFAULT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
`version` int NOT NULL,
PRIMARY KEY (`id`),
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,
`apply_app_code` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_app_key` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_payload_hash` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_request_token` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_round` int DEFAULT NULL,
`assign_json` longtext COLLATE utf8mb4_general_ci,
`biz_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_snapshot` longtext COLLATE utf8mb4_general_ci,
`biz_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_type` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`current_nodes` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`def_code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`def_id` bigint NOT NULL,
`def_version` int NOT NULL,
`event_seq_counter` int DEFAULT NULL,
`finish_time` datetime DEFAULT NULL,
`form_data_json` longtext COLLATE utf8mb4_general_ci,
`item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`item_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`legacy_slot` int DEFAULT NULL,
`sch_id` bigint NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL,
`status` int NOT NULL,
`submit_time` datetime DEFAULT NULL,
`submitter_id` int NOT NULL,
`submitter_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`version` int NOT NULL,
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,
`direct_host` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`path` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
`reg_app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`router_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`service_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`status` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_router_key` (`router_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_state_event` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`apply_round` int DEFAULT NULL,
`dispatched` int NOT NULL,
`event_id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`event_seq` int NOT NULL,
`event_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`instance_id` bigint NOT NULL,
`operator_id` int DEFAULT NULL,
`payload_json` longtext COLLATE utf8mb4_general_ci NOT NULL,
`sch_id` bigint NOT NULL,
`task_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_state_event_id` (`event_id`),
KEY `idx_flw_state_event_instance` (`instance_id`),
KEY `idx_flw_state_event_dispatch` (`dispatched`,`create_time`),
KEY `idx_flw_state_event_seq` (`instance_id`,`event_seq`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_task` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`arrive_time` datetime(3) NOT NULL,
`assignee_id` int NOT NULL,
`assignee_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`deadline` datetime DEFAULT NULL,
`edge_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`handle_time` datetime(3) DEFAULT NULL,
`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,
PRIMARY KEY (`id`),
KEY `idx_flw_task_inst_status` (`instance_id`,`status`),
KEY `idx_flw_task_assignee` (`assignee_id`,`status`,`arrive_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_usage_grant` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`app_id` int NOT NULL,
`app_key_snapshot` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`app_name_snapshot` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`enabled` int NOT NULL,
`flow_guid` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`remark` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_usage_grant` (`sch_id`,`flow_guid`,`app_id`),
KEY `idx_flw_usage_grant_sch_flow` (`sch_id`,`flow_guid`),
KEY `idx_flw_usage_grant_app` (`app_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!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 */;
+26
View File
@@ -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
+26
View File
@@ -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;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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
@@ -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
@@ -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
@@ -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
@@ -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());
+ }
+}
+38
View File
@@ -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 验收。
+8
View File
@@ -100,6 +100,14 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </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> </dependencies>
<build> <build>
@@ -13,6 +13,7 @@ import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository; import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionPhase;
@@ -28,10 +29,22 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
/** /**
* 回调分发器(概览 §8.4 可靠性设计):引擎 AFTER_COMMIT 事件 → 匹配 flw_callback_reg * 回调分发器(概览 §8.4 可靠性设计;2026-10-25 R2 返修):引擎 AFTER_COMMIT 事件 →
* (flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。 * 匹配 flw_callback_reg(flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
* 落库成功即视为主业务完成,推送失败不影响审批(异步解耦是特性)。 * 落库成功即视为主业务完成,推送失败不影响审批(异步解耦是特性)。
* @Scheduled(fixedDelay=30s) 扫 (status=0, next_retry_time<=now) 重推;死信留管理端手工重推。 * @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 @Slf4j
@Component @Component
@@ -48,6 +61,17 @@ public class CallbackDispatcher {
private final CallbackPushService pushService; private final CallbackPushService pushService;
private final CallbackProperties properties; private final CallbackProperties properties;
private final ObjectMapper objectMapper; 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; private volatile ExecutorService pushExecutor;
@@ -70,14 +94,129 @@ public class CallbackDispatcher {
} }
try { try {
dispatch(event); dispatch(event);
markFactDispatched(event);
} catch (Exception e) { } 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); 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); FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null);
if (instance == null) { if (instance == null) {
return; return;
@@ -90,6 +229,7 @@ public class CallbackDispatcher {
List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus( List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
instance.getSchId(), def.getFlowGuid(), 0); instance.getSchId(), def.getFlowGuid(), 0);
for (FlwCallbackReg reg : regs) { for (FlwCallbackReg reg : regs) {
if (!self(reg, instance)) continue;
if (!subscribed(reg.getEventTypes(), eventCode)) { if (!subscribed(reg.getEventTypes(), eventCode)) {
continue; continue;
} }
@@ -97,54 +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 { try {
String eventId = UUID.randomUUID().toString().replace("-", "").toLowerCase(); logId = logStore.persist(callbackLog);
Map<String, Object> payload = new HashMap<>(); } catch (DuplicateKeyException dup) {
payload.put("eventId", eventId); return; // uk(event_id, reg_id) 并发兜底:另一路径已建行,幂等成功
payload.put("event", event.getType().getCode()); }
payload.put("schId", instance.getSchId()); submitPush(logId);
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());
}
payload.put("data", data);
String payloadJson = objectMapper.writeValueAsString(payload);
FlwCallbackLog callbackLog = new FlwCallbackLog(); /** 异步首推提交:失败不回退建行结果(行已在,重试扫描接手) */
callbackLog.setSchId(instance.getSchId()); private void submitPush(Long logId) {
callbackLog.setRegId(reg.getId()); try {
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 起)
executor().submit(() -> pushService.pushAsync(logId)); executor().submit(() -> pushService.pushAsync(logId));
} catch (Exception e) { } catch (Exception e) {
log.error("[flow-center] 回调日志落库失败(对账兜底会追平):instance={} event={} {}", log.warn("[flow-center] 异步首推提交失败(重试扫描接手):logId={} {}", logId, e.toString());
instance.getId(), event.getType().getCode(), e.toString(), e);
} }
} }
/**
* 存量现场信封(事实冻结体缺席的兜底路径):字段口径与 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 永远只是加速器) */ /** 重推扫描(fixedDelay 串行防重入;推拉结合里 Webhook 永远只是加速器) */
@Scheduled(fixedDelay = 30_000) @Scheduled(fixedDelay = 30_000)
public void scanRetry() { 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.entity.FlwCallbackLog;
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository; 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.stereotype.Service;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; 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 监听阶段原事务已提交 * 回调日志落库(独立 bean 保证 REQUIRES_NEW 生效):AFTER_COMMIT 监听阶段原事务已提交
* 但事务资源仍挂在线程上,REQUIRED 会"加入"已完结事务并抛 no transaction is in progress—— * 但事务资源仍挂在线程上,REQUIRED 会"加入"已完结事务并抛 no transaction is in progress——
* 必须显式开新事务写日志。落库成功即视为主业务完成,推送失败不影响审批。 * 必须显式开新事务写日志。落库成功即视为主业务完成,推送失败不影响审批。
*/ */
@Service @Service
@RequiredArgsConstructor @Slf4j
public class CallbackLogStore { public class CallbackLogStore {
private final FlwCallbackLogRepository logRepository; 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 */ /** @return 落库后的日志行 id */
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW)
@@ -24,4 +48,198 @@ public class CallbackLogStore {
FlwCallbackLog saved = logRepository.saveAndFlush(callbackLog); FlwCallbackLog saved = logRepository.saveAndFlush(callbackLog);
return saved.getId(); 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.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.HashSet; import java.util.HashMap;
import java.util.Set; import java.util.Map;
import java.util.UUID; import java.util.UUID;
/** /**
@@ -32,7 +32,7 @@ import java.util.UUID;
* <b>持续重试不终止</b>(T1.1:消费方只承诺"瞬时失败回 5xx",恢复后自动追平,消费方免对账); * <b>持续重试不终止</b>(T1.1:消费方只承诺"瞬时失败回 5xx",恢复后自动追平,消费方免对账);
* 挂起(retryCount 首达 hang-alert-retries)时 UserMsg 告警恰一次; * 挂起(retryCount 首达 hang-alert-retries)时 UserMsg 告警恰一次;
* 死信(status=2)仅两个入口——注册停用、管理端手工标记,重试次数永不自动触发。 * 死信(status=2)仅两个入口——注册停用、管理端手工标记,重试次数永不自动触发。
* 推送一律在新事务里落库(AFTER_COMMIT 监听器/调度线程均无外层事务)。 * HTTP 在业务事务之外执行,结果在认领会话上以自动提交的条件 UPDATE 落定。
* 推送本身与落库解耦:payload_json 是完整快照,可重放。 * 推送本身与落库解耦:payload_json 是完整快照,可重放。
*/ */
@Slf4j @Slf4j
@@ -40,43 +40,72 @@ import java.util.UUID;
@RequiredArgsConstructor @RequiredArgsConstructor
public class CallbackPushService { 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 FlwCallbackLogRepository logRepository;
private final FlwCallbackRegRepository regRepository; private final FlwCallbackRegRepository regRepository;
private final CallbackProperties properties; private final CallbackProperties properties;
private final CallbackLogStore logStore;
private final UserMsgClient userMsgClient; private final UserMsgClient userMsgClient;
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider> private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
appIdentityProvider; 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) { public void pushAsync(Long logId) {
synchronized (IN_FLIGHT) { 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; return;
} }
} doPush(claim);
try {
doPush(logId);
} finally { } finally {
synchronized (IN_FLIGHT) { synchronized (IN_FLIGHT) {
IN_FLIGHT.remove(logId); IN_FLIGHT.computeIfPresent(logId, (id, count) -> count == 1 ? null : count - 1);
} }
} }
} }
private void doPush(Long logId) { private void doPush(CallbackLogStore.Claim claim) {
FlwCallbackLog callbackLog = logRepository.findById(logId).orElse(null); FlwCallbackLog callbackLog = claim.row();
if (callbackLog == null if (callbackLog == null
|| callbackLog.getStatus() != FlwEnums.CallbackLogStatus.PENDING.getCode()) { || callbackLog.getStatus() != FlwEnums.CallbackLogStatus.PENDING.getCode()) {
log.debug("[flow-center] 回调推送早退(行缺席或非待推):logId={} status={}",
callbackLog == null ? null : callbackLog.getId(), callbackLog == null ? null : callbackLog.getStatus());
return; return;
} }
FlwCallbackReg reg = regRepository.findById(callbackLog.getRegId()).orElse(null); FlwCallbackReg reg = regRepository.findById(callbackLog.getRegId()).orElse(null);
if (reg == null || reg.getStatus() != 0) { if (reg == null || reg.getStatus() != 0) {
// 注册已停用:保留日志待查,不再推送(置死信便于管理端一次性清理) // 注册已停用:保留日志待查,不再推送(置死信便于管理端一次性清理)
markDead(callbackLog, "registration disabled"); markDead(claim, "registration disabled");
return; 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 body = callbackLog.getPayloadJson();
String ts = String.valueOf(System.currentTimeMillis()); String ts = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16); String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
@@ -94,41 +123,34 @@ public class CallbackPushService {
new HttpEntity<>(body, headers), String.class); new HttpEntity<>(body, headers), String.class);
int status = resp.getStatusCodeValue(); int status = resp.getStatusCodeValue();
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
markSuccess(callbackLog, status); markSuccess(claim, status);
} else { } else {
markRetryOrFail(callbackLog, "HTTP " + status, String.valueOf(status)); markRetryOrFail(claim, "HTTP " + status, String.valueOf(status));
} }
} catch (Exception e) { } catch (Exception e) {
markRetryOrFail(callbackLog, rootMessage(e), null); markRetryOrFail(claim, rootMessage(e), null);
} }
} }
private void markSuccess(FlwCallbackLog callbackLog, int httpStatus) { private void markSuccess(CallbackLogStore.Claim claim, int httpStatus) {
callbackLog.setStatus(FlwEnums.CallbackLogStatus.SUCCESS.getCode()); if (claim.success(httpStatus)) {
callbackLog.setRetryCount(callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount()); log.debug("[flow-center] 回调推送成功:eventId={} http={}", claim.row().getEventId(), httpStatus);
callbackLog.setLastHttpStatus(String.valueOf(httpStatus)); }
callbackLog.setLastError(null);
callbackLog.setPushTime(LocalDateTime.now());
saveQuietly(callbackLog);
log.debug("[flow-center] 回调推送成功:eventId={} http={}", callbackLog.getEventId(), httpStatus);
} }
/** /**
* 失败落定(持续重试语义):越过退避表后按 {@link CallbackProperties#getCapBackoffMinutes()} * 失败落定(持续重试语义):越过退避表后按 {@link CallbackProperties#getCapBackoffMinutes()}
* 封顶继续排期,<b>绝不自动死信</b>;retryCount 首次等于挂起阈值时告警恰一次。 * 封顶继续排期,<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; 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(); java.util.List<Long> backoff = properties.getRetryBackoffMinutes();
long backoffMinutes = retryCount <= backoff.size() long backoffMinutes = retryCount <= backoff.size()
? backoff.get(retryCount - 1) ? backoff.get(retryCount - 1)
: Math.max(1, properties.getCapBackoffMinutes()); : Math.max(1, properties.getCapBackoffMinutes());
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode()); if (!claim.failure(truncate(error), httpStatus, backoffMinutes)) return;
callbackLog.setNextRetryTime(LocalDateTime.now().plusMinutes(backoffMinutes)); callbackLog.setRetryCount(retryCount); // 仅用于日志/告警;数据库已原子 +1,绝不 merge 此快照。
saveQuietly(callbackLog);
log.warn("[flow-center] 回调推送失败(第 {} 次重试,{} 分钟后):eventId={} {}", log.warn("[flow-center] 回调推送失败(第 {} 次重试,{} 分钟后):eventId={} {}",
retryCount, backoffMinutes, callbackLog.getEventId(), error); retryCount, backoffMinutes, callbackLog.getEventId(), error);
// 挂起告警恰一次:retryCount 每失败 +1 单调递增,等于阈值的时机只有一次; // 挂起告警恰一次:retryCount 每失败 +1 单调递增,等于阈值的时机只有一次;
@@ -163,9 +185,20 @@ public class CallbackPushService {
* 仍被注册停用场景使用(doPush:注册已停用即无意义重试,置死信便于管理端清理)。 * 仍被注册停用场景使用(doPush:注册已停用即无意义重试,置死信便于管理端清理)。
*/ */
public void markDead(FlwCallbackLog callbackLog, String error) { public void markDead(FlwCallbackLog callbackLog, String error) {
callbackLog.setStatus(FlwEnums.CallbackLogStatus.DEAD.getCode()); try (CallbackLogStore.Claim claim = logStore.tryClaim(callbackLog.getId())) {
callbackLog.setLastError(truncate(error)); if (claim == null) throw new com.yunhai.platform.apistd.BizException(
saveQuietly(callbackLog); 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={}", log.error("[flow-center] 回调推送进入死信:eventId={} biz={}/{} error={}",
callbackLog.getEventId(), callbackLog.getBizType(), callbackLog.getBizId(), error); callbackLog.getEventId(), callbackLog.getBizType(), callbackLog.getBizId(), error);
alertDead(callbackLog, error); alertDead(callbackLog, error);
@@ -203,14 +236,6 @@ public class CallbackPushService {
} }
} }
private void saveQuietly(FlwCallbackLog callbackLog) {
try {
logRepository.save(callbackLog);
} catch (Exception e) {
log.error("[flow-center] 回调日志落库失败:eventId={} {}", callbackLog.getEventId(), e.toString());
}
}
/** /**
* 回调探测(2026-10-11 管理端增强):以该注册的 secret 按现行信封签一条未知事件码 flow.ping, * 回调探测(2026-10-11 管理端增强):以该注册的 secret 按现行信封签一条未知事件码 flow.ping,
* 走真实推送管道同步一发——接收方按规范 §6.4 对未知事件码"记日志后 200",不破坏业务数据。 * 走真实推送管道同步一发——接收方按规范 §6.4 对未知事件码"记日志后 200",不破坏业务数据。
@@ -0,0 +1,57 @@
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 true; // Legacy callback migration is explicit; formal facts always use UUID SELF.
}
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");
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,6 +6,7 @@ import com.yunhai.flowcenter.entity.FlwCallbackReg;
import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.model.FlowModels; import com.yunhai.flowcenter.model.FlowModels;
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
import com.yunhai.flowcenter.repository.FlwInstanceRepository; import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.service.FlowAdminGuard; import com.yunhai.flowcenter.service.FlowAdminGuard;
import com.yunhai.flowcenter.service.FlowCallbackAdminService; import com.yunhai.flowcenter.service.FlowCallbackAdminService;
@@ -108,6 +109,46 @@ public class FlowAdminController {
return appAdminService.stats(adminGuard.requireAdmin(user, null)); 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);
}
/** 授予/重申使用授权(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") @GetMapping("/def/get")
public FlwDef defGet(BoaUser user, @RequestParam("id") Long id) { public FlwDef defGet(BoaUser user, @RequestParam("id") Long id) {
return defAdminService.get(adminGuard.requireAdmin(user, null), id); return defAdminService.get(adminGuard.requireAdmin(user, null), id);
@@ -145,7 +186,16 @@ public class FlowAdminController {
(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("appKey"), (String) body.get("changeLog"),
(String) body.get("formUuid"), (String) body.get("formUuid"),
body.get("formVersion") == null ? null : Integer.valueOf(String.valueOf(body.get("formVersion")))); 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);
}
} }
/** /**
@@ -181,6 +231,24 @@ public class FlowAdminController {
return defAdminService.versions(adminGuard.requireAdmin(user, null), flowGuid); 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 键) */ /** 在途办件数(议题二④):flowGuids 逗号分隔,去重后最多 100;返回 flowGuid → 审批中办件数(无在途也返 0 键) */
@GetMapping("/def/inflight") @GetMapping("/def/inflight")
public Map<String, Long> defInflight(BoaUser user, @RequestParam("flowGuids") String flowGuids) { public Map<String, Long> defInflight(BoaUser user, @RequestParam("flowGuids") String flowGuids) {
@@ -3,6 +3,7 @@ package com.yunhai.flowcenter.controller.api;
import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.entity.FlwTask;
import com.yunhai.flowcenter.model.FlowModels; import com.yunhai.flowcenter.model.FlowModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.service.FlowAccessGuard; import com.yunhai.flowcenter.service.FlowAccessGuard;
import com.yunhai.flowcenter.service.FlowDiagramService; import com.yunhai.flowcenter.service.FlowDiagramService;
import com.yunhai.flowcenter.service.FlowInstanceService; import com.yunhai.flowcenter.service.FlowInstanceService;
@@ -41,6 +42,22 @@ public class FlowUserController {
private final FlowDiagramService diagramService; private final FlowDiagramService diagramService;
private final com.yunhai.flowcenter.service.DiagramImageService imageService; 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") @GetMapping("/my")
public BasePageResult<FlowModels.InstanceItem> my(BoaUser user, public BasePageResult<FlowModels.InstanceItem> my(BoaUser user,
@@ -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);
}
}
@@ -208,7 +208,7 @@ public class OapiFlowController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size, @RequestParam(value = "size", required = false) Integer size,
@RequestAttribute(value = "yh.app.auth", required = false) String appKey) { @RequestAttribute(value = "yh.app.auth", required = false) String appKey) {
return queryService.pageDone(schId, assigneeId, appKey, page, size); return queryService.pageDone(schId, assigneeId, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(appKey), page, size);
} }
/** /**
@@ -222,7 +222,7 @@ public class OapiFlowController {
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size, @RequestParam(value = "size", required = false) Integer size,
@RequestAttribute(value = "yh.app.auth", required = false) String appKey) { @RequestAttribute(value = "yh.app.auth", required = false) String appKey) {
return queryService.pageCc(schId, userId, appKey, page, size); return queryService.pageCc(schId, userId, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(appKey), page, size);
} }
/** 办件分页(oapi):bizType/status 可选过滤 */ /** 办件分页(oapi):bizType/status 可选过滤 */
@@ -15,7 +15,8 @@ import java.util.List;
/** /**
* 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源, * 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源,
* 零跨服务依赖);USERS 定义直配,姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。 * 零跨服务依赖);USERS 定义直配。审批责任人统一走 D11 资格终审(存在+启用+校域);
* 仅 NOTIFY 抄送接收人保留宽容解析(姓名补全失败降级空名、不校验资格)。
*/ */
@Slf4j @Slf4j
@Service @Service
@@ -32,13 +33,31 @@ public class AssigneeResolver {
} }
List<AssigneeRef> refs = new ArrayList<>(); List<AssigneeRef> refs = new ArrayList<>();
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) { 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)) { for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
refs.add(new AssigneeRef(m.getUserId(), m.getUserName())); 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)) { } else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
if (a.userIds != null) { if (a.userIds != null) {
for (Integer uid : a.userIds) { for (Integer uid : a.userIds) {
refs.add(new AssigneeRef(uid, fetchName(uid))); refs.add(new AssigneeRef(uid, requireEligibleAssigneeName(schId, uid)));
} }
} }
} else { } else {
@@ -51,10 +70,30 @@ public class AssigneeResolver {
return refs; return refs;
} }
/**
* 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 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义), * 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义),
* 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批, * 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
* 绝不能因抄送对象缺失卡住流程推进。与 {@link #resolve} 的强口径相反。 * 绝不能因抄送对象缺失卡住流程推进。D11 的资格终审只约束审批责任人,本方法刻意不校验
* 存在/启用/校域,直接复用既有姓名补全容错。
*/ */
public List<AssigneeRef> resolveTolerant(Long schId, FlowDefModel.Node node) { public List<AssigneeRef> resolveTolerant(Long schId, FlowDefModel.Node node) {
FlowDefModel.Assignee a = node.receivers; FlowDefModel.Assignee a = node.receivers;
@@ -89,24 +128,52 @@ public class AssigneeResolver {
} }
/** /**
* 移交目标校验(严格口径,与 {@link #fetchName} 的降级容错相反): * 审批责任人资格终审(D11 唯一权威入口):存在 + 启用(status=1,null fail-closed)
* 查不到就必须拒绝——否则会建出一条谁在待办里都看不到的僵尸任务。 * + 属于当前学校。UserBase 不可达与“人不合格”分别报错,前者不可降级为过滤。
* 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) { if (userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象"); throw new BizException(ErrorCode.PARAM_ERROR, "缺少办理人");
} }
BoaUser user; BoaUser user;
try { try {
user = userClient.getUserById(String.valueOf(userId)); user = userClient.getUserById(String.valueOf(userId));
} catch (Exception e) { } catch (Exception e) {
log.warn("[flow-center] 移交对象校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage()); log.warn("[flow-center] 办理人资格校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
throw new BizException(ErrorCode.PARAM_ERROR, "移交对象校验失败,请稍后重试:" + userId); throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "办理人资格校验失败,请稍后重试:" + userId);
} }
if (user == null) { 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;
} }
} }
@@ -29,6 +29,7 @@ public final class DefValidator {
*/ */
public static void validate(FlowDefModel model, Set<String> enabledDeptKeys, Set<String> enabledRouterKeys) { public static void validate(FlowDefModel model, Set<String> enabledDeptKeys, Set<String> enabledRouterKeys) {
List<String> problems = new ArrayList<>(); List<String> problems = new ArrayList<>();
VariableContract.validateDefinition(model, false);
if (model.nodes == null || model.nodes.isEmpty()) { if (model.nodes == null || model.nodes.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点"); throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点");
@@ -248,7 +249,7 @@ public final class DefValidator {
* (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。 * (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
* *
* <p>EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦 * <p>EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦
* (表单变量是子应用运行时传的,发布期无从核对 schema,一律放行、缺失按 null 注入求值)。 * 新契约的变量引用/类型/值域由 VariableContract 统一检查;历史无声明版本保持兼容。
*/ */
private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set<String> enabledRouterKeys, private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set<String> enabledRouterKeys,
List<String> problems) { List<String> problems) {
@@ -1,81 +1,201 @@
package com.yunhai.flowcenter.engine; package com.yunhai.flowcenter.engine;
import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.*;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.entity.FlwTask;
import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.flowcenter.repository.*;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.*;
import org.springframework.transaction.annotation.Transactional;
/** import javax.persistence.EntityManager;
* 节点放行推进(AFTER_COMMIT 监听器经代理调用本 bean,REQUIRES_NEW 生效—— import javax.persistence.PersistenceContext;
* 事务已提交后的同步监听阶段没有活动事务,@Modifying 批量语句必须有新事务承载): import java.time.LocalDateTime;
* 该节点待办清零且办件在途才推进;并发冲突由 flw_instance.version 乐观锁暴露,监听器层负责重试。 import java.util.*;
*/
@Slf4j /** D09: durable activation membership, serialized last-handler payload, and revision-scoped settlement. */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class FlowAdvanceService { public class FlowAdvanceService {
private final FlwInstanceRepository instanceRepository; private final FlwInstanceRepository instanceRepository;
private final FlwDefRepository defRepository; private final FlwDefRepository defRepository;
private final FlwTaskRepository taskRepository; private final FlwTaskRepository taskRepository;
private final FlowEngine flowEngine; 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 待放行节点 * One transaction owns validation, engine effects and settlement. RC sees the last committed task status;
* @param edgeName 多出口节点所选出口(办理事件携带);可空——兜底从该节点 * the head lock serializes against last-handler refresh, and instance @Version fences reactivation writes.
* 最近一条已同意任务的 edge_name 列重放(推进在 AFTER_COMMIT 新事务重查,
* 事件传参只是快路径,落库的值才是权威)
* @param operator 本节点办理人(TASK_HANDLED 事件 data 穿透;推进产生的
* TASK_CREATED/COMPLETED 操作人信封字段来源),可空
* @return 是否实际推进了
*/ */
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
public boolean tryAdvance(Long instanceId, String nodeId, String edgeName, FlowOperator operator) { public boolean tryAdvance(Long intentId, Long observedRevision) {
FlwInstance instance = instanceRepository.findById(instanceId).orElse(null); FlwAdvanceIntent intent = lockIntent(intentId);
if (instance == null if (!ownsRevision(intent, observedRevision)) return false;
|| instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) { 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; return false;
} }
// 已推进过的节点不再放行(currentNodes 由引擎在放行时移除,乐观锁保证互斥) if (intent.getActivationTaskId() == null || !intent.containsTask(intent.getActivationTaskId())
String current = instance.getCurrentNodes(); || intent.getTriggerTaskId() == null || !intent.handledTask(intent.getTriggerTaskId())) {
boolean active = current != null && java.util.Arrays.stream(current.split(",")) hold(intent, "D09_UNANCHORED: audited cohort/last-handler backfill required; not executed");
.anyMatch(nodeId::equals);
if (!active) {
return false; return false;
} }
long pending = taskRepository.findByInstanceIdAndStatus(instanceId, FlwInstance instance = instanceRepository.findById(intent.getInstanceId()).orElse(null);
FlwEnums.TaskStatus.PENDING.getCode()).stream() if (instance == null || instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()
.filter(t -> nodeId.equals(t.getNodeId())) || !active(instance, intent.getNodeId())) {
.count(); done(intent, null);return false;
if (pending > 0) {
return false;
} }
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null); if (!Objects.equals(instance.getDefId(), intent.getActivationDefId())
if (def == null) { || !Objects.equals(instance.getDefVersion(), intent.getActivationDefVersion())
log.error("[flow-center] 办件定义缺失,推进中止:instance={} defId={}", || !Objects.equals(instance.getApplyRound(), intent.getActivationApplyRound())) {
instanceId, instance.getDefId()); hold(intent, "D09_IDENTITY_MISMATCH: no proven successor; audited backfill required");return false;
return false;
} }
String chosen = edgeName; boolean pending = taskRepository.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode())
if (chosen == null) { .stream().anyMatch(t -> intent.getNodeId().equals(t.getNodeId()));
chosen = taskRepository.findByInstanceIdAndStatus(instanceId, if (pending) {
FlwEnums.TaskStatus.APPROVED.getCode()).stream() // A future handler holds/obtains this same head lock and must re-arm it in its task transaction.
.filter(t -> nodeId.equals(t.getNodeId()) && t.getEdgeName() != null) done(intent, null);return false;
.max(java.util.Comparator.comparing(
FlwTask::getHandleTime, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder())))
.map(FlwTask::getEdgeName)
.orElse(null);
} }
flowEngine.continueAdvance(instance, def, nodeId, chosen, operator); FlwTask trigger = taskRepository.findById(intent.getTriggerTaskId()).orElse(null);
instanceRepository.save(instance); 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; 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); }
} }
@@ -56,6 +56,18 @@ public class FlowDefModel {
public List<Node> nodes; public List<Node> nodes;
public List<Edge> edges; public List<Edge> edges;
public Settings settings; 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 → 左上角),仅前端设计器/查看器复用;引擎不消费。 * 画布坐标(nodeId → 左上角),仅前端设计器/查看器复用;引擎不消费。
* 缺失时前端自行做自动布局,故可选、可空。 * 缺失时前端自行做自动布局,故可选、可空。
@@ -45,6 +45,9 @@ public class FlowEngine {
/** 发起后首推:从 START 激活(instance 已建好、status=10)。operator=null:TASK_CREATED/COMPLETED 的操作人回退发起人 */ /** 发起后首推:从 START 激活(instance 已建好、status=10)。operator=null:TASK_CREATED/COMPLETED 的操作人回退发起人 */
public void startAdvance(FlwInstance instance, FlwDef def) { public void startAdvance(FlwInstance instance, FlwDef def) {
FlowDefModel model = parseModel(def); FlowDefModel model = parseModel(def);
// D11:整份定义的固定 USERS 先整体预检,非法人不得等到后续节点才暴露(会留下卡单)。
// 校验失败抛业务异常,由发起事务整体回滚(零实例零任务)。
assigneeResolver.requireEligibleFixedAssignees(instance.getSchId(), model);
setCurrentNodes(instance, null); setCurrentNodes(instance, null);
advance(instance, model, model.start, null, null); advance(instance, model, model.start, null, null);
} }
@@ -69,6 +69,55 @@ public class FlowEvent {
private final String operatorName; private final String operatorName;
/** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */ /** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */
private final Map<String, Object> data; 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) { public static FlowEvent of(Type type, Long instanceId) {
return new FlowEvent(type, instanceId, null, null, null, new HashMap<>()); return new FlowEvent(type, instanceId, null, null, null, new HashMap<>());
@@ -1,50 +1,102 @@
package com.yunhai.flowcenter.engine; package com.yunhai.flowcenter.engine;
import com.yunhai.flowcenter.entity.FlwAdvanceIntent;
import com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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.orm.ObjectOptimisticLockingFailureException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener; import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** import java.time.LocalDateTime;
* 节点放行推进监听器:TASK_HANDLED 事件 AFTER_COMMIT 后在新事务里重查—— import java.util.HashMap;
* 该节点待办是否清零、办件是否仍在途,全部成立才继续推进(下一节点/JOIN 合并/END)。 import java.util.Map;
* 会签并发办理时各方提交的快照互相看不到对方未提交的办理行,事务内判断
* "节点是否全部办结"必假,提交后重查才是唯一可靠时机;推进串行化由办件乐观锁兜底 /** TASK_CREATED establishes membership; TASK_HANDLED refreshes that same activation in the handling transaction. */
* (重试循环在监听器侧,跨 bean 调用 {@link FlowAdvanceService#tryAdvance} 保证 REQUIRES_NEW 生效)。
*/
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class FlowNodeAdvanceListener { public class FlowNodeAdvanceListener {
private final FlowAdvanceService advanceService; 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) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onTaskHandled(FlowEvent event) { public void onTaskHandled(FlowEvent event) {
if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) { if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) return;
return;
}
String nodeId = (String) event.getData().get("nodeId"); String nodeId = (String) event.getData().get("nodeId");
if (nodeId == null) { if (nodeId == null) return;
return; FlowAdvanceService.IntentRef ref = advanceService.pendingForTask(event.getInstanceId(), nodeId, event.getTaskId());
} if (ref == null) return;
String edgeName = (String) event.getData().get("edgeName");
// 办理人随事件 data 穿透进推进链:新 TASK_CREATED/COMPLETED 的操作人信封字段(2026-09-20)
FlowOperator operator = FlowOperator.of(
(Integer) event.getData().get("assigneeId"),
(String) event.getData().get("assigneeName"));
for (int attempt = 1; attempt <= 3; attempt++) { for (int attempt = 1; attempt <= 3; attempt++) {
try { try { advanceService.tryAdvance(ref.id, ref.revision);return; }
advanceService.tryAdvance(event.getInstanceId(), nodeId, edgeName, operator); catch (ObjectOptimisticLockingFailureException conflict) {
return; log.warn("[flow-center] D09 advance conflict {}/3: intent={} revision={}", attempt, ref.id, ref.revision);
} catch (ObjectOptimisticLockingFailureException e) { } catch (Exception failure) {
log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}", advanceService.defer(ref.id, ref.revision, failure.toString());return;
attempt, event.getInstanceId(), nodeId);
} }
} }
log.error("[flow-center] 推进重试耗尽(请人工检查):instance={} node={}", advanceService.defer(ref.id, ref.revision, "AFTER_COMMIT retries exhausted; sweep will resume");
event.getInstanceId(), nodeId); }
@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,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,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; 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) 是扫描主路径。 * payload_json 完整快照可重放;索引 (status, next_retry_time) 是扫描主路径。
*/ */
@Getter @Getter
@Setter @Setter
@Entity @Entity
@Table(name = "flw_callback_log", @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 = { indexes = {
@Index(name = "idx_flw_log_scan", columnList = "status, next_retry_time"), @Index(name = "idx_flw_log_scan", columnList = "status, next_retry_time"),
@Index(name = "idx_flw_log_inst", columnList = "instance_id")}) @Index(name = "idx_flw_log_inst", columnList = "instance_id")})
@@ -21,6 +21,9 @@ import javax.persistence.UniqueConstraint;
columnNames = {"sch_id", "app_key", "flow_guid"}), columnNames = {"sch_id", "app_key", "flow_guid"}),
indexes = @Index(name = "idx_flw_reg_flow", columnList = "sch_id, flow_guid, status")) indexes = @Index(name = "idx_flw_reg_flow", columnList = "sch_id, flow_guid, status"))
public class FlwCallbackReg extends BaseEntity { public class FlwCallbackReg extends BaseEntity {
@Column(name="source_principal_id",length=36)
private String sourcePrincipalId;
/** 租户键 */ /** 租户键 */
@Column(name = "sch_id", nullable = false) @Column(name = "sch_id", nullable = false)
@@ -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;
}
@@ -74,6 +74,15 @@ public class FlwDef extends BaseEntity {
@Column(name = "form_version") @Column(name = "form_version")
private Integer formVersion; 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 */ /** §5.1 流程定义 JSON */
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT") @Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
private String contentJson; private String contentJson;
@@ -14,8 +14,9 @@ import java.time.LocalDateTime;
/** /**
* 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存 * 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存
* biz_title/biz_url/biz_snapshot 快照与关联键;uk(sch_id, biz_type, biz_id) 幂等—— * biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为
* 同一业务对象一条办件,重复代发起返回原办件;驳回/撤回后重提复用同一行(R4 重启语义)。 * (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。
* 旧入口以 sourcePrincipalId + legacySlot=1 实现每来源一业务一行;正式命令 legacySlot=NULL。
* *
* <p>version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件), * <p>version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件),
* 由 JPA @Version 在 flush 时自增。 * 由 JPA @Version 在 flush 时自增。
@@ -24,12 +25,19 @@ import java.time.LocalDateTime;
@Setter @Setter
@Entity @Entity
@Table(name = "flw_instance", @Table(name = "flw_instance",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_instance_biz", uniqueConstraints = {
columnNames = {"sch_id", "biz_type", "biz_id"}), @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 = { indexes = {
@Index(name = "idx_flw_inst_submitter", columnList = "sch_id, submitter_id, status"), @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")}) @Index(name = "idx_flw_inst_status_time", columnList = "sch_id, status, submit_time")})
public class FlwInstance extends BaseEntity { public class FlwInstance extends BaseEntity {
@Column(name="source_principal_id",length=36,updatable=false)
private String sourcePrincipalId;
/** 1 for source-scoped legacy API writers, NULL for formal commands. */
@Column(name="legacy_slot")
private Integer legacySlot = 1;
/** 租户键 */ /** 租户键 */
@Column(name = "sch_id", nullable = false) @Column(name = "sch_id", nullable = false)
@@ -141,7 +149,7 @@ public class FlwInstance extends BaseEntity {
private String applyRequestToken; private String applyRequestToken;
/** /**
* 当前发起轮次(1 起;随新一轮请求标识进位)。 * 当前发起轮次(1 起)。正式命令由服务端坐标锁内分配,不接受客户端 attempt 分轮。
* *
* <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投, * <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
* 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p> * 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p>
@@ -149,6 +157,22 @@ public class FlwInstance extends BaseEntity {
@Column(name = "apply_round") @Column(name = "apply_round")
private Integer applyRound; 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 的路由判断行无出口)。 * 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充, * 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
@@ -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;
}
@@ -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;
}
@@ -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; }
}
@@ -39,6 +39,11 @@ public final class FlowApplyRetryModels {
* 这样"网络重投"与"用户授权重提"在中心侧也是两个不同的事实。 * 这样"网络重投"与"用户授权重提"在中心侧也是两个不同的事实。
*/ */
private Boolean retryOnly; private Boolean retryOnly;
/**
* 期望的前一轮次(T04 服务端轮次,可选):显式重提必须声明基于第 N 轮;
* 服务端在坐标锁内核对并只分配 N+1,不信客户端 attempt。null=既有语义。
*/
private Integer expectedPrevRound;
} }
/** v2 响应:在旧 {instanceId,duplicated} 之上把"中心判出来的是哪一种命中"说清楚 */ /** v2 响应:在旧 {instanceId,duplicated} 之上把"中心判出来的是哪一种命中"说清楚 */
@@ -60,6 +65,9 @@ public final class FlowApplyRetryModels {
private String branch; private String branch;
/** 行上的当前轮次(消费方据此校正自己的命令表认知) */ /** 行上的当前轮次(消费方据此校正自己的命令表认知) */
private Integer applyRound; private Integer applyRound;
/** 同一响应的真实状态/实例版本;分支判据保持不变。 */
private Integer status;
private Integer version;
} }
/** 分支码常量(与 {@link ApplyResultV2#branch} 同值域) */ /** 分支码常量(与 {@link ApplyResultV2#branch} 同值域) */
@@ -71,6 +79,11 @@ public final class FlowApplyRetryModels {
public static final String REPLAY_ONLY = "REPLAY_ONLY"; public static final String REPLAY_ONLY = "REPLAY_ONLY";
public static final String RESTARTED = "RESTARTED"; public static final String RESTARTED = "RESTARTED";
public static final String CONCURRENT_CREATED = "CONCURRENT_CREATED"; public static final String CONCURRENT_CREATED = "CONCURRENT_CREATED";
/**
* 冲突(2026-10-25 T04):同 token 异载荷、或带 token 撞上他人/其他轮次的在途与通过件——
* 调用方(OapiFlowService)把该分支翻译成业务异常,不伪装幂等成功。
*/
public static final String CONFLICT = "CONFLICT";
private Branch() { private Branch() {
} }
@@ -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;
}
}
@@ -107,6 +107,13 @@ public final class OapiModels {
@Data @Data
public static class InstanceVo { public static class InstanceVo {
private Long instanceId; 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 status;
private Integer version; private Integer version;
private String nodeSummary; private String nodeSummary;
@@ -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,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);
}
@@ -13,6 +13,9 @@ import java.util.List;
public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog, Long> { public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog, Long> {
/** T05 投递幂等:同一事件事实对同一订阅方只建一行(补分发与正常分发竞争时去重) */
boolean existsByEventIdAndRegId(String eventId, Long regId);
/** 重推扫描主路径:待推/重试中且到点 */ /** 重推扫描主路径:待推/重试中且到点 */
List<FlwCallbackLog> findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc( List<FlwCallbackLog> findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc(
Integer status, LocalDateTime now); Integer status, LocalDateTime now);
@@ -14,7 +14,7 @@ public interface FlwCcRepository extends JpaRepository<FlwCc, Long> {
/** 抄送我的(用户面页签,走 user+sent_time 索引;appKey 可空=不按应用过滤,口径=定义归属 flw_def.app_key) */ /** 抄送我的(用户面页签,走 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 " @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 " + "where c.schId = :schId and c.userId = :userId "
+ "and (:appKey is null or d.appKey = :appKey) order by c.sentTime desc") + "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, Page<FlwCc> pageCc(@Param("schId") Long schId, @Param("userId") Integer userId,
@Param("appKey") String appKey, Pageable pageable); @Param("appKey") String appKey, Pageable pageable);
@@ -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);
}
@@ -32,12 +32,22 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
List<FlwDef> findBySchIdOrderByUpdateTimeDesc(Long schId); 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 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") + "and d2.flowGuid = d.flowGuid and d2.status = 1) order by d.updateTime desc")
List<FlwDef> findPublishedLatestPerGuid(@Param("schId") Long schId); 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); List<FlwDef> findBySchIdAndFlowGuidIn(Long schId, Collection<String> flowGuids);
} }
@@ -12,8 +12,38 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long> { public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long> {
@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 幂等主键 */
/**
* 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> findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(Long school, String source, String type, String biz);
@Query("select i from FlwInstance i where i.schId=:school and i.sourcePrincipalId=:source and (:bizType is null or i.bizType=:bizType) and (:status is null or i.status=:status) order by i.id desc")
Page<FlwInstance> pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable);
Optional<FlwInstance> findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
Long school, String source, String bizType, String bizId, Integer slot);
/** Unknown-source rows are never used to recover a caller's instance. */
@Query("select i from FlwInstance i where i.schId=?1 and i.bizType=?2 and i.bizId=?3 and i.sourcePrincipalId is null")
Optional<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId); Optional<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
List<FlwInstance> findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status); List<FlwInstance> findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status);
@@ -0,0 +1,24 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwStateEvent;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface FlwStateEventRepository extends JpaRepository<FlwStateEvent, Long> {
/** 实例内单调序号取号:调用方持有该实例的状态行锁(同事务状态变更),max+1 串行安全 */
@Query("select coalesce(max(e.eventSeq), 0) from FlwStateEvent e where e.instanceId = :instanceId")
int maxSeqByInstance(@Param("instanceId") Long instanceId);
/** 崩溃窗口恢复扫描:未分发且已过缓冲期(给 AFTER_COMMIT 首分发留时间) */
List<FlwStateEvent> findTop50ByDispatchedAndCreateTimeLessThanEqualOrderByCreateTimeAsc(
Integer dispatched, LocalDateTime deadline);
java.util.Optional<FlwStateEvent> findByEventId(String eventId);
boolean existsByEventId(String eventId);
}
@@ -13,6 +13,12 @@ import java.util.List;
public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> { public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
/** Additive D09 query: the real transfer parent is in this set. Ambiguous generations must be rejected. */
@Query(value="select t.id from flw_task t join flw_task n on n.id=:newTaskId "
+ "where t.instance_id=n.instance_id and t.node_id=n.node_id and t.status=5 "
+ "and t.assignee_id=:operatorId and t.handle_time=n.arrive_time", nativeQuery=true)
List<Long> findTransferParentCandidates(@Param("newTaskId") Long newTaskId, @Param("operatorId") Integer operatorId);
List<FlwTask> findByInstanceIdOrderByIdAsc(Long instanceId); List<FlwTask> findByInstanceIdOrderByIdAsc(Long instanceId);
List<FlwTask> findByInstanceIdAndStatus(Long instanceId, Integer status); List<FlwTask> findByInstanceIdAndStatus(Long instanceId, Integer status);
@@ -52,10 +58,13 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId") @Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId")
int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId); int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId);
@Query("select t from FlwTask t join FlwInstance i on i.id=t.instanceId where t.schId=:school and i.sourcePrincipalId=:source and t.assigneeId=:user and t.status=0 and (:type is null or i.bizType=:type) order by t.id asc")
Page<FlwTask> pageSelfTodo(@Param("school") Long school,@Param("source") String source,@Param("user") Integer user,@Param("type") String type, Pageable pageable);
/** 我的待办(主路径,走 assignee 索引;appKey 可空=不按应用过滤,口径=办件定义归属应用 flw_def.app_key) */ /** 我的待办(主路径,走 assignee 索引;appKey 可空=不按应用过滤,口径=办件定义归属应用 flw_def.app_key) */
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId " @Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
+ "where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 " + "where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 "
+ "and (:appKey is null or d.appKey = :appKey) " + "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) "
+ "order by t.arriveTime asc, t.id asc") + "order by t.arriveTime asc, t.id asc")
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
@Param("appKey") String appKey, Pageable pageable); @Param("appKey") String appKey, Pageable pageable);
@@ -84,7 +93,7 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId " @Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
+ "where t.schId = :schId and t.assigneeId = :assigneeId " + "where t.schId = :schId and t.assigneeId = :assigneeId "
+ "and t.status in :statuses " + "and t.status in :statuses "
+ "and (:appKey is null or d.appKey = :appKey) " + "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) "
+ "and t.id = (select max(t2.id) from FlwTask t2 where t2.instanceId = t.instanceId " + "and t.id = (select max(t2.id) from FlwTask t2 where t2.instanceId = t.instanceId "
+ " and t2.assigneeId = :assigneeId and t2.status in :statuses) " + " and t2.assigneeId = :assigneeId and t2.status in :statuses) "
+ "order by t.handleTime desc, t.id desc") + "order by t.handleTime desc, t.id desc")
@@ -0,0 +1,18 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwUsageGrant;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface FlwUsageGrantRepository extends JpaRepository<FlwUsageGrant, Long> {
boolean existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(Long school, String flow, String source, Integer enabled);
Optional<FlwUsageGrant> findBySchIdAndFlowGuidAndAppId(Long schId, String flowGuid, Integer appId);
List<FlwUsageGrant> findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(Long schId, String flowGuid);
List<FlwUsageGrant> findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(Long schId, Integer appId);
}
@@ -0,0 +1,49 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Set;
/** 发起前按冻结的定义契约校验;历史无声明版本保留原有的引用变量存在性规则。 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ApplyVariableValidator {
private final ObjectMapper objectMapper;
/** def 的全部 EXPRESSION 出边引用的业务变量键(去重保序) */
public Set<String> referencedBusinessKeys(FlowDefModel model) {
return com.yunhai.flowcenter.engine.VariableContract.referencedKeys(model);
}
public void requireVariablesPresent(FlowDefModel model, String formDataJson) {
com.yunhai.flowcenter.engine.VariableContract.validateInput(model, parse(formDataJson, model.variables != null));
}
/** 与 ConditionRouteService.parseVariables 同源:formData 顶层即变量;非对象 JSON=空(后续逐键报缺失) */
private Map<String, Object> parse(String formDataJson, boolean strict) {
if (formDataJson == null || formDataJson.trim().isEmpty()) {
return new java.util.HashMap<>();
}
try {
Map<String, Object> values = objectMapper.readValue(formDataJson, new TypeReference<Map<String, Object>>() { });
if (values == null) {
if (strict) throw new IllegalArgumentException("null object");
return new java.util.HashMap<>();
}
return values;
} catch (Exception e) {
if (strict) throw new BizException(ErrorCode.PARAM_ERROR, "formData 必须为 JSON 对象");
return new java.util.HashMap<>();
}
}
}
@@ -0,0 +1,36 @@
package com.yunhai.flowcenter.service;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* SDK 快捷审批 ActorContext 门禁(2026-10-25 T07 建立;同日 R1 返修改为无条件 fail-closed)。
*
* <p>oapi 通道的 approve/reject 只有<b>自报 operatorId/operatorName</b>——appKey 验签证明的是
* 应用,不是用户;"task.assigneeId == operatorId" 的本人比较 likewise 只能证明调用方知道
* 办理人 ID,不能证明实际办理的用户。在"可验证用户证明"(面向 FL 的用户会话票据/受限委托:
* 绑定用户、学校、调用应用、任务/动作范围、期限与防重放标识,见 Codex 终审 D03/C.2)落地前,
* 快捷审批<b>一律不放行,不存在放行配置</b>。</p>
*
* <p>R1 返修(Codex 终审第一轮):原 {@code flow-center.sdk-approve.require-user-proof}
* 开关的 false=存量放行语义已废除——未配置与 false 均不得默认放行不可信审批,此前依赖该
* 通道的存量消费方(work-leave、svc-hall 应用内审批快捷动作等)须迁移到标准 FL iframe/CAS
* 人工办理({@code FlowUserController} → {@code FlowTaskService.handle},会话用户身份,
* 不经本门)。未来 ActorContext 证明落地后,在此校验证明(用户/校/应用/任务/期限/nonce)
* 并按证明放行;届时再恢复配置化灰度。</p>
*/
@Slf4j
@Component
public class FlowActorContextGate {
/** oapi 审批动作前置门(approve/reject 共用):无条件拒绝 */
public void requireActorProof(Long schId, Long taskId, Integer operatorId) {
log.warn("[actor-gate] SDK 快捷审批被拒(无可验证用户证明,fail-closed):schId={} taskId={} operatorId={}",
schId, taskId, operatorId);
throw new BizException(ErrorCode.FORBIDDEN,
"SDK 快捷审批未放行:仅凭 appKey/operatorId 不构成用户授权。"
+ "请通过流程中心人工办理页(CAS)办理");
}
}
@@ -33,6 +33,7 @@ public class FlowCallbackAdminService {
private final FlwCallbackLogRepository logRepository; private final FlwCallbackLogRepository logRepository;
private final CallbackPushService pushService; private final CallbackPushService pushService;
private final CallbackProperties callbackProperties; private final CallbackProperties callbackProperties;
private final com.yunhai.flowcenter.callback.CallbackLogStore logStore;
public List<FlwCallbackReg> listRegs(Long schId) { public List<FlwCallbackReg> listRegs(Long schId) {
return regRepository.findBySchIdOrderByUpdateTimeDesc(schId); return regRepository.findBySchIdOrderByUpdateTimeDesc(schId);
@@ -104,7 +105,6 @@ public class FlowCallbackAdminService {
* 管理端手工标记死信(持续重试语义下死信的唯一人工入口:人工判定事件无意义时止血)。 * 管理端手工标记死信(持续重试语义下死信的唯一人工入口:人工判定事件无意义时止血)。
* 标记后可经既有 rePush 复活。 * 标记后可经既有 rePush 复活。
*/ */
@Transactional
public void markDead(Long schId, Long logId) { public void markDead(Long schId, Long logId) {
FlwCallbackLog callbackLog = logRepository.findById(logId) FlwCallbackLog callbackLog = logRepository.findById(logId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "推送日志不存在:" + logId)); .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "推送日志不存在:" + logId));
@@ -119,7 +119,6 @@ public class FlowCallbackAdminService {
} }
/** 手工重推(死信/失败日志):置回待推并立即触发一次推送 */ /** 手工重推(死信/失败日志):置回待推并立即触发一次推送 */
@Transactional
public void rePush(Long schId, List<Long> logIds) { public void rePush(Long schId, List<Long> logIds) {
if (logIds == null || logIds.isEmpty()) { if (logIds == null || logIds.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要重推的日志"); throw new BizException(ErrorCode.PARAM_ERROR, "请选择要重推的日志");
@@ -128,20 +127,9 @@ public class FlowCallbackAdminService {
if (logs.size() != logIds.size()) { if (logs.size() != logIds.size()) {
throw new BizException(ErrorCode.PARAM_ERROR, "存在不属于该校或不存在 dead/pending 日志"); throw new BizException(ErrorCode.PARAM_ERROR, "存在不属于该校或不存在 dead/pending 日志");
} }
for (FlwCallbackLog callbackLog : logs) { // 短事务提交并释放会话锁后才执行 HTTP;不能把管理端旧实体全量回写覆盖刚成功的状态。
if (callbackLog.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) { for (Long id : logStore.resetForReplay(schId, logIds)) {
continue; pushService.pushAsync(id);
}
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
callbackLog.setRetryCount(0);
callbackLog.setNextRetryTime(LocalDateTime.now());
logRepository.save(callbackLog);
}
logRepository.flush();
for (FlwCallbackLog callbackLog : logs) {
if (callbackLog.getStatus() != null) {
pushService.pushAsync(callbackLog.getId());
}
} }
} }
@@ -3,10 +3,12 @@ package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.DefValidator; import com.yunhai.flowcenter.engine.DefValidator;
import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.VariableContract;
import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwAssigneeMap; import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
@@ -192,12 +194,13 @@ public class FlowDefAdminService {
boolean pairing = formUuid != null && !clearing && formVersion == null; boolean pairing = formUuid != null && !clearing && formVersion == null;
boolean versionOnly = formUuid == null && formVersion != null; boolean versionOnly = formUuid == null && formVersion != null;
if (pairing || versionOnly) { if (pairing || versionOnly) {
throw new BizException(ErrorCode.PARAM_ERROR, throw new BizException(formVersion == null ? FormBindingErrorCode.VERSION_REQUIRED
: FormBindingErrorCode.INVALID_COORDINATE,
"关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid=" "关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid="
+ (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion); + (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion);
} }
if (formUuid != null && !clearing) { if (formUuid != null && !clearing) {
checkedSchema(schId, formUuid.trim(), formVersion); // Validate before the first save. checkedSchema(schId, formUuid.trim(), formVersion, "保存"); // Validate before the first save.
} }
FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog); FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
if (formUuid != null) { if (formUuid != null) {
@@ -216,7 +219,7 @@ public class FlowDefAdminService {
* 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照 * 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照
* 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验), * 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验),
* 并做表达式<b>键查①</b>红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试); * 并做表达式<b>键查①</b>红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试);
* 运行期对 form-center 零依赖。form_uuid 为空(存量线/Mode C)行为逐字不变。 */ * 运行期对 form-center 零依赖;新发布的无绑定版本必须显式声明 variables。 */
@Transactional @Transactional
public FlwDef publish(Long schId, Long id) { public FlwDef publish(Long schId, Long id) {
FlwDef def = requireDef(schId, id); FlwDef def = requireDef(schId, id);
@@ -227,86 +230,33 @@ public class FlowDefAdminService {
throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿"); throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿");
} }
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) { if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
Map<String, Object> snapshot; Map<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
try { def.getFormVersion(), "发布");
// 管理链始终使用确切版本;缺版本须重新选择,不回落当前发布版。 model.variables = VariableContract.fromSnapshot(snapshot);
snapshot = checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion()); VariableContract.validateDefinition(model, true);
} catch (FormCenterSchemaClient.SchemaUnavailableException e) { DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
throw new BizException(ErrorCode.PARAM_ERROR, def.setContentJson(embedFormContract(def.getContentJson(), snapshot, model.variables));
"关联表单快照拉取失败,发布已阻断(可重试):" + e.getMessage()); } else if (def.getFormVersion() != null) {
} catch (FormCenterSchemaClient.SchemaContractViolationException e) { throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
throw new BizException(ErrorCode.PARAM_ERROR, }
"关联表单版本核验失败,发布已阻断(重试无意义,请重新选择表单与版本):" + e.getMessage()); if (def.getFormUuid() == null || def.getFormUuid().trim().isEmpty()) {
} VariableContract.validateDefinition(model, true);
List<String> problems = new ArrayList<>(); DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
validateExpressionTypesAndDomains(model, snapshot, problems);
if (!problems.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"发布校验失败(表达式三查):\n" + String.join("\n", problems));
}
def.setContentJson(embedFormContract(def.getContentJson(), snapshot));
} }
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now()); def.setPublishTime(LocalDateTime.now());
return defRepository.save(def); return defRepository.save(def);
} }
/**
* 表达式键查①(C1 三查之一,红牌):对每条 EXPRESSION 判断的出边表达式,
* 变量集 ⊆ 快照字段 keys ∪ 内置变量({@link DefValidator#EXPRESSION_BUILTINS})。
* 不执行表达式、不改沙箱(口径与 v2 方案 §4.7 一致)。
*/
private void validateExpressionKeysAgainstSnapshot(FlowDefModel model, Map<String, Object> snapshot,
List<String> problems) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
java.util.Set<String> fieldKeys = new java.util.HashSet<>();
if (fields != null) {
for (Map<String, Object> f : fields) {
Object k = f.get("key");
if (k != null) {
fieldKeys.add(String.valueOf(k));
}
}
}
if (fieldKeys.isEmpty()) {
problems.add("关联表单快照无字段(formContract.fields 为空):请检查表单是否已发布");
return;
}
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !n.expressionJudge()) {
continue;
}
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null || e.condition.trim().isEmpty()) {
continue;
}
List<String> vars;
try {
vars = com.yunhai.flowcenter.engine.ExpressionEvaluator.variableNames(e.condition.trim());
} catch (Exception ex) {
// 语法错已由 DefValidator 拦下,这里理论上到不了;防御口径跳过
continue;
}
for (String v : vars) {
if (!fieldKeys.contains(v) && !DefValidator.EXPRESSION_BUILTINS.contains(v)) {
problems.add("节点「" + n.name + "」出口「" + e.name + "」表达式变量 " + v
+ " 不在关联表单字段集内(快照 " + fieldKeys.size() + " 个字段)");
}
}
}
}
}
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */ /** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
private String embedFormContract(String contentJson, Map<String, Object> snapshot) { private String embedFormContract(String contentJson, Map<String, Object> snapshot,
List<FlowDefModel.Variable> variables) {
try { try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode) com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson); objectMapper.readTree(contentJson);
root.set("formContract", objectMapper.valueToTree(snapshot)); root.set("formContract", objectMapper.valueToTree(snapshot));
root.set("variables", objectMapper.valueToTree(variables));
return objectMapper.writeValueAsString(root); return objectMapper.writeValueAsString(root);
} catch (BizException e) { } catch (BizException e) {
throw e; throw e;
@@ -315,109 +265,46 @@ public class FlowDefAdminService {
} }
} }
// ===================== 三查②③(2026-10-20 C1 第一刀余量项) =====================
/** 数值桶:可与数值字面量比大小的快照字段类型 */
private static final java.util.Set<String> NUMERIC_TYPES =
new java.util.HashSet<>(java.util.Arrays.asList("number"));
/** 值域字面量型(enum 域查③适用):options 收进快照 enum 的类型 */
private static final java.util.Set<String> ENUM_TYPES =
new java.util.HashSet<>(java.util.Arrays.asList("select", "radio"));
/** `字段 op '字面量'` / `字段 op 数值`(op 含两侧空格容忍) */
private static final java.util.regex.Pattern FIELD_STR_EQ = java.util.regex.Pattern.compile(
"([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=)\\s*'([^']*)'");
private static final java.util.regex.Pattern FIELD_NUM_CMP = java.util.regex.Pattern.compile(
"([A-Za-z_][A-Za-z0-9_]*)\\s*(>=|<=|>|<)\\s*(-?\\d+(\\.\\d+)?)");
private static final java.util.regex.Pattern NUM_FIELD_CMP = java.util.regex.Pattern.compile(
"(-?\\d+(\\.\\d+)?)\\s*(>=|<=|>|<)\\s*([A-Za-z_][A-Za-z0-9_]*)");
/**
* 类型桶查②红牌 + 域查③红牌(<b>启发式实现</b>:正则模式匹配字面量比较形态,非 AST——
* v2 §4.7 保守边界:不执行表达式、不做隐式转换,只拦"数值比较 × 非数值字段"与
* "枚举等值 × 字面量不在值域"两类高发错配;不命中的形态留给运行期既有挂起纪律)。
*/
@SuppressWarnings("unchecked")
private void validateExpressionTypesAndDomains(FlowDefModel model, Map<String, Object> snapshot,
List<String> problems) {
List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
if (fields == null || fields.isEmpty()) {
return;
}
Map<String, Map<String, Object>> fieldByKey = new HashMap<>();
for (Map<String, Object> f : fields) {
Object k = f.get("key");
if (k != null) {
fieldByKey.put(String.valueOf(k), f);
}
}
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !n.expressionJudge()) {
continue;
}
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null || e.condition.trim().isEmpty()) {
continue;
}
String where = "节点「" + n.name + "」出口「" + e.name + "」";
String expr = e.condition.trim();
// ② 类型桶查:数值比较算子作用于非数值字段 → 红牌
java.util.regex.Matcher m = FIELD_NUM_CMP.matcher(expr);
while (m.find()) {
checkNumericBucket(where, m.group(1), m.group(2), fieldByKey, problems);
}
m = NUM_FIELD_CMP.matcher(expr);
while (m.find()) {
checkNumericBucket(where, m.group(4), m.group(3), fieldByKey, problems);
}
// ② 类型桶查(字符串侧):文本字面量等值比较数值字段 → 红牌
// ③ 域查:枚举字段等值比较的字面量 ∉ 快照 enum 值域 → 红牌
m = FIELD_STR_EQ.matcher(expr);
while (m.find()) {
String key = m.group(1);
String op = m.group(2);
String literal = m.group(3);
Map<String, Object> field = fieldByKey.get(key);
if (field == null) {
continue; // 内置变量或未知键:①已拦/不属本查
}
String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
if (NUMERIC_TYPES.contains(type)) {
problems.add(where + ":字段 " + key + " 是 number 类型,不能与字符串字面量 '"
+ literal + "' 做 " + op + " 比较(类型桶查②)");
} else if (ENUM_TYPES.contains(type) && "==".equals(op)) {
List<String> enums = (List<String>) field.get("enum");
if (enums != null && !enums.isEmpty() && !enums.contains(literal)) {
problems.add(where + ":字段 " + key + " 的值域是 " + enums
+ ",字面量 '" + literal + "' 不在其中(域查③)");
}
}
}
}
}
}
private void checkNumericBucket(String where, String key, String op,
Map<String, Map<String, Object>> fieldByKey, List<String> problems) {
Map<String, Object> field = fieldByKey.get(key);
if (field == null) {
return;
}
String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
if (type != null && !NUMERIC_TYPES.contains(type)) {
problems.add(where + ":字段 " + key + " 是 " + type + " 类型,不能做数值比较 "
+ key + " " + op + " 数字(类型桶查②)");
}
}
/** 停用:不可新发起,存量跑完 */ /** 停用:不可新发起,存量跑完 */
@Transactional @Transactional
public void offline(Long schId, Long id) { public void offline(Long schId, Long id) {
FlwDef def = requireDef(schId, 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()); def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
defRepository.save(def); 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 对称。 * 启用(2026-09-18):只对"停用"版恢复为已发布,与 offline 对称。
* 复用发布期的静态校验(把"任务无主"重新拦一次)——若建的时候某科室还有启用办理人、之后 * 复用发布期的静态校验(把"任务无主"重新拦一次)——若建的时候某科室还有启用办理人、之后
@@ -435,22 +322,38 @@ public class FlowDefAdminService {
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) { if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion()); 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.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now()); def.setPublishTime(LocalDateTime.now());
return defRepository.save(def); return defRepository.save(def);
} }
private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version) { private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version, String operation) {
if (version == null || version < 1) { if (version == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "关联表单必须选择确切的正整数版本,请重新选择"); throw new BizException(FormBindingErrorCode.VERSION_REQUIRED, "关联表单缺少明确版本,"
+ operation + "已阻断,请重新选择表单与版本");
}
if (version < 1 || uuid == null || uuid.trim().isEmpty()) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE,
"关联表单必须选择确切的正整数版本," + operation + "已阻断,请重新选择");
} }
try { try {
return formCenterSchemaClient.fetchSchema(schId, uuid, version); return formCenterSchemaClient.fetchSchema(schId, uuid, version);
} catch (FormCenterSchemaClient.SchemaUnavailableException } catch (FormCenterSchemaClient.SchemaUnavailableException e) {
| FormCenterSchemaClient.SchemaContractViolationException e) { throw new BizException(FormBindingErrorCode.SCHEMA_UNAVAILABLE,
throw new BizException(ErrorCode.PARAM_ERROR, "关联表单核验失败,操作已阻断:" + e.getMessage()); "关联表单快照拉取失败," + 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);
} }
} }
@@ -509,7 +412,12 @@ public class FlowDefAdminService {
} }
def.setName(cleanName); def.setName(cleanName);
def.setAppKey(cleanAppKey); def.setAppKey(cleanAppKey);
def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName)); // 发布封存(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 { try {
return defRepository.save(def); return defRepository.save(def);
} catch (DataIntegrityViolationException e) { } catch (DataIntegrityViolationException e) {
@@ -24,7 +24,8 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
* 办件生命周期:代发起(幂等 + R4 重启重提)、撤回。 * 引擎实例生命周期与迁移前代发起。正式命令通过 create/startFormalInstance 建立独立轮次实例,
* 命令受理、坐标锁和轮次分配归 FormalApplyCommandService;下述 R4 复用仅描述旧入口。
* *
* <p><b>R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿)</b>:biz uk 命中已终态办件 * <p><b>R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿)</b>:biz uk 命中已终态办件
* (30 已拒绝 / 40 已撤回)时复用同一办件行"重启推进"——不新建、不报错:定义刷新为当前 * (30 已拒绝 / 40 已撤回)时复用同一办件行"重启推进"——不新建、不报错:定义刷新为当前
@@ -63,51 +64,51 @@ public class FlowInstanceService {
public final String applyAppCode; public final String applyAppCode;
/** 发起时指派快照 JSON(oapi apply 校验后渲染;null=无指派) */ /** 发起时指派快照 JSON(oapi apply 校验后渲染;null=无指派) */
public final String assignJson; public final String assignJson;
/**
* 受理载荷指纹(2026-10-25 T04,SHA-256 hex;<b>非构造参数</b>——13 参构造被
* {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值;
* null=存量调用方,指纹核对缺省关)。
*/
public String payloadHash;
/** Assigned only by authenticated server-side source resolution; not a request DTO field. */
public String sourcePrincipalId;
} }
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */ /** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
@Transactional @Transactional
public ApplyOutcome apply(ApplyCommand cmd) { public ApplyOutcome apply(ApplyCommand cmd) {
FlwInstance existing = instanceRepository FlwInstance existing = findOwned(cmd);
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
if (existing != null) { if (existing != null) {
requireSameOwner(existing, cmd);
int status = existing.getStatus(); int status = existing.getStatus();
if (status == FlwEnums.InstanceStatus.APPROVING.getCode() if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) { || status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
return new ApplyOutcome(existing.getId(), true); return new ApplyOutcome(existing.getId(), true);
} }
// 30/40:R4 重启推进(复用办件行,定义/快照/时间刷新,重新走 START)
restart(existing, cmd); restart(existing, cmd);
return new ApplyOutcome(existing.getId(), true); return new ApplyOutcome(existing.getId(), true);
} }
FlwInstance instance = new FlwInstance(); FlwInstance created = createInstance(cmd, java.util.UUID.randomUUID().toString(), 1, cmd.sourcePrincipalId, 1, true);
instance.setSchId(cmd.schId); return new ApplyOutcome(created.getId(), false);
instance.setDefId(cmd.def.getId()); }
instance.setDefCode(cmd.def.getCode());
instance.setDefVersion(cmd.def.getVersion()); private FlwInstance findOwned(ApplyCommand cmd) {
instance.setBizType(cmd.bizType); if (cmd.schId == null || cmd.schId <= 0 || cmd.sourcePrincipalId == null
instance.setBizId(cmd.bizId); || !cmd.sourcePrincipalId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) {
instance.setBizTitle(cmd.bizTitle); throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源或有效申报学校");
instance.setBizUrl(cmd.bizUrl); }
instance.setBizSnapshot(cmd.bizSnapshot); FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
instance.setFormDataJson(cmd.formDataJson); cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, 1).orElse(null);
instance.setAssignJson(cmd.assignJson); if (existing == null && instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(
instance.setApplyAppKey(cmd.applyAppKey); cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId).isPresent()) {
instance.setApplyAppCode(cmd.applyAppCode); throw new BizException(ErrorCode.FORBIDDEN, "该业务对象已有正式命令实例,请使用原命令通道");
instance.setSubmitterId(cmd.submitterId); }
instance.setSubmitterName(cmd.submitterName); return existing;
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instanceRepository.saveAndFlush(instance);
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
return new ApplyOutcome(instance.getId(), false);
} }
private void restart(FlwInstance instance, ApplyCommand cmd) { private void restart(FlwInstance instance, ApplyCommand cmd) {
restart(instance, cmd, null, null); restart(instance, cmd, java.util.UUID.randomUUID().toString(), instance.getApplyRound());
} }
/** /**
@@ -138,6 +139,10 @@ public class FlowInstanceService {
if (round != null) { if (round != null) {
instance.setApplyRound(round); instance.setApplyRound(round);
} }
if (cmd.payloadHash != null) {
// 新一轮命令重新取指纹(新轮=新命令,指纹跟随最新受理载荷)
instance.setApplyPayloadHash(cmd.payloadHash);
}
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now()); instance.setSubmitTime(LocalDateTime.now());
instance.setFinishTime(null); instance.setFinishTime(null);
@@ -297,6 +302,16 @@ public class FlowInstanceService {
public final String requestToken; public final String requestToken;
public final Integer attempt; public final Integer attempt;
public final boolean retryOnly; public final boolean retryOnly;
/**
* 期望的前一轮次(2026-10-25 T04 服务端轮次收口,可选):显式申报"我基于第 N 轮重提"。
* 非空时:行轮次 ≠ N ⇒ STALE_ROUND(不重启);通过则服务端分配 round=N+1,
* <b>不信客户端 attempt</b>。null=既有客户端 attempt 语义(存量兼容)。
*/
public final Integer expectedPrevRound;
public ApplyRetryPolicy(String requestToken, Integer attempt, boolean retryOnly) {
this(requestToken, attempt, retryOnly, null);
}
} }
/** 守卫化结果:除 instanceId/duplicated 外,把"命中的是哪一种情形"落成稳定分支码 */ /** 守卫化结果:除 instanceId/duplicated 外,把"命中的是哪一种情形"落成稳定分支码 */
@@ -327,15 +342,14 @@ public class FlowInstanceService {
*/ */
@Transactional @Transactional
public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) { public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) {
FlwInstance existing = instanceRepository FlwInstance existing = findOwned(cmd);
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
if (existing != null) { if (existing != null) {
return settleExisting(existing, cmd, policy); return settleExisting(existing, cmd, policy);
} }
String token = policy == null ? null : trimToNull(policy.requestToken); String token = policy == null ? null : trimToNull(policy.requestToken);
Integer round = policy == null || policy.attempt == null ? Integer.valueOf(1) : policy.attempt; Integer round = policy == null || policy.attempt == null ? Integer.valueOf(1) : policy.attempt;
createInstance(cmd, token, round); FlwInstance created = createInstance(cmd, token, round, cmd.sourcePrincipalId, 1, true);
return new GuardedOutcome(instanceOf(cmd), false, FlowApplyRetryModels.Branch.CREATED, round); return new GuardedOutcome(created.getId(), false, FlowApplyRetryModels.Branch.CREATED, round);
} }
private GuardedOutcome settleExisting(FlwInstance existing, ApplyCommand cmd, ApplyRetryPolicy policy) { private GuardedOutcome settleExisting(FlwInstance existing, ApplyCommand cmd, ApplyRetryPolicy policy) {
@@ -345,6 +359,15 @@ public class FlowInstanceService {
// 新一轮授权重提(attempt≥2)才走得动 R4——存量行的守卫缺省是**关**而不是开。 // 新一轮授权重提(attempt≥2)才走得动 R4——存量行的守卫缺省是**关**而不是开。
int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound(); int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
if (token != null && token.equals(existing.getApplyRequestToken())) { if (token != null && token.equals(existing.getApplyRequestToken())) {
// T04(2026-10-25):同 token 必须同指纹——同 ID 不同载荷是两份命令撞同一 token,
// 按冲突拒绝,不伪装成幂等成功。存量行(指纹 NULL)或请求无指纹时跳过(守卫缺省关)。
if (existing.getApplyPayloadHash() != null && cmd.payloadHash != null
&& !existing.getApplyPayloadHash().equals(cmd.payloadHash)) {
log.warn("[flow-center] 同请求标识但载荷指纹不一致(冲突拒绝):instance={} biz={}/{}",
existing.getId(), cmd.bizType, cmd.bizId);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
}
log.info("[flow-center] 同请求标识重投命中,只回读现状不重启:instance={} biz={}/{} status={}", log.info("[flow-center] 同请求标识重投命中,只回读现状不重启:instance={} biz={}/{} status={}",
existing.getId(), cmd.bizType, cmd.bizId, existing.getStatus()); existing.getId(), cmd.bizType, cmd.bizId, existing.getStatus());
return new GuardedOutcome(existing.getId(), true, return new GuardedOutcome(existing.getId(), true,
@@ -353,6 +376,15 @@ public class FlowInstanceService {
int status = existing.getStatus(); int status = existing.getStatus();
if (status == FlwEnums.InstanceStatus.APPROVING.getCode() if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) { || status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
// T04:带 token 的请求撞上在途/通过件且 token 不符=另一份命令(审计 P0-2:
// "10/20 即使异命令也回 IDEMPOTENT"是把他人命令的成功当自己的)。无 token 的
// 存量调用方保持 IDEMPOTENT(守卫缺省关)。
if (token != null) {
log.warn("[flow-center] 在途/通过件命中且请求 token 不符(冲突拒绝):instance={} biz={}/{}",
existing.getId(), cmd.bizType, cmd.bizId);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
}
return new GuardedOutcome(existing.getId(), true, return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.IDEMPOTENT, existing.getApplyRound()); FlowApplyRetryModels.Branch.IDEMPOTENT, existing.getApplyRound());
} }
@@ -363,21 +395,53 @@ public class FlowInstanceService {
return new GuardedOutcome(existing.getId(), true, return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.REPLAY_ONLY, existing.getApplyRound()); FlowApplyRetryModels.Branch.REPLAY_ONLY, existing.getApplyRound());
} }
int incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt; // T04 服务端轮次:显式 expectedPrevRound 时——行轮次必须恰为 N(否则陈旧),服务端只分配 N+1;
if (incomingRound <= currentRound) { // 未显式时保持客户端 attempt 语义(存量兼容)。
log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}", Integer expectedPrev = policy == null ? null : policy.expectedPrevRound;
existing.getId(), currentRound, incomingRound); int incomingRound;
return new GuardedOutcome(existing.getId(), true, if (expectedPrev != null) {
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound()); if (expectedPrev != currentRound) {
log.warn("[flow-center] 重提前置轮次不符(不重启):instance={} 行轮次={} 期望前轮={}",
existing.getId(), currentRound, expectedPrev);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
}
incomingRound = currentRound + 1;
} else {
incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt;
if (incomingRound <= currentRound) {
log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}",
existing.getId(), currentRound, incomingRound);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
}
} }
restart(existing, cmd, token, incomingRound); restart(existing, cmd, token, incomingRound);
return new GuardedOutcome(existing.getId(), true, return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.RESTARTED, incomingRound); FlowApplyRetryModels.Branch.RESTARTED, incomingRound);
} }
/** 新建实例(与旧 apply 同一段代码形状:不复制第二份推进逻辑) */ /** Formal command caller must hold the coordinate lock in the same transaction. */
private void createInstance(ApplyCommand cmd, String token, Integer round) { @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FlwInstance createFormalInstance(ApplyCommand cmd, String token, Integer round, String source) {
return createInstance(cmd, token, round, source);
}
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void startFormalInstance(FlwInstance instance, ApplyCommand cmd) {
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
}
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) {
return createInstance(cmd, token, round, source, null, false);
}
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source,
Integer legacySlot, boolean start) {
FlwInstance instance = new FlwInstance(); FlwInstance instance = new FlwInstance();
instance.setSourcePrincipalId(source);
instance.setLegacySlot(legacySlot);
instance.setSchId(cmd.schId); instance.setSchId(cmd.schId);
instance.setDefId(cmd.def.getId()); instance.setDefId(cmd.def.getId());
instance.setDefCode(cmd.def.getCode()); instance.setDefCode(cmd.def.getCode());
@@ -393,6 +457,7 @@ public class FlowInstanceService {
instance.setApplyAppCode(cmd.applyAppCode); instance.setApplyAppCode(cmd.applyAppCode);
instance.setApplyRequestToken(token); instance.setApplyRequestToken(token);
instance.setApplyRound(round); instance.setApplyRound(round);
instance.setApplyPayloadHash(cmd.payloadHash);
instance.setSubmitterId(cmd.submitterId); instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName); instance.setSubmitterName(cmd.submitterName);
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
@@ -400,28 +465,19 @@ public class FlowInstanceService {
instanceRepository.saveAndFlush(instance); instanceRepository.saveAndFlush(instance);
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请", appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName); FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
flowEngine.startAdvance(instance, cmd.def); if (start) {
publishSubmitted(instance); flowEngine.startAdvance(instance, cmd.def);
} publishSubmitted(instance);
}
/** return instance;
* 旧 {@link #apply(ApplyCommand)} 的建件段复用(行为逐字不变,只是把 new 出来的那段抽出来)。
* 这里不复用 createInstance 是因为旧路径不写 token/round(保持存量行 NULL,不伪装"有守卫")。
*/
private Long instanceOf(ApplyCommand cmd) {
FlwInstance created = instanceRepository
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
return created == null ? null : created.getId();
} }
/** /**
* 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用 * 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用
* 相同 bizType 键当自己的结果")。两侧都非空才判——历史行没有 token/appKey 快照, * 相同 bizType 键当自己的结果")。来源必须存在且匹配,不用 appKey 快照替代来源 UUID。
* 不能因为"当年没记"就把存量件判成别人的。
*/ */
private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) { private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) {
if (existing.getApplyAppKey() != null && cmd.applyAppKey != null if (cmd.sourcePrincipalId == null || !cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) {
&& !existing.getApplyAppKey().equals(cmd.applyAppKey)) {
throw new BizException(ErrorCode.FORBIDDEN, throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")"); "该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
} }
@@ -0,0 +1,62 @@
package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/** Authenticated request scope; no user-supplied source/header is trusted. */
@Component @RequiredArgsConstructor
public class FlowOapiSourceScope implements HandlerInterceptor {
private static final String ATTRIBUTE = FlowOapiSourceScope.class.getName();
private final FlowSourcePrincipalResolver resolver;
private final FlwInstanceRepository instances;
private final FlwTaskRepository tasks;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String source = resolver.resolve((String) request.getAttribute("yh.app.auth")).id;
request.setAttribute(ATTRIBUTE, source);
String id = request.getParameter("instanceId");
java.util.regex.Matcher pathId = java.util.regex.Pattern.compile("/instance/(\\d+)(?:/|$)").matcher(request.getRequestURI());
if (id == null && pathId.find()) id = pathId.group(1);
String taskId = request.getParameter("taskId");
java.util.regex.Matcher taskPath = java.util.regex.Pattern.compile("/task/(\\d+)(?:/|$)").matcher(request.getRequestURI());
if (taskId == null && taskPath.find()) taskId = taskPath.group(1);
if (id != null) requireOwn(parse(id), source);
if (taskId != null) {
Long task = parse(taskId);
requireOwn(tasks.findById(task).orElseThrow(() -> denied()).getInstanceId(), source);
}
return true;
}
private void requireOwn(Long id, String source) {
FlwInstance instance = instances.findById(id).orElseThrow(() -> denied());
if (!source.equals(instance.getSourcePrincipalId())) throw denied();
}
public static String current() {
String source = currentOr(null);
if (source == null) throw denied();
return source;
}
public static String currentOr(String fallback) {
if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes)) return fallback;
Object value = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest().getAttribute(ATTRIBUTE);
return value instanceof String ? (String) value : fallback;
}
private Long parse(String value) {
try { return Long.valueOf(value); } catch (NumberFormatException e) { throw denied(); }
}
private static BizException denied() { return new BizException(ErrorCode.FORBIDDEN, "仅允许访问本应用来源的流程事实"); }
}
@@ -162,15 +162,18 @@ public class FlowRouterAdminService {
} }
/** /**
* 重跑路由(F5-4 僵尸逃生门):useLatestDef=true 时 def 改取该 flowGuid 当前已发布最高版本, * 重跑路由(挂起恢复唯一入口,§4.4)。2026-10-25 T03 在途锁版收口:useLatestDef=true 的
* 办件 defId/defCode/defVersion 快照同步刷新(与 R4 重启同语义,只刷定义三列,不动业务快照/ * "切到最新发布版续走"旁路已按"在途实例固定原版本"原则停用——新发布版只影响新申报;
* 状态/时间),trace 落一行留痕(保留节点 id=reroute-def,nodeName 载明 v旧→v新)。 * 挂起件按原定义重跑(修复表达式后按<b>原版本</b>重新发布或由发起方重新申报)。
* 锚定 128/129 僵尸案例:办件挂在 v1 的 CONDITION 节点、修好的表达式已发布为 v2,按 def 快照 * 保留参数与显式拒绝(不静默忽略),旧调用方拿到人类可读业务错。
* 重跑永远打转——此开关让管理员一键切到 v2 续走。在途 CONDITION 节点在新定义不存在时
* 按人类可读业务错拒绝(不猜测节点映射)。useLatestDef=false 现行为逐字不变。
*/ */
@Transactional @Transactional
public void reroute(Long schId, Long instanceId, boolean useLatestDef) { public void reroute(Long schId, Long instanceId, boolean useLatestDef) {
if (useLatestDef) {
throw new BizException(ErrorCode.PARAM_ERROR,
"在途换版已停用(在途实例固定使用原版本,新版本只影响新申报);"
+ "请修复表达式后按原版本重新发布,或让发起方终态后重新申报");
}
FlwInstance instance = instanceRepository.findById(instanceId) FlwInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId)); .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
if (!instance.getSchId().equals(schId)) { if (!instance.getSchId().equals(schId)) {
@@ -183,58 +186,15 @@ public class FlowRouterAdminService {
if (def == null) { if (def == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失"); throw new BizException(ErrorCode.PARAM_ERROR, "办件定义缺失");
} }
if (useLatestDef) {
def = refreshToLatestPublishedDef(instance, def);
}
String conditionNode = findParkedCondition(instance, def); String conditionNode = findParkedCondition(instance, def);
if (conditionNode == null) { if (conditionNode == null) {
throw new BizException(ErrorCode.PARAM_ERROR, useLatestDef throw new BizException(ErrorCode.PARAM_ERROR,
? "在途条件节点在最新已发布定义中不存在(节点已被改版移除/改型),无法按新定义重跑" "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
: "办件未挂在条件分支节点(无挂起的路由判断,无需重跑)");
} }
flowEngine.rerouteCondition(instance, def, conditionNode); flowEngine.rerouteCondition(instance, def, conditionNode);
instanceRepository.save(instance); instanceRepository.save(instance);
} }
/** 取 flowGuid 当前已发布最高版本并刷新办件定义快照(仅三列;已是最新则原样返回不落留痕) */
private FlwDef refreshToLatestPublishedDef(FlwInstance instance, FlwDef current) {
FlwDef latest = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
instance.getSchId(), current.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
.orElse(null);
if (latest == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该流程当前无已发布版本,无法按最新定义重跑");
}
if (latest.getId().equals(instance.getDefId())) {
return latest;
}
instance.setDefId(latest.getId());
instance.setDefCode(latest.getCode());
instance.setDefVersion(latest.getVersion());
appendRerouteDefTrace(instance, current.getVersion(), latest.getVersion());
return latest;
}
/**
* 定义切换留痕行(同 FlowInstanceService.appendTraceEvent 形态;彼方法私有且该类冻结,不共用):
* status=6 + 保留节点 id,nodeName 载明版本迁移;不参与引擎任何判断(同 submit/withdraw 行)。
*/
private void appendRerouteDefTrace(FlwInstance instance, Integer fromVersion, Integer toVersion) {
java.time.LocalDateTime now = java.time.LocalDateTime.now();
com.yunhai.flowcenter.entity.FlwTask row = new com.yunhai.flowcenter.entity.FlwTask();
row.setInstanceId(instance.getId());
row.setSchId(instance.getSchId());
row.setNodeId(FlwEnums.TaskStatus.NODE_REROUTE_DEF);
row.setNodeName("按新版定义重跑(v" + fromVersion + "→v" + toVersion + ")");
// assignee=0:管理端动作无自然办理人,落 0 不产生待办(同发起行脏数据兜底口径)
row.setAssigneeId(0);
row.setAssigneeName("管理端");
row.setStatus(FlwEnums.TaskStatus.SUBMITTED.getCode());
row.setArriveTime(now);
row.setHandleTime(now);
row.setUrgeCount(0);
taskRepository.save(row);
}
/** 在途节点里找 CONDITION(多个时取第一个;正常挂起只会有一个) */ /** 在途节点里找 CONDITION(多个时取第一个;正常挂起只会有一个) */
private String findParkedCondition(FlwInstance instance, FlwDef def) { private String findParkedCondition(FlwInstance instance, FlwDef def) {
String cur = instance.getCurrentNodes(); String cur = instance.getCurrentNodes();
@@ -0,0 +1,46 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class FlowSourcePrincipalResolver {
private final OpenApiAppClient appClient;
public Principal resolve(String authenticatedAppKey) {
if (authenticatedAppKey == null || authenticatedAppKey.trim().isEmpty()) {
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已认证的调用应用");
}
JsonNode app;
try { app = appClient.getAppByKey(authenticatedAppKey.trim()); }
catch (RuntimeException unavailable) {
throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体查询不可用");
}
if (app == null || !app.path("id").isIntegralNumber() || app.path("id").intValue() <= 0) {
throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体不可解析");
}
String source = app.path("sourcePrincipalId").asText("");
try {
if (!UUID.fromString(source).toString().equals(source)) throw new IllegalArgumentException();
} catch (IllegalArgumentException e) {
throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 稳定来源尚未迁移,禁止回退到数字 ID/appKey");
}
return new Principal(source, app.path("id").intValue(), authenticatedAppKey,
app.path("appCode").isTextual() ? app.path("appCode").textValue() : null);
}
@lombok.RequiredArgsConstructor
public static class Principal {
public final String id;
public final Integer appId;
public final String appKey;
public final String appCode;
}
}
@@ -246,7 +246,7 @@ public class FlowTaskService {
/** /**
* 移交(B4):办理权转给他人。当前任务置 TRANSFERRED 留痕,同节点新建一条待办给目标人 * 移交(B4):办理权转给他人。当前任务置 TRANSFERRED 留痕,同节点新建一条待办给目标人
* (deadline 沿用原任务);推进监听器因新待办 pending>0 自然不放行,无需改推进逻辑。 * (deadline 沿用原任务);推进监听器因新待办 pending>0 自然不放行,无需改推进逻辑。
* 目标人存在性由 AssigneeResolver 严格校验(查不到必须拒绝,否则产生僵尸待办)。 * 目标人资格由 AssigneeResolver 终审(存在/启用/本校,D11)——查不到必须拒绝,否则产生僵尸待办。
*/ */
@Transactional @Transactional
public void transfer(Long schId, Long taskId, Integer toUserId, String reason, Integer operatorId) { public void transfer(Long schId, Long taskId, Integer toUserId, String reason, Integer operatorId) {
@@ -259,7 +259,9 @@ public class FlowTaskService {
} }
FlwInstance instance = instanceRepository.findById(task.getInstanceId()) FlwInstance instance = instanceRepository.findById(task.getInstanceId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
String toUserName = assigneeResolver.requireUserName(toUserId); // D11 移交终审:目标必须存在/启用/属于本校;全部校验在抢占原任务之前完成,
// 失败不会先替换人员或改写任务状态。
String toUserName = assigneeResolver.requireEligibleAssigneeName(task.getSchId(), toUserId);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String fromLabel = task.getNodeName() == null ? task.getNodeId() : task.getNodeName(); String fromLabel = task.getNodeName() == null ? task.getNodeId() : task.getNodeName();
@@ -0,0 +1,128 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.flowcenter.entity.FlwUsageGrant;
import com.yunhai.flowcenter.repository.FlwUsageGrantRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 应用使用授权管理面(2026-10-25 T01):学校×流程线×稳定主体的授予/撤销/查询。
*
* <p>授予时把 appKey 解析成 UserBase 稳定主键 appId 落库(授权对象是主体不是凭据),
* 并按学校开通事实做发放闸(enforce 开启时未开通拒发)。撤销只置 {@code enabled=0}
* (不删行,审计与幂等判据保留);撤权只拦新发起,不回写在途实例与已受理命令。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowUsageGrantAdminService {
private final FlwUsageGrantRepository grantRepository;
private final UserBaseActivationClient activationClient;
@Autowired(required = false)
private OpenApiAppClient appClient;
public List<FlwUsageGrant> listByFlow(Long schId, String flowGuid) {
return grantRepository.findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(schId, flowGuid);
}
public List<FlwUsageGrant> listByApp(Long schId, Integer appId) {
return grantRepository.findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(schId, appId);
}
/**
* 授予/重申(三元键 upsert)。
*
* @throws BizException appKey 不可解析为稳定主体(fail-closed,管理面必须授到真实主体上);
* enforce 开启且应用未在该校开通或核验不可用
*/
@Transactional
public FlwUsageGrant grant(Long schId, String flowGuid, String appKey, String remark, String operator) {
if (schId == null || schId <= 0 || flowGuid == null || flowGuid.trim().isEmpty()
|| appKey == null || appKey.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "schId/flowGuid/appKey 必填");
}
String cleanKey = appKey.trim();
JsonNode registered = fetchRegistered(cleanKey);
Integer appId = registered == null ? null : readId(registered);
if (appId == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"appKey 未能在 UserBase 解析出稳定应用主体,拒绝授权:" + cleanKey);
}
if (!activationClient.isAppActivatedStrict(appId, schId)) {
throw new BizException(ErrorCode.FORBIDDEN,
"该应用未在学校 " + schId + " 开通,不能授予流程使用许可");
}
FlwUsageGrant grant = grantRepository
.findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId)
.orElseGet(FlwUsageGrant::new);
boolean isNew = grant.getId() == null;
if (isNew) {
grant.setSchId(schId);
grant.setFlowGuid(flowGuid.trim());
grant.setAppId(appId);
}
{
String source = registered.path("sourcePrincipalId").asText("");
try {
if (!java.util.UUID.fromString(source).toString().equals(source)) throw new IllegalArgumentException();
} catch (IllegalArgumentException invalid) {
throw new BizException(ErrorCode.FORBIDDEN, "UserBase 稳定来源未迁移,不能发放正式许可");
}
grant.setSourcePrincipalId(source);
}
grant.setAppKeySnapshot(cleanKey);
grant.setAppNameSnapshot(registered.path("appName").asText(null));
grant.setEnabled(1);
grant.setRemark(remark);
FlwUsageGrant saved = grantRepository.save(grant);
log.info("[usage-grant] 授予 schId={} flowGuid={} appId={} appKey={} operator={} 新建={}",
schId, flowGuid, appId, cleanKey, operator, isNew);
return saved;
}
/** 撤销(enabled=0,行保留);幂等:对已撤销行重复撤销不报错 */
@Transactional
public FlwUsageGrant revoke(Long schId, String flowGuid, Integer appId, String remark, String operator) {
FlwUsageGrant grant = grantRepository
.findBySchIdAndFlowGuidAndAppId(schId, flowGuid, appId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"使用许可不存在:school=" + schId + " flow=" + flowGuid + " app=" + appId));
grant.setEnabled(0);
if (remark != null && !remark.trim().isEmpty()) {
grant.setRemark(remark.trim());
}
FlwUsageGrant saved = grantRepository.save(grant);
log.info("[usage-grant] 撤销 schId={} flowGuid={} appId={} operator={}(只拦新发起,在途不受影响)",
schId, flowGuid, appId, operator);
return saved;
}
private JsonNode fetchRegistered(String appKey) {
if (appClient == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 应用注册通道未装配,拒绝授权");
}
try {
JsonNode node = appClient.getAppByKey(appKey);
return node == null || node.isNull() ? null : node;
} catch (RuntimeException e) {
log.warn("[usage-grant] UserBase 注册解析失败 appKey={}:{}", appKey, e.toString());
throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 应用注册解析不可用,请稍后重试");
}
}
private Integer readId(JsonNode registered) {
JsonNode idNode = registered.path("id");
return idNode.isNumber() ? idNode.intValue() : null;
}
}
@@ -0,0 +1,85 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.flowcenter.entity.FlwUsageGrant;
import com.yunhai.flowcenter.repository.FlwUsageGrantRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 应用使用许可闸门(2026-10-25 T01):新发起必须持有生效的 (school, flowGuid, appId) 许可。
*
* <p>三层独立事实,缺一不可(所有环境):</p>
* <ol>
* <li><b>稳定主体</b>:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用,
* 不是主体;主体解析不可达=fail-closed 拒绝);</li>
* <li><b>FL 本地使用许可</b>:{@code flw_usage_grant} 生效行(独立于 flw_app 定义保存白名单
* 与流程定义管理权);</li>
* <li><b>UB 学校开通</b>:该应用在该校的平台开通事实(第二重证明,不因本地 grant 而豁免)。</li>
* </ol>
*
* <p><b>撤权边界(P0-3)</b>:本闸只拦"新发起";已受理命令的回执恢复与在途实例不回读本表——
* 恢复路径的"是否已受理"判定归命令协议(T04),调用方负责在恢复命中时跳过本闸。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowUsageGrantGuard {
private final FlwUsageGrantRepository grantRepository;
private final UserBaseActivationClient activationClient;
@Autowired(required = false)
private OpenApiAppClient appClient;
/** Mandatory in every profile; the historical enforce flag cannot disable authorization. */
public boolean isEnforce() {
return true;
}
/**
* 新发起资格核验:返回稳定主体 appId(供命令快照/审计使用)。
*
* @throws BizException 所有环境:主体解析不可用、无生效许可、未在该校开通、
* UB 资格核验不可用——四类都拒绝,不猜不降级
*/
public Integer requireNewApplyGrant(Long schId, String flowGuid, String appKey) {
if (schId == null || flowGuid == null || flowGuid.trim().isEmpty()
|| appKey == null || appKey.trim().isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少使用许可判定所需的学校/流程线/调用主体");
}
return requireNewApplyGrant(schId, flowGuid, new FlowSourcePrincipalResolver(appClient).resolve(appKey));
}
/** UserBase app-school and FL app-flow are independent mandatory gates. */
public Integer requireNewApplyGrant(Long schId, String flowGuid, FlowSourcePrincipalResolver.Principal source) {
if (schId == null || schId <= 0 || flowGuid == null || flowGuid.trim().isEmpty() || source == null) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少有效申报学校/流程线/来源主体");
}
Integer appId = source.appId;
if (appId == null || appId <= 0 || source.id == null) {
throw new BizException(ErrorCode.FORBIDDEN, "调用应用主体解析不可用,已按未授权拒绝");
}
FlwUsageGrant grant = grantRepository.findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId)
.orElse(null);
if (grant == null || !Integer.valueOf(1).equals(grant.getEnabled())
|| !source.id.equals(grant.getSourcePrincipalId())) {
log.warn("[usage-grant] 拒绝新发起(无生效使用许可):schId={} flowGuid={} appId={} appKey={}",
schId, flowGuid, appId, source.appKey);
throw new BizException(ErrorCode.FORBIDDEN,
"该应用未获此流程线的使用许可(school=" + schId + " flow=" + flowGuid + ")");
}
if (!activationClient.isAppActivatedStrict(appId, schId)) {
log.warn("[usage-grant] 拒绝新发起(应用未在该校开通):schId={} appId={}", schId, appId);
throw new BizException(ErrorCode.FORBIDDEN,
"该应用未在学校 " + schId + " 开通,不能发起新申报");
}
return appId;
}
}
@@ -3,6 +3,9 @@ package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -14,10 +17,10 @@ import java.util.Map;
/** /**
* form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。<br> * form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。<br>
* GET {目录解析 form-center}/oapi/form/schema?schId=&amp;uuid= ——按 form_uuid 拉当前已发布版 schema * GET {目录解析 form-center}/oapi/form/schema?schId=&amp;uuid=&amp;version= ——只取明确发布版本。
* (T11 建立的 by-uuid 形态;version 缺省=当前发布版)。调用形态对齐 {@link RouteJudgeClient}: * 保存、发布与启用共用精确坐标契约;无 UUID-only 回退。调用形态对齐 YschOpenCall:
* 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。<br> * 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。<br>
* <b>只在发布期调用</b>(拉取失败=发布阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br> * <b>只在管理期调用</b>(拉取失败=阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
* audience 解析:<b>契约 v2 C5/G3 起是字符串数组</b>(breaking,T13 切换)——本客户端按数组读取, * audience 解析:<b>契约 v2 C5/G3 起是字符串数组</b>(breaking,T13 切换)——本客户端按数组读取,
* 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。 * 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。
*/ */
@@ -60,48 +63,28 @@ public class FormCenterSchemaClient {
} }
} }
/** /** FC 将不存在、不可读、未发布的精确版本统一拒绝,不能靠文案区分资源存在性。 */
* 发布期快照:{formUuid, formVersion, fields:[{key,type,required,enum?}], audience(数组)}。 public static class SchemaResourceUnavailableException extends RuntimeException {
* fields 由 groups 扁平化(分组只是展示结构,表达式键域与分组无关); public SchemaResourceUnavailableException(Throwable cause) {
* select/radio 类字段的 options 值域收进 enum(供域查③,本轮仅透传)。 super("表单或指定发布版本不存在或不可读", cause);
*/ }
public Map<String, Object> fetchSchema(Long schId, String formUuid) { }
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { public static class SchemaAccessDeniedException extends RuntimeException {
try { public SchemaAccessDeniedException(Throwable cause) {
Map<String, String> query = new HashMap<>(); super("表单访问身份或授权被拒绝", cause);
query.put("schId", String.valueOf(schId));
query.put("uuid", formUuid);
Map<String, Object> data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class);
if (data == null) {
throw new IllegalStateException("schema 返回空 data");
}
return toSnapshot(data);
} catch (RuntimeException e) {
last = e;
log.warn("[flow-center] form-center schema 快照拉取失败(第 {}/{} 次):uuid={} {}",
attempt, MAX_ATTEMPTS, formUuid, e.toString());
} catch (Exception e) {
last = new IllegalStateException(e.toString(), e);
log.warn("[flow-center] form-center schema 快照拉取异常(第 {}/{} 次):uuid={} {}",
attempt, MAX_ATTEMPTS, formUuid, e.toString());
}
} }
throw new SchemaUnavailableException(
"关联表单 schema 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last);
} }
/** /**
* 发布期快照的<b>确切版本</b>形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=}, * 发布期快照的<b>确切版本</b>形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=},
* 并把<b>返回坐标与请求坐标逐件核对</b>——不符即 {@link SchemaContractViolationException}(不重试)。 * 并把<b>返回坐标与请求坐标逐件核对</b>——不符即 {@link SchemaContractViolationException}(不重试)。
* *
* <p>刻意不与两参 {@link #fetchSchema(Long, String)} 共用循环体:那条是 C1 第一刀的既有行为 * fields 按分组扁平化,options 值域收进 enum;没有缺省当前版本的重载。
* ({@code FormCenterSchemaClientTest} 逐条钉住它的重试与文案),改它等于同时动两条契约。
* 新管理链(def 带 {@code formVersion})只走本方法。</p>
*/ */
public Map<String, Object> fetchSchema(Long schId, String formUuid, Integer version) { public Map<String, Object> fetchSchema(Long schId, String formUuid, Integer version) {
if (version == null) { if (version == null || version < 1 || formUuid == null || formUuid.trim().isEmpty()) {
throw new IllegalArgumentException("按确切版本取快照必须给 version(缺版本=取当前发布版,那是旧口径)"); throw new IllegalArgumentException("按确切版本取快照必须给 uuid 与正整数 version");
} }
RuntimeException last = null; RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
@@ -119,6 +102,20 @@ public class FormCenterSchemaClient {
return snapshot; return snapshot;
} catch (SchemaContractViolationException e) { } catch (SchemaContractViolationException e) {
throw e; throw e;
} catch (DException e) {
int code = e.getCode() == null ? -1 : e.getCode().getCode();
if (code == ErrorCode.PARAM_ERROR.getCode()) {
throw new SchemaResourceUnavailableException(e);
}
if (code == ErrorCode.FORBIDDEN.getCode() || code == ErrorCode.UNAUTHORIZED.getCode()) {
throw new SchemaAccessDeniedException(e);
}
last = e;
} catch (OpenApiException e) {
if (e.getHttpStatus() == 404) {
throw new SchemaResourceUnavailableException(e);
}
last = e;
} catch (RuntimeException e) { } catch (RuntimeException e) {
last = e; last = e;
log.warn("[flow-center] form-center schema 精确版本拉取失败(第 {}/{} 次):uuid={} v={} {}", log.warn("[flow-center] form-center schema 精确版本拉取失败(第 {}/{} 次):uuid={} v={} {}",
@@ -137,9 +134,15 @@ public class FormCenterSchemaClient {
private static void requireCoordinate(Map<String, Object> snapshot, String formUuid, Integer version) { private static void requireCoordinate(Map<String, Object> snapshot, String formUuid, Integer version) {
Object gotUuid = snapshot.get("formUuid"); Object gotUuid = snapshot.get("formUuid");
Object gotVersion = snapshot.get("formVersion"); Object gotVersion = snapshot.get("formVersion");
if (gotUuid == null || gotVersion == null Integer parsedVersion;
try {
parsedVersion = gotVersion == null ? null : Integer.valueOf(String.valueOf(gotVersion));
} catch (NumberFormatException e) {
throw new SchemaContractViolationException("表单中心返回的版本不是合法整数");
}
if (gotUuid == null || parsedVersion == null || parsedVersion < 1
|| !String.valueOf(gotUuid).equals(formUuid) || !String.valueOf(gotUuid).equals(formUuid)
|| Integer.parseInt(String.valueOf(gotVersion)) != version) { || !parsedVersion.equals(version)) {
throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 " throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 "
+ formUuid + " v" + version + ",返回 " + gotUuid + " v" + gotVersion + ")"); + formUuid + " v" + version + ",返回 " + gotUuid + " v" + gotVersion + ")");
} }
@@ -0,0 +1,177 @@
package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.*;
import com.yunhai.flowcenter.model.FormalApplyModels;
import com.yunhai.flowcenter.repository.*;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Objects;
/** Authoritative settled command facts; each accepted round gets a distinct engine instance. */
@Service
@RequiredArgsConstructor
public class FormalApplyCommandService {
private final FlowSourcePrincipalResolver principals;
private final FormalPayloadCanonicalizer canonicalizer;
private final FlwCommandSourceRepository sources;
private final FlwBizCoordinateRepository coordinates;
private final FlwApplyCommandRepository commands;
private final FlwInstanceRepository instances;
private final FlwUsageGrantRepository grants;
private final UserBaseActivationClient activation;
private final OapiFlowService validation;
private final FlowInstanceService lifecycle;
private final TransactionTemplate transactions;
@Value("${flow-center.formal-command.enabled:false}")
private boolean enabled;
public FormalApplyModels.Receipt submit(Long school, String appKey, FormalApplyModels.Request request) {
requireEnabled();
validateShape(school, request);
FlowSourcePrincipalResolver.Principal principal = principals.resolve(appKey);
String hash = canonicalizer.hash(request);
// A source mutex INSERT can race on the very first command. Retry only outside rollback.
for (int attempt = 0; ; attempt++) {
try {
return transactions.execute(tx -> accept(school, principal, request, hash));
} catch (org.springframework.dao.TransientDataAccessException | DataIntegrityViolationException conflict) {
if (attempt >= 2) throw conflict;
}
}
}
public FormalApplyModels.Receipt query(Long school, String appKey, String requestId, String expectedHash) {
requireEnabled();
if (school == null || school <= 0 || !key(requestId, 64)) {
throw new BizException(ErrorCode.PARAM_ERROR, "学校/requestId 非法");
}
FlowSourcePrincipalResolver.Principal principal = principals.resolve(appKey);
return transactions.execute(tx -> {
FlwApplyCommand command = commands.findBySchIdAndSourcePrincipalIdAndRequestId(school, principal.id, requestId)
.orElse(null);
if (command == null) {
FormalApplyModels.Receipt absent = new FormalApplyModels.Receipt();
absent.setState("NOT_FOUND"); absent.setSchoolId(school);
absent.setSourcePrincipalId(principal.id); absent.setRequestId(requestId);
return absent;
}
if (expectedHash != null && !expectedHash.equals(command.getCanonicalHash())) throw conflict();
return receipt(command);
});
}
private FormalApplyModels.Receipt accept(Long school, FlowSourcePrincipalResolver.Principal source,
FormalApplyModels.Request request, String hash) {
FlwCommandSource mutex = sources.lock(source.id).orElse(null);
if (mutex == null) {
mutex = new FlwCommandSource(); mutex.setId(source.id); mutex.setAppId(source.appId);
sources.saveAndFlush(mutex);
} else if (!source.appId.equals(mutex.getAppId())) {
throw new BizException(ErrorCode.FORBIDDEN, "来源 UUID 的 UserBase 主体绑定发生变化");
}
FlwApplyCommand existing = commands.findBySchIdAndSourcePrincipalIdAndRequestId(school, source.id, request.getRequestId())
.orElse(null);
if (existing != null) {
if (!hash.equals(existing.getCanonicalHash())) throw conflict();
return receipt(existing);
}
FlwApplyCommand command = new FlwApplyCommand();
command.setSchId(school); command.setSourcePrincipalId(source.id); command.setRequestId(request.getRequestId());
command.setCanonicalHash(hash); command.setCanonicalVersion(1); command.setBizType(request.getBizType());
command.setBizId(request.getBizId()); command.setFlowGuid(request.getFlowGuid());
FlwBizCoordinate coordinate = coordinates.lock(school, source.id, request.getBizType(), request.getBizId()).orElse(null);
if (coordinate == null) {
coordinate = new FlwBizCoordinate(); coordinate.setSchId(school); coordinate.setSourcePrincipalId(source.id);
coordinate.setBizType(request.getBizType()); coordinate.setBizId(request.getBizId());
coordinates.saveAndFlush(coordinate);
}
if (instances.findBySchIdAndBizTypeAndBizId(school, request.getBizType(), request.getBizId()).isPresent()
|| instances.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
school, source.id, request.getBizType(), request.getBizId(), 1).isPresent()) {
return reject(command, "LEGACY_COORDINATE_NOT_MIGRATED", "旧业务坐标尚未审核迁移");
}
if (coordinate.getActiveInstanceId() != null) return reject(command, "IN_FLIGHT", "同一业务坐标已有在途实例");
if (!Objects.equals(coordinate.getLastRound(), request.getExpectedPreviousRound())
|| !Objects.equals(coordinate.getLastInstanceId(), request.getExpectedPreviousInstanceId())) {
return reject(command, "PRECONDITION_FAILED", "首次申报须前轮为 0;重报须匹配前实例及轮次");
}
if (coordinate.getLastInstanceId() != null) {
FlwInstance previous = instances.findFormalPreviousForUpdate(coordinate.getLastInstanceId()).orElseThrow(IllegalStateException::new);
if (!Integer.valueOf(30).equals(previous.getStatus()) && !Integer.valueOf(40).equals(previous.getStatus())) {
return reject(command, "RESUBMIT_NOT_ALLOWED", "仅符合既有业务资格的拒绝/撤回件可显式新申报");
}
}
FlwUsageGrant grant = grants.findBySchIdAndFlowGuidAndAppId(school, request.getFlowGuid(), source.appId).orElse(null);
if (grant == null || !Integer.valueOf(1).equals(grant.getEnabled()) || !source.id.equals(grant.getSourcePrincipalId())) {
return reject(command, "USAGE_DENIED", "缺少与稳定来源绑定的流程使用许可");
}
// Unavailability throws and rolls back. A definitive false is a settled rejection.
if (!activation.isAppActivatedStrict(source.appId, school)) return reject(command, "SCHOOL_NOT_ACTIVATED", "调用应用未在该校开通");
FlowInstanceService.ApplyCommand prepared;
try {
prepared = validation.prepareFormal(school, source, request);
} catch (BizException denied) {
if (denied.getErrorCode() != ErrorCode.PARAM_ERROR && denied.getErrorCode() != ErrorCode.FORBIDDEN) throw denied;
return reject(command, "VALIDATION_REJECTED", denied.getMessage());
}
// Never catch persistence/engine/event failures below: the entire admission must roll back.
prepared.payloadHash = hash;
int round = Math.addExact(coordinate.getLastRound(), 1);
FlwInstance instance = lifecycle.createFormalInstance(prepared, request.getRequestId(), round, source.id);
coordinate.setLastRound(round); coordinate.setLastInstanceId(instance.getId()); coordinate.setActiveInstanceId(instance.getId());
coordinates.saveAndFlush(coordinate);
command.setState("ACCEPTED"); command.setRound(round); command.setInstanceId(instance.getId());
command.setDefId(instance.getDefId()); command.setDefVersion(instance.getDefVersion()); commands.saveAndFlush(command);
lifecycle.startFormalInstance(instance, prepared);
return receipt(command);
}
private FormalApplyModels.Receipt reject(FlwApplyCommand command, String code, String detail) {
command.setState("REJECTED"); command.setRejectCode(code);
command.setRejectDetail(detail == null ? null : detail.substring(0, Math.min(detail.length(), 500)));
commands.saveAndFlush(command);
return receipt(command);
}
private FormalApplyModels.Receipt receipt(FlwApplyCommand command) {
FormalApplyModels.Receipt result = new FormalApplyModels.Receipt();
BeanUtils.copyProperties(command, result);
result.setSchoolId(command.getSchId());
if ("ACCEPTED".equals(command.getState())) {
FlwInstance instance = instances.findById(command.getInstanceId()).orElseThrow(IllegalStateException::new);
if (!command.getSchId().equals(instance.getSchId()) || !command.getSourcePrincipalId().equals(instance.getSourcePrincipalId())
|| !command.getBizType().equals(instance.getBizType()) || !command.getBizId().equals(instance.getBizId())
|| !command.getRequestId().equals(instance.getApplyRequestToken()) || !command.getRound().equals(instance.getApplyRound())
|| !command.getCanonicalHash().equals(instance.getApplyPayloadHash()) || !command.getDefVersion().equals(instance.getDefVersion())
|| !Objects.equals(command.getDefId(), instance.getDefId())) {
throw new IllegalStateException("命令与实例事实不一致,禁止返回成功回执");
}
result.setStatus(instance.getStatus()); result.setVersion(instance.getVersion());
}
return result;
}
private void validateShape(Long school, FormalApplyModels.Request request) {
if (school == null || school <= 0 || request == null || request.getSubmitterId() == null
|| !key(request.getRequestId(), 64) || !key(request.getBizType(), 32)
|| !key(request.getBizId(), 64) || !key(request.getFlowGuid(), 32)) {
throw new BizException(ErrorCode.PARAM_ERROR, "正式命令缺少有效学校、requestId、业务坐标、流程线或提交人");
}
}
// MySQL 5.6 VARCHAR collations ignore trailing spaces, even *_bin. Reject, never trim/alias raw keys.
private boolean key(String value, int length) {
return value != null && !value.isEmpty() && value.equals(value.trim()) && value.length() <= length;
}
private void requireEnabled() {
if (!enabled) throw new BizException(ErrorCode.FORBIDDEN, "正式命令模式尚未完成迁移启用");
}
private BizException conflict() { return new BizException(ErrorCode.PARAM_ERROR, "REQUEST_ID_PAYLOAD_CONFLICT:原命令保持不变"); }
}
@@ -0,0 +1,101 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.*;
import com.yunhai.flowcenter.model.FormalApplyModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
/** v1 structural canonicalization, before definition lookup or server-side name enrichment. */
@Component
@RequiredArgsConstructor
public class FormalPayloadCanonicalizer {
private final ObjectMapper mapper;
public String hash(FormalApplyModels.Request request) {
try {
ObjectNode node = mapper.createObjectNode();
node.set("flowGuid", mapper.valueToTree(request.getFlowGuid()));
node.set("bizType", mapper.valueToTree(request.getBizType()));
node.set("bizId", mapper.valueToTree(request.getBizId()));
node.set("bizTitle", mapper.valueToTree(request.getBizTitle()));
node.set("bizUrl", mapper.valueToTree(request.getBizUrl()));
node.set("submitterId", mapper.valueToTree(request.getSubmitterId()));
node.set("submitterName", mapper.valueToTree(request.getSubmitterName()));
node.set("formData", mapper.valueToTree(request.getFormData()));
node.set("expectedDefVersion", mapper.valueToTree(request.getExpectedDefVersion()));
node.set("expectedPreviousInstanceId", mapper.valueToTree(request.getExpectedPreviousInstanceId()));
node.set("expectedPreviousRound", mapper.valueToTree(request.getExpectedPreviousRound()));
node.set("bizSnapshot", jsonValue(request.getBizSnapshot()));
ObjectNode assigns = mapper.createObjectNode();
if (request.getAssigns() != null) {
for (OapiModels.AssignItem item : request.getAssigns()) {
if (item == null || item.getNodeId() == null || assigns.has(item.getNodeId())
|| item.getUserIds() == null) {
throw invalid("指派节点缺失、重复或缺少人员集合");
}
TreeSet<Integer> users = new TreeSet<>();
for (Integer uid : item.getUserIds()) {
if (uid == null || !users.add(uid)) {
throw invalid("指派人员为空或重复");
}
}
assigns.set(item.getNodeId(), mapper.valueToTree(users));
}
}
node.set("assigns", request.getAssigns() == null ? NullNode.instance : assigns);
String normalized = canonical(node);
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(normalized.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(64);
for (byte value : bytes) {
hex.append(Character.forDigit((value >>> 4) & 15, 16));
hex.append(Character.forDigit(value & 15, 16));
}
return hex.toString();
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw invalid("无法规范化命令载荷");
}
}
private JsonNode jsonValue(Object value) throws Exception {
if (value == null) return NullNode.instance;
if (value instanceof String) {
JsonNode decoded = mapper.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree((String) value);
if (decoded == null) throw invalid("摘要 JSON 为空");
return decoded;
}
return mapper.valueToTree(value);
}
private String canonical(JsonNode node) throws Exception {
if (node.isObject()) {
SortedMap<String, JsonNode> sorted = new TreeMap<>();
node.fields().forEachRemaining(e -> sorted.put(e.getKey(), e.getValue()));
StringJoiner out = new StringJoiner(",", "{", "}");
for (Map.Entry<String, JsonNode> field : sorted.entrySet()) {
out.add(mapper.writeValueAsString(field.getKey()) + ":" + canonical(field.getValue()));
}
return out.toString();
}
if (node.isArray()) {
StringJoiner out = new StringJoiner(",", "[", "]");
for (JsonNode value : node) out.add(canonical(value));
return out.toString();
}
if (node.isNumber()) return node.decimalValue().stripTrailingZeros().toPlainString();
return mapper.writeValueAsString(node);
}
private BizException invalid(String message) { return new BizException(ErrorCode.PARAM_ERROR, message); }
}
@@ -106,15 +106,114 @@ public class OapiFlowService {
this.transactionTemplate = template; this.transactionTemplate = template;
} }
private FlowInstanceService.GuardedOutcome inTransaction( /**
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) { * T01 应用使用许可闸(2026-10-25):新发起必须持有 (school, flowGuid, appId) 生效许可。
* setter 注入(构造被单测逐位钉住,同 transactionTemplate 先例);未装配(老单测直接 new)
* 视为关闭,生产装配后由 {@code yh.flow.school-activation-enforce} 控制生效。
* enforce 开启时 UserBase 不可达 fail-closed(见 {@link FlowUsageGrantGuard})。
*/
private FlowUsageGrantGuard usageGrantGuard;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setUsageGrantGuard(FlowUsageGrantGuard guard) {
this.usageGrantGuard = guard;
}
/**
* 发起期变量终审(字段注入:保持既有构造签名)。手工构造服务时也使用同一校验器,
* 所有拒绝均发生在实例创建之前。
*/
private ApplyVariableValidator applyVariableValidator;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setApplyVariableValidator(ApplyVariableValidator validator) {
this.applyVariableValidator = validator;
}
/** R1 SDK 快捷审批门禁(字段注入同上):门禁无条件 fail-closed,装配与否均不放行 */
private FlowActorContextGate actorContextGate;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setActorContextGate(FlowActorContextGate gate) {
this.actorContextGate = gate;
}
private void requireExpressionVariablesPresent(FlwDef def, String formDataJson) {
ApplyVariableValidator validator = applyVariableValidator == null
? new ApplyVariableValidator(objectMapper) : applyVariableValidator;
validator.requireVariablesPresent(
FlowDefModel.parse(objectMapper, def.getContentJson()), formDataJson);
}
private <T> T inTransaction(
org.springframework.transaction.support.TransactionCallback<T> action) {
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action); return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
} }
@Value("${flow-center.formal-command.enabled:false}")
private boolean formalCommandEnabled;
@org.springframework.beans.factory.annotation.Autowired
private FlowSourcePrincipalResolver sourcePrincipalResolver;
@org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwApplyCommandRepository formalCommands;
@org.springframework.beans.factory.annotation.Autowired private com.yunhai.flowcenter.repository.FlwUsageGrantRepository formalGrants;
private void requireLegacyWriter() {
if (formalCommandEnabled) throw new BizException(ErrorCode.FORBIDDEN,
"正式命令模式已启用,请迁移到 /oapi/flow/commands;旧发起接口已关闭");
}
/** New-command validation only. Never call this while recovering a settled receipt. */
FlowInstanceService.ApplyCommand prepareFormal(Long schId, FlowSourcePrincipalResolver.Principal source,
com.yunhai.flowcenter.model.FormalApplyModels.Request body) {
BoaUser user;
try { user = userClient.getUserById(String.valueOf(body.getSubmitterId())); }
catch (RuntimeException unavailable) { throw new IllegalStateException("提交人权威查询不可用", unavailable); }
if (user == null || !Integer.valueOf(1).equals(user.getStatus()) || user.getSchool() == null || user.getSchool().getId() == null
|| !schId.equals(Long.valueOf(user.getSchool().getId()))) {
throw new BizException(ErrorCode.FORBIDDEN, "提交人不存在或不属于该学校");
}
if (body.getAssigns() != null && !body.getAssigns().isEmpty() && body.getExpectedDefVersion() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "正式指派须携带预览定义版本锚");
}
FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程版本已改变,请刷新后使用新的请求 ID");
}
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
// D11:固定 USERS 属审批责任人,正式命令在受理阶段整体终审——任一非法即 VALIDATION_REJECTED,
// 不落实例、不做“过滤后继续”。运行期建任务仍由 FlowEngine/AssigneeResolver 再兜底。
assigneeResolver.requireEligibleFixedAssignees(schId, FlowDefModel.parse(objectMapper, def.getContentJson()));
String assigns = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
return new FlowInstanceService.ApplyCommand(schId, def, body.getBizType(), body.getBizId(),
body.getBizTitle(), body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()),
source.appKey, source.appCode, assigns);
}
private boolean canReadFormalDefinition(Long school, String flow) {
String source = FlowOapiSourceScope.current();
return formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, source, 1)
|| formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(school, source, flow, "ACCEPTED");
}
private void requireFormalPreviewGrant(Long school, String flow) {
if (!formalCommandEnabled) return;
FlowSourcePrincipalResolver.Principal principal = sourcePrincipalResolver.resolve(
(String)((org.springframework.web.context.request.ServletRequestAttributes)org.springframework.web.context.request.RequestContextHolder.currentRequestAttributes()).getRequest().getAttribute("yh.app.auth"));
if (!formalGrants.existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(school, flow, principal.id, 1)
|| !formalActivation.isAppActivatedStrict(principal.appId, school)) {
throw new BizException(ErrorCode.FORBIDDEN, "没有此流程线的发起预览权限");
}
}
@org.springframework.beans.factory.annotation.Autowired private UserBaseActivationClient formalActivation;
/** 已发布流程列表(每条流程线取最高已发布版本) */ /** 已发布流程列表(每条流程线取最高已发布版本) */
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) { public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
List<OapiModels.FlowSummaryVo> out = new ArrayList<>(); List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
for (FlwDef def : defRepository.findPublishedLatestPerGuid(schId)) { for (FlwDef def : defRepository.findPublishedLatestPerGuid(schId)) {
if (formalCommandEnabled && !canReadFormalDefinition(schId, def.getFlowGuid())) continue;
out.add(toSummary(def)); out.add(toSummary(def));
} }
return out; return out;
@@ -122,6 +221,7 @@ public class OapiFlowService {
/** 流程详情回显(未发布/不存在返回 null,消费方按"未绑定流程"降级) */ /** 流程详情回显(未发布/不存在返回 null,消费方按"未绑定流程"降级) */
public OapiModels.FlowSummaryVo getFlow(Long schId, String flowGuid) { public OapiModels.FlowSummaryVo getFlow(Long schId, String flowGuid) {
if (formalCommandEnabled && !canReadFormalDefinition(schId, flowGuid)) return null;
return defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( return defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode()) schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
.map(this::toSummary).orElse(null); .map(this::toSummary).orElse(null);
@@ -129,18 +229,22 @@ public class OapiFlowService {
/** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份, /** 代发起(biz uk 幂等 + R4 重启重提,见 FlowInstanceService):appKey=验签上下文调用方身份,
* 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */ * 与 app_code 一起快照进办件(消息 jump_app 的权威来源,2026-10-12 起替代 bizType 冒充) */
@Transactional
public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) { public OapiModels.ApplyResultVo apply(Long schId, String appKey, OapiModels.ApplyBody body) {
requireLegacyWriter();
if (schId == null || body.getFlowGuid() == null || body.getBizType() == null if (schId == null || body.getFlowGuid() == null || body.getBizType() == null
|| body.getBizId() == null || body.getSubmitterId() == null) { || body.getBizId() == null || body.getSubmitterId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, throw new BizException(ErrorCode.PARAM_ERROR,
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId"); "缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
} }
requireSchoolMember(schId, body.getSubmitterId()); requireSchoolMember(schId, body.getSubmitterId());
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) // T01 使用许可(v1 路径):回执幂等(既有在途/通过件)不拦;无实例或终态件 R4 重启=新申报,
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, // 必须持有效使用许可。恢复感知的分支判定与 FlowInstanceService.apply 保持同键同序。
"流程不存在或未发布:" + body.getFlowGuid())); FlowSourcePrincipalResolver.Principal source = requireApplySource(schId, cleanAppKey);
requireUsageGrant(schId, body.getFlowGuid(), source);
FlwDef def = requirePublishedDefForApply(schId, body.getFlowGuid());
// T06 变量终审:EXPRESSION 引用的业务变量缺失/空=null 即拒(零实例),不等运行期 nil 放行
requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
// S2.7 版本竞态(可选、opt-in):调用方回带预览时的定义版本,改版即人类可读拒绝, // S2.7 版本竞态(可选、opt-in):调用方回带预览时的定义版本,改版即人类可读拒绝,
// 免得选好的 nodeId/候选人在新版本里对不上而报「指派节点不存在」。不传=现状逐字不变。 // 免得选好的 nodeId/候选人在新版本里对不上而报「指派节点不存在」。不传=现状逐字不变。
if (body.getExpectedDefVersion() != null if (body.getExpectedDefVersion() != null
@@ -150,15 +254,21 @@ public class OapiFlowService {
+ ",当前已发布版本 " + def.getVersion() + ")"); + ",当前已发布版本 " + def.getVersion() + ")");
} }
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId()); String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def, schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(), body.getBizType(), body.getBizId(), body.getBizTitle(),
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()), body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(), body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()), asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson); cleanAppKey, source.appCode, assignJson);
FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd); cmd.sourcePrincipalId = source.id;
cmd.payloadHash = payloadHash(cmd);
FlowInstanceService.ApplyOutcome outcome;
try { outcome = inTransaction(tx -> instanceService.apply(cmd)); }
catch (DataIntegrityViolationException concurrent) {
// Outside the failed transaction: retry the SAME source-scoped lookup, never a broad business-key read.
outcome = inTransaction(tx -> instanceService.apply(cmd));
}
OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo(); OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo();
vo.setInstanceId(outcome.getInstanceId()); vo.setInstanceId(outcome.getInstanceId());
vo.setDuplicated(outcome.isDuplicated()); vo.setDuplicated(outcome.isDuplicated());
@@ -184,32 +294,52 @@ public class OapiFlowService {
*/ */
public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey, public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey,
FlowApplyRetryModels.ApplyRequestV2 body) { FlowApplyRetryModels.ApplyRequestV2 body) {
requireLegacyWriter();
if (schId == null || body == null || body.getFlowGuid() == null || body.getBizType() == null if (schId == null || body == null || body.getFlowGuid() == null || body.getBizType() == null
|| body.getBizId() == null || body.getSubmitterId() == null) { || body.getBizId() == null || body.getSubmitterId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, throw new BizException(ErrorCode.PARAM_ERROR,
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId"); "缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
} }
requireSchoolMember(schId, body.getSubmitterId()); requireSchoolMember(schId, body.getSubmitterId());
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) FlowSourcePrincipalResolver.Principal source = requireApplySource(schId, cleanAppKey);
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
"流程不存在或未发布:" + body.getFlowGuid())); schId, source.id, body.getBizType(), body.getBizId(), 1).orElse(null);
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) { boolean replay = existing != null && body.getRequestToken() != null
&& !body.getRequestToken().trim().isEmpty()
&& body.getRequestToken().trim().equals(existing.getApplyRequestToken());
// 同 token 用已受理版本构造原指纹;归属和异载荷冲突仍由 applyGuarded 原守卫裁决。
FlwDef def = replay ? defRepository.findById(existing.getDefId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "原请求的定义版本不存在"))
: requirePublishedDefForApply(schId, body.getFlowGuid());
if (replay && !body.getFlowGuid().equals(def.getFlowGuid())) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件绑定的流程线不同");
}
if (!replay) requireExpressionVariablesPresent(def, asFormDataString(body.getFormData()));
if (!replay && body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
throw new BizException(ErrorCode.PARAM_ERROR, throw new BizException(ErrorCode.PARAM_ERROR,
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion() "流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
+ ",当前已发布版本 " + def.getVersion() + ")"); + ",当前已发布版本 " + def.getVersion() + ")");
} }
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId()); String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def, schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(), body.getBizType(), body.getBizId(), body.getBizTitle(),
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()), body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(), body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()), asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson); cleanAppKey, source.appCode, assignJson);
cmd.sourcePrincipalId = source.id;
// T04:受理载荷指纹(服务端计算,不信任客户端自报)——同 token 异载荷按冲突拒绝
cmd.payloadHash = payloadHash(cmd);
// Only an exact, source-owned, already accepted command can recover after revocation.
boolean provenReceipt = replay && source.id.equals(existing.getSourcePrincipalId())
&& existing.getApplyPayloadHash() != null && existing.getApplyPayloadHash().equals(cmd.payloadHash)
&& java.util.Objects.equals(existing.getSubmitterId(), cmd.submitterId);
if (!provenReceipt) requireUsageGrant(schId, body.getFlowGuid(), source);
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy( FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly())); body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()),
body.getExpectedPrevRound());
FlowInstanceService.GuardedOutcome outcome; FlowInstanceService.GuardedOutcome outcome;
try { try {
@@ -218,14 +348,136 @@ public class OapiFlowService {
// 事务边界外重读:另一个并发请求已用同一业务键建好办件 // 事务边界外重读:另一个并发请求已用同一业务键建好办件
return fromConcurrent(schId, cmd, policy); return fromConcurrent(schId, cmd, policy);
} }
requireNotConflict(outcome);
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2(); FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(outcome.instanceId); vo.setInstanceId(outcome.instanceId);
vo.setDuplicated(outcome.duplicated); vo.setDuplicated(outcome.duplicated);
vo.setBranch(outcome.branch); vo.setBranch(outcome.branch);
vo.setApplyRound(outcome.applyRound); vo.setApplyRound(outcome.applyRound);
instanceRepository.findById(outcome.instanceId).ifPresent(current -> {
int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound();
int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound;
if (currentRound == outcomeRound) {
vo.setStatus(current.getStatus());
vo.setVersion(current.getVersion());
} // 已被另一请求推进到新轮时,让消费方按实例查询恢复,不混装两轮证据。
});
return vo; return vo;
} }
/**
* 新发起的当前有效发布版解析(2026-10-25 T03 收口):最高已发布版 + 线级停发守卫——
* 整线停发(line_offline=1)即拒绝新发起,且不回退到更旧的已发布版本(停发不是降级)。
* 在途实例不受影响(推进按实例固定 defId,不经本方法)。
*/
private FlwDef requirePublishedDefForApply(Long schId, String flowGuid) {
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + flowGuid));
if (def.getLineOffline() != null && def.getLineOffline() == 1) {
throw new BizException(ErrorCode.PARAM_ERROR,
"流程线已停发,暂停新发起(在途办件不受影响):" + flowGuid);
}
return def;
}
/**
* T01 使用许可闸·v1 路径(恢复感知最小形态):v1 无 token/轮次,回执与新申报的区分只能靠
* 既有实例状态——在途(10)/通过(20)命中=幂等回读放行;无实例=CREATED;终态(30/40)=R4
* 重启即新一轮申报。每次均强制通过独立的学校开通与流程使用授权校验。
*/
private FlowSourcePrincipalResolver.Principal requireApplySource(Long school, String appKey) {
if (school == null || school <= 0 || sourcePrincipalResolver == null) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少有效申报学校或来源解析服务");
}
return sourcePrincipalResolver.resolve(appKey);
}
private void requireUsageGrant(Long school, String flow, FlowSourcePrincipalResolver.Principal source) {
if (usageGrantGuard == null) throw new BizException(ErrorCode.FORBIDDEN, "流程使用授权服务未装配");
usageGrantGuard.requireNewApplyGrant(school, flow, source);
}
/**
* v1 回执判定:true=该请求只会命中幂等回读(不建件不重启),false=会触发新申报或 R4 重启。
* 与 {@code FlowInstanceService.apply(ApplyCommand)} 的分支一一对应(包私有便于规则单测钉住)。
*/
static boolean legacyApplyIsReceiptOnly(FlwInstance existing) {
return existing != null
&& (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode());
}
/**
* v2 回执判定:与 {@code applyGuarded/settleExisting} 同键同序——
* SAME_REQUEST(同 token)→ REPLAY_ONLY(retryOnly)→ IDEMPOTENT(在途/通过)→
* STALE_ROUND(请求轮次≤行轮次,缺省按 1)四类回执返回 true;CREATED/RESTARTED 返回 false。
*/
static boolean guardedApplyIsReceiptOnly(FlwInstance existing, String requestToken,
Integer attempt, boolean retryOnly) {
if (existing == null) {
return false;
}
String token = requestToken == null ? null : requestToken.trim();
if (token != null && !token.isEmpty() && token.equals(existing.getApplyRequestToken())) {
return true;
}
if (retryOnly) {
return true;
}
if (existing.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
|| existing.getStatus() == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
return true;
}
int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
int incomingRound = attempt == null ? 1 : attempt;
return incomingRound <= currentRound;
}
/**
* T04:CONFLICT 分支统一翻译成业务异常——冲突不是成功,调用方(SH 恢复链)收到 4xx
* 走 UNKNOWN → 按来源+token 复核,不得把他人/其他轮次的在途件当自己的受理结果。
*/
private static void requireNotConflict(FlowInstanceService.GuardedOutcome outcome) {
if (outcome != null && FlowApplyRetryModels.Branch.CONFLICT.equals(outcome.branch)) {
throw new BizException(ErrorCode.CUSTOM,
"该业务键已有在途/终态申报且请求标识不符(另一命令或另一轮次),"
+ "请按实例查询恢复,禁止跨命令认领;终态后重新申报请走显式重提");
}
}
/**
* 受理载荷指纹(T04):SHA-256(流序规范化) over flowGuid|bizType|bizId|submitterId|bizTitle|
* bizUrl|bizSnapshot|formData|assignJson。重发链载荷逐字节同源 → 同指纹;
* 任何业务字段改动 → 不同指纹 → 冲突。expectedDefVersion 刻意不参与(重试期间发布新版本
* 不应把同一次命令误判成两份)。
*/
private String payloadHash(FlowInstanceService.ApplyCommand cmd) {
String canon = String.join("|",
nullSafe(cmd.def == null ? null : cmd.def.getFlowGuid()),
nullSafe(cmd.bizType), nullSafe(cmd.bizId),
cmd.submitterId == null ? "" : String.valueOf(cmd.submitterId),
nullSafe(cmd.bizTitle), nullSafe(cmd.bizUrl),
nullSafe(cmd.bizSnapshot), nullSafe(cmd.formDataJson), nullSafe(cmd.assignJson));
try {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(canon.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(64);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (java.security.NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
private static String nullSafe(String v) {
return v == null ? "" : v;
}
/** /**
* 并发撞键后的收口:重读既有实例 → <b>先核归属</b> → 同 token 记 {@code SAME_REQUEST}, * 并发撞键后的收口:重读既有实例 → <b>先核归属</b> → 同 token 记 {@code SAME_REQUEST},
* 否则按幂等命中回读。归属不符就拒绝,绝不返回别人的办件 id。 * 否则按幂等命中回读。归属不符就拒绝,绝不返回别人的办件 id。
@@ -233,29 +485,18 @@ public class OapiFlowService {
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId, private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
FlowInstanceService.ApplyCommand cmd, FlowInstanceService.ApplyCommand cmd,
FlowInstanceService.ApplyRetryPolicy policy) { FlowInstanceService.ApplyRetryPolicy policy) {
FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId( FlowInstanceService.GuardedOutcome outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy));
schId, cmd.bizType, cmd.bizId).orElse(null); requireNotConflict(outcome);
if (existing == null) {
throw new BizException(ErrorCode.CUSTOM, "办件创建冲突且读不到既有实例,请稍后重试");
}
if (existing.getApplyAppKey() != null && cmd.applyAppKey != null
&& !existing.getApplyAppKey().equals(cmd.applyAppKey)) {
throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
}
if (existing.getSubmitterId() != null && cmd.submitterId != null
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符");
}
String token = policy.requestToken == null ? null : policy.requestToken.trim();
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2(); FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(existing.getId()); vo.setInstanceId(outcome.instanceId); vo.setDuplicated(outcome.duplicated);
vo.setDuplicated(true); vo.setBranch(outcome.branch); vo.setApplyRound(outcome.applyRound);
vo.setApplyRound(existing.getApplyRound()); instanceRepository.findById(outcome.instanceId).ifPresent(current -> {
vo.setBranch(token != null && token.equals(existing.getApplyRequestToken()) int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound();
? FlowApplyRetryModels.Branch.SAME_REQUEST : FlowApplyRetryModels.Branch.CONCURRENT_CREATED); int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound;
log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}", if (currentRound == outcomeRound && cmd.sourcePrincipalId.equals(current.getSourcePrincipalId())) {
existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch()); vo.setStatus(current.getStatus()); vo.setVersion(current.getVersion());
}
});
return vo; return vo;
} }
@@ -287,12 +528,45 @@ public class OapiFlowService {
* apply 时指派严格校验(方案 §5.2 五条,不变量 I7)+ 快照渲染。 * apply 时指派严格校验(方案 §5.2 五条,不变量 I7)+ 快照渲染。
* assigns 空 → null(宽松回退 D3)。任何不过 → BizException(PARAM_ERROR) 拒绝发起。 * assigns 空 → null(宽松回退 D3)。任何不过 → BizException(PARAM_ERROR) 拒绝发起。
*/ */
/**
* T06 required 覆盖判定(包私有纯函数,规则单测钉住):定义里全部开启且必选的指派节点中,
* 提交项未覆盖的 nodeId 列表。空 assigns + 存在必选节点 ⇒ 全部缺失(空 assigns 不绕过)。
*/
static List<String> missingRequiredAssignNodes(FlowDefModel model, List<OapiModels.AssignItem> assigns) {
java.util.Set<String> submitted = new java.util.HashSet<>();
if (assigns != null) {
for (OapiModels.AssignItem item : assigns) {
if (item.getNodeId() != null) {
submitted.add(item.getNodeId());
}
}
}
List<String> missing = new ArrayList<>();
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !FlowDefModel.TYPE_APPROVAL.equals(n.type) || n.assignPolicy == null
|| !Boolean.TRUE.equals(n.assignPolicy.required)) {
continue;
}
if (!submitted.contains(n.id)) {
missing.add(n.id);
}
}
return missing;
}
private String renderAssignJson(Long schId, FlwDef def, List<OapiModels.AssignItem> assigns, private String renderAssignJson(Long schId, FlwDef def, List<OapiModels.AssignItem> assigns,
Integer submitterId) { Integer submitterId) {
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
// T06(2026-10-25)required 覆盖终审:遍历<b>定义里全部</b>开启且必选的指派节点——
// 不能只遍历请求里出现的项,更不能让空 assigns 绕过 required 检查(审计 §7.4 点名)。
List<String> missing = missingRequiredAssignNodes(model, assigns);
if (!missing.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"必选指派节点未提交人选(node=" + String.join(", ", missing) + "),请重新选择后提交");
}
if (assigns == null || assigns.isEmpty()) { if (assigns == null || assigns.isEmpty()) {
return null; return null;
} }
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>(); Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>();
Set<String> seenNodeIds = new LinkedHashSet<>(); Set<String> seenNodeIds = new LinkedHashSet<>();
for (OapiModels.AssignItem item : assigns) { for (OapiModels.AssignItem item : assigns) {
@@ -318,6 +592,16 @@ public class OapiFlowService {
throw new BizException(ErrorCode.PARAM_ERROR, throw new BizException(ErrorCode.PARAM_ERROR,
"不允许指派发起人本人:" + item.getNodeId()); "不允许指派发起人本人:" + item.getNodeId());
} }
// D11:手选人先逐人资格终审(存在/启用/校域),再核候选集——任一人不合格整体拒绝,
// 快照与实例均不落;不能只信前端候选列表,也不能以“在候选 ID 中”替代资格。
List<Map<String, Object>> rows = new ArrayList<>();
for (Integer uid : uids) {
String userName = assigneeResolver.requireEligibleAssigneeName(schId, uid);
Map<String, Object> row = new HashMap<>();
row.put("userId", uid);
row.put("userName", userName);
rows.add(row);
}
// 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝 // 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝
List<AssigneeRef> candidates = assigneeResolver.resolve(schId, node); List<AssigneeRef> candidates = assigneeResolver.resolve(schId, node);
Set<Integer> candidateIds = new LinkedHashSet<>(); Set<Integer> candidateIds = new LinkedHashSet<>();
@@ -344,14 +628,6 @@ public class OapiFlowService {
"指派人数越界[" + min + "~" + max + "]:" + item.getNodeId()); "指派人数越界[" + min + "~" + max + "]:" + item.getNodeId());
} }
} }
List<Map<String, Object>> rows = new ArrayList<>();
for (Integer uid : uids) {
String userName = assigneeResolver.requireUserName(uid);
Map<String, Object> row = new HashMap<>();
row.put("userId", uid);
row.put("userName", userName);
rows.add(row);
}
snapshot.put(item.getNodeId(), rows); snapshot.put(item.getNodeId(), rows);
} }
try { try {
@@ -366,13 +642,11 @@ public class OapiFlowService {
* 节点级解析异常降级空列表(I6)。 * 节点级解析异常降级空列表(I6)。
*/ */
public OapiModels.ApplyPreviewVo applyPreview(Long schId, String flowGuid, Integer submitterId) { public OapiModels.ApplyPreviewVo applyPreview(Long schId, String flowGuid, Integer submitterId) {
requireFormalPreviewGrant(schId, flowGuid);
if (schId == null || flowGuid == null || submitterId == null) { if (schId == null || flowGuid == null || submitterId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId"); throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId");
} }
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( FlwDef def = requirePublishedDefForApply(schId, flowGuid);
schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + flowGuid));
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo(); OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo();
vo.setFlowGuid(def.getFlowGuid()); vo.setFlowGuid(def.getFlowGuid());
@@ -421,7 +695,7 @@ public class OapiFlowService {
/** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */ /** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */
public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) { public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) {
FlwInstance instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId) FlwInstance instance = findByBizForOapi(schId, bizType, bizId)
.orElse(null); .orElse(null);
return instance == null ? null : toInstanceVo(instance); return instance == null ? null : toInstanceVo(instance);
} }
@@ -437,8 +711,9 @@ public class OapiFlowService {
if (schId == null || assigneeId == null) { if (schId == null || assigneeId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId"); throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId");
} }
Page<FlwTask> p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), Page<FlwTask> p = formalCommandEnabled
PageRequest.of(normPage(page), normSize(size))); ? taskRepository.pageSelfTodo(schId, FlowOapiSourceScope.current(), assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)))
: taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), PageRequest.of(normPage(page), normSize(size)));
List<Long> instanceIds = p.getContent().stream() List<Long> instanceIds = p.getContent().stream()
.map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList()); .map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList());
Map<Long, FlwInstance> instances = instanceIds.isEmpty() Map<Long, FlwInstance> instances = instanceIds.isEmpty()
@@ -457,8 +732,9 @@ public class OapiFlowService {
if (schId == null) { if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId"); throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
} }
Page<FlwInstance> p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, Page<FlwInstance> p = formalCommandEnabled
PageRequest.of(normPage(page), normSize(size))); ? instanceRepository.pageSelf(schId, FlowOapiSourceScope.current(), emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)))
: instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, PageRequest.of(normPage(page), normSize(size)));
List<OapiModels.InstanceItemVo> items = p.getContent().stream() List<OapiModels.InstanceItemVo> items = p.getContent().stream()
.map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList()); .map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList());
return toPageResult(p.getTotalElements(), items, page, size); return toPageResult(p.getTotalElements(), items, page, size);
@@ -490,8 +766,7 @@ public class OapiFlowService {
OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo(); OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo();
vo.setBizType(cleanBizType); vo.setBizType(cleanBizType);
vo.setBizId(cleanBizId); vo.setBizId(cleanBizId);
FlwInstance instance = instanceRepository FlwInstance instance = findByBizForOapi(schId, cleanBizType, cleanBizId).orElse(null);
.findBySchIdAndBizTypeAndBizId(schId, cleanBizType, cleanBizId).orElse(null);
if (instance == null) { if (instance == null) {
return vo; return vo;
} }
@@ -546,6 +821,7 @@ public class OapiFlowService {
@Transactional @Transactional
public FlwTask approveTask(Long schId, Long taskId, Integer operatorId, String operatorName, public FlwTask approveTask(Long schId, Long taskId, Integer operatorId, String operatorName,
String opinion, String edgeName) { String opinion, String edgeName) {
requireActorProof(schId, taskId, operatorId);
requireOperator(schId, taskId, operatorId, operatorName); requireOperator(schId, taskId, operatorId, operatorName);
return taskService.handle(schId, taskId, true, opinion, operatorId, edgeName); return taskService.handle(schId, taskId, true, opinion, operatorId, edgeName);
} }
@@ -554,10 +830,27 @@ public class OapiFlowService {
@Transactional @Transactional
public FlwTask rejectTask(Long schId, Long taskId, Integer operatorId, String operatorName, public FlwTask rejectTask(Long schId, Long taskId, Integer operatorId, String operatorName,
String opinion) { String opinion) {
requireActorProof(schId, taskId, operatorId);
requireOperator(schId, taskId, operatorId, operatorName); requireOperator(schId, taskId, operatorId, operatorName);
return taskService.handle(schId, taskId, false, opinion, operatorId); return taskService.handle(schId, taskId, false, opinion, operatorId);
} }
/**
* R1(Codex D03):oapi 审批统一过 ActorContext 门——门禁本身无条件 fail-closed;
* 未装配(手工构造的老单测)时同样拒绝,不存在"未装配=直通"豁免。
*/
private void requireActorProof(Long schId, Long taskId, Integer operatorId) {
if (actorContextGate != null) {
actorContextGate.requireActorProof(schId, taskId, operatorId);
return;
}
log.warn("[actor-gate] oapi 快捷审批被拒(门禁未装配,fail-closed):schId={} taskId={} operatorId={}",
schId, taskId, operatorId);
throw new BizException(ErrorCode.FORBIDDEN,
"SDK 快捷审批未放行:仅凭 appKey/operatorId 不构成用户授权。"
+ "请通过流程中心人工办理页(CAS)办理");
}
private void requireOperator(Long schId, Long taskId, Integer operatorId, String operatorName) { private void requireOperator(Long schId, Long taskId, Integer operatorId, String operatorName) {
if (schId == null || taskId == null || operatorId == null) { if (schId == null || taskId == null || operatorId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/taskId/operatorId"); throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/taskId/operatorId");
@@ -662,7 +955,7 @@ public class OapiFlowService {
if (bizType == null || bizId == null) { if (bizType == null || bizId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId"); throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:instanceId 或 bizType/bizId");
} }
instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId) instance = findByBizForOapi(schId, bizType, bizId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见")); .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在或不可见"));
} }
return toProgressVo(instance); return toProgressVo(instance);
@@ -675,6 +968,10 @@ public class OapiFlowService {
* 业务边界保证),浏览器侧只认 token。 * 业务边界保证),浏览器侧只认 token。
*/ */
public OapiModels.DiagramUrlVo diagramUrl(Long schId, Long instanceId, String flowGuid, String mode) { public OapiModels.DiagramUrlVo diagramUrl(Long schId, Long instanceId, String flowGuid, String mode) {
if (formalCommandEnabled && instanceId == null && !canReadFormalDefinition(schId, flowGuid)) {
throw new BizException(ErrorCode.FORBIDDEN, "没有此流程定义的读取权限");
}
if (flowGuid != null && !flowGuid.trim().isEmpty()) { if (flowGuid != null && !flowGuid.trim().isEmpty()) {
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, flowGuid.trim(), FlwEnums.DefStatus.PUBLISHED.getCode()) schId, flowGuid.trim(), FlwEnums.DefStatus.PUBLISHED.getCode())
@@ -756,6 +1053,11 @@ public class OapiFlowService {
} }
/** 进度/换图共用的办件定位(schId 收敛在次) */ /** 进度/换图共用的办件定位(schId 收敛在次) */
private java.util.Optional<FlwInstance> findByBizForOapi(Long school, String type, String biz) {
return instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(
school, FlowOapiSourceScope.current(), type, biz);
}
private FlwInstance requireInstance(Long schId, Long instanceId) { private FlwInstance requireInstance(Long schId, Long instanceId) {
FlwInstance instance = instanceRepository.findById(instanceId) FlwInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId)); .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
@@ -1020,6 +1322,19 @@ public class OapiFlowService {
reg.setAppKey(appKey); reg.setAppKey(appKey);
reg.setFlowGuid(body.getFlowGuid()); reg.setFlowGuid(body.getFlowGuid());
} }
{
FlowSourcePrincipalResolver.Principal source = sourcePrincipalResolver.resolve(appKey);
if (formalCommandEnabled) {
com.yunhai.flowcenter.entity.FlwUsageGrant grant = formalGrants.findBySchIdAndFlowGuidAndAppId(schId, body.getFlowGuid(), source.appId).orElse(null);
boolean permitted = grant != null && Integer.valueOf(1).equals(grant.getEnabled()) && source.id.equals(grant.getSourcePrincipalId());
if (!permitted && !formalCommands.existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(schId, source.id, body.getFlowGuid(), "ACCEPTED")) {
throw new BizException(ErrorCode.FORBIDDEN, "没有 SELF 订阅权限");
}
} else {
requireUsageGrant(schId, body.getFlowGuid(), source);
}
reg.setSourcePrincipalId(source.id);
}
reg.setCallbackUrl(body.getCallbackUrl()); reg.setCallbackUrl(body.getCallbackUrl());
reg.setEventTypes(body.getEventTypes()); reg.setEventTypes(body.getEventTypes());
reg.setBasePath(body.getBasePath()); reg.setBasePath(body.getBasePath());
@@ -1112,6 +1427,9 @@ public class OapiFlowService {
public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) { public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) {
OapiModels.InstanceVo vo = new OapiModels.InstanceVo(); OapiModels.InstanceVo vo = new OapiModels.InstanceVo();
vo.setInstanceId(instance.getId()); vo.setInstanceId(instance.getId());
vo.setApplyRound(instance.getApplyRound());
vo.setRequestToken(instance.getApplyRequestToken());
vo.setApplyAppKey(instance.getApplyAppKey());
vo.setStatus(instance.getStatus()); vo.setStatus(instance.getStatus());
vo.setVersion(instance.getVersion()); vo.setVersion(instance.getVersion());
vo.setBizTitle(instance.getBizTitle()); vo.setBizTitle(instance.getBizTitle());
@@ -0,0 +1,83 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.core.PlatformService;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* UserBase「应用×学校开通」事实核验客户端(2026-10-25 T01,flow-center 侧)。
*
* <p>事实源=UserBase {@code sys_group_app_rel}(学校组单点开通,不沿祖先),
* 开放端点 {@code GET /oapi/app/activated?appId=&schoolId=}。<b>enforce 开启时
* 不可达/异常一律抛错(fail-closed)</b>——"UserBase 不可达"不得降级为"仅本地 grant 存在即放行"。</p>
*/
@Slf4j
@Component
public class UserBaseActivationClient {
private static final String ACTIVATED_PATH = "/oapi/app/activated";
private final YschOpenCall openCall;
/**
* 历史配置保留绑定;任何值均不能关闭学校开通校验。
* 生产开启前须确认 UserBase 已部署 {@code /oapi/app/activated} 且开通数据齐备。
*/
@Value("${yh.flow.school-activation-enforce:false}")
private boolean enforce;
public UserBaseActivationClient(@Autowired(required = false) YschOpenCall openCall) {
this.openCall = openCall;
}
public boolean isEnforce() {
return true;
}
/**
* @return true=已开通;false=明确未开通(调用方按"未获使用资格"拒绝)
* @throws BizException enforce 开启且 UserBase 不可达/返回异常——fail-closed
*/
public boolean isAppActivatedAtSchool(Integer appId, Long schoolId) {
return isAppActivatedStrict(appId, schoolId);
}
/** Formal commands never inherit the legacy bypass switch. */
public boolean isAppActivatedStrict(Integer appId, Long schoolId) {
if (appId == null || appId <= 0 || schoolId == null || schoolId <= 0) {
throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验缺少主体或校域");
}
if (openCall == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用(未配置出站通道)");
}
Map<String, String> query = new HashMap<>();
query.put("appId", String.valueOf(appId));
query.put("schoolId", String.valueOf(schoolId));
JsonNode data;
try {
data = openCall.get(PlatformService.USERBASE.key(), ACTIVATED_PATH, query, JsonNode.class);
} catch (RuntimeException e) {
log.warn("[usage-grant] UserBase 开通资格核验失败 appId={} schoolId={}:{}", appId, schoolId, e.toString());
throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用,请稍后重试");
}
if (data == null || data.isNull() || !data.hasNonNull("activated")) {
log.warn("[usage-grant] UserBase 开通资格核验返回异常 appId={} schoolId={}", appId, schoolId);
throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格核验不可用,请稍后重试");
}
if (!data.path("activated").isBoolean()
|| !data.path("appId").isIntegralNumber() || data.path("appId").intValue() != appId
|| !data.path("schoolId").isIntegralNumber() || data.path("schoolId").longValue() != schoolId) {
throw new BizException(ErrorCode.UNAUTHORIZED, "应用学校开通资格响应不匹配");
}
return data.path("activated").booleanValue();
}
}
@@ -0,0 +1,6 @@
-- 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) 既有索引后过滤,暂不加列索引
@@ -0,0 +1,4 @@
-- 2026-10-25 T04:受理载荷指纹(同 requestToken 不同载荷=冲突,不伪装幂等)
-- ddl-auto=update 会自动加列;本脚本为生产备案。存量行保持 NULL=指纹核对缺省关。
ALTER TABLE `flw_instance`
ADD COLUMN `apply_payload_hash` VARCHAR(64) NULL COMMENT '受理时规范载荷指纹 SHA-256(T04 同ID同指纹幂等/异载荷冲突)' AFTER `apply_round`;
@@ -0,0 +1,46 @@
-- 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(64) NULL, `create_time` DATETIME(6) NULL,
`update_by` VARCHAR(64) 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(64) NULL, `create_time` DATETIME(6) NULL,
`update_by` VARCHAR(64) 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)';
-- T05 修正(2026-10-25 FlowEngineIT 会签并发实证):REPEATABLE READ 快照读 max+1 会撞 uk,
-- 事件序号改为实例行上计数器(行锁内 +1)
ALTER TABLE `flw_instance`
ADD COLUMN `event_seq_counter` INT NULL COMMENT '事件事实 eventSeq 发号器(T05 行锁内 +1)' AFTER `apply_payload_hash`;
@@ -0,0 +1,21 @@
-- 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 白名单)';
@@ -0,0 +1,3 @@
-- 已在开发库按旧形态建过表的场合执行一次;新库直接用主脚本(无 uk)
ALTER TABLE `flw_state_event` DROP INDEX `uk_flw_state_event_seq`;
ALTER TABLE `flw_state_event` ADD INDEX `idx_flw_state_event_seq` (`instance_id`, `event_seq`);
@@ -0,0 +1,12 @@
-- 2026-10-25 R2(Codex 终审 D04):flw_callback_log 投递幂等键从 event_id 单列改为 (event_id, reg_id) 复合。
--
-- 背景:T05 起投递行 eventId 复用事实行稳定身份——同一事件对全部匹配订阅方各建一行(每 reg 一行),
-- 旧的单列 uk_flw_log_event(event_id) 会误拒第二订阅方的合法投递行(第二行插入即撞唯一键);
-- 同时并发"exists→insert"竞态不是幂等证明,改由复合 uk 兜底(DuplicateKeyException=另一路径已建行,
-- 调用方按幂等成功处理)。
--
-- 执行说明:仅在已按旧形态建过 uk 的库执行一次;新库由 ddl-auto=update / 建表脚本直接生成复合键。
-- 本脚本不具幂等性(DROP INDEX 重复执行报 1091)——按 Codex D15 口径纳入发布前置检查清单,
-- 先 SHOW INDEX FROM flw_callback RESULTS 确认现存索引名再执行对应分支。
ALTER TABLE `flw_callback_log` DROP INDEX `uk_flw_log_event`;
ALTER TABLE `flw_callback_log` ADD UNIQUE KEY `uk_flw_log_event_reg` (`event_id`, `reg_id`);
@@ -0,0 +1,102 @@
-- D09 DRAFT ONLY. Never run this file against production as part of application startup.
-- Quiesce handling, transfer, return, restart and advance workers before an audited legacy backfill.
-- Apply additive columns first, then deploy ALL D09 writers together; mixed old/new writers are unsafe.
-- The root task is an identity, not a completion-order comparison. Never backfill MAX(task_id).
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);
-- Inventory is a rollout gate, not an automatic guess of the historical cohort/last handler:
-- SELECT instance_id,node_id,id,status FROM flw_advance_intent WHERE activation_task_id IS NULL AND status IN ('PENDING','EXHAUSTED');
-- NULL work without a registered successor is held EXHAUSTED, never executed or silently marked DONE.
-- For a still-active legacy node, an operator supplies the EXACT audited current task cohort.
-- If pending tasks remain, p_trigger must be NULL: their future real handling re-arms the new head.
-- If all tasks are already handled, p_trigger requires independent evidence of the LAST handler;
-- equal handle_time/ID order is NOT evidence. Ambiguous history stays held for an explicit audited resolution.
-- p_audit_ref identifies that evidence. A successor is inserted BEFORE NULL work is retired, atomically.
-- Test harness extracts the procedure below as ONE JDBC statement; mysql clients need DELIMITER $$ ... $$.
DELIMITER $$
-- D09_BACKFILL_BEGIN
CREATE PROCEDURE d09_backfill_activation(IN p_instance BIGINT, IN p_node VARCHAR(64), IN p_tasks JSON,
IN p_trigger BIGINT, IN p_audit_ref VARCHAR(128))
BEGIN
DECLARE v_root BIGINT;
DECLARE v_def BIGINT;
DECLARE v_version INT;
DECLARE v_round INT;
DECLARE v_status INT;
DECLARE v_nodes TEXT;
DECLARE v_pending INT;
DECLARE v_count INT;
DECLARE v_head BIGINT;
DECLARE v_members LONGTEXT;
DECLARE v_handled JSON;
DECLARE v_edge VARCHAR(128);
DECLARE v_user INT;
DECLARE v_name VARCHAR(64);
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
IF p_audit_ref IS NULL OR LENGTH(TRIM(p_audit_ref))=0 OR p_tasks IS NULL
OR JSON_TYPE(p_tasks)<>'ARRAY' OR JSON_LENGTH(p_tasks)=0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 audited cohort input is required';
END IF;
IF EXISTS (SELECT 1 FROM JSON_TABLE(p_tasks,'$[*]' COLUMNS(ord FOR ORDINALITY,task_id BIGINT PATH '$')) j
WHERE JSON_TYPE(JSON_EXTRACT(p_tasks,CONCAT('$[',j.ord-1,']')))<>'INTEGER' OR j.task_id<=0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 task identities must be positive integers';
END IF;
START TRANSACTION;
SELECT def_id,def_version,apply_round,status,current_nodes INTO v_def,v_version,v_round,v_status,v_nodes
FROM flw_instance WHERE id=p_instance FOR UPDATE;
IF v_status IS NULL OR v_status<>10 OR FIND_IN_SET(p_node,COALESCE(v_nodes,''))=0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 legacy node is not currently active';
END IF;
IF EXISTS (SELECT 1 FROM flw_advance_intent WHERE instance_id=p_instance AND node_id=p_node AND activation_task_id IS NOT NULL) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 registered successor exists; do not overwrite it';
END IF;
SELECT COUNT(DISTINCT t.id),MIN(t.id) INTO v_count,v_root
FROM flw_task t JOIN JSON_TABLE(p_tasks,'$[*]' COLUMNS(task_id BIGINT PATH '$')) j ON j.task_id=t.id
WHERE t.instance_id=p_instance AND t.node_id=p_node AND t.status IN (0,1,3,5);
IF v_count<>JSON_LENGTH(p_tasks) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 cohort has duplicates, foreign or invalid tasks';
END IF;
SET v_members=CONCAT(',',REPLACE(REPLACE(REPLACE(CAST(p_tasks AS CHAR),'[',''),']',''),' ',''),',');
IF EXISTS (SELECT 1 FROM flw_task WHERE instance_id=p_instance AND node_id=p_node AND status=0
AND LOCATE(CONCAT(',',id,','),v_members)=0) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 cohort omits current pending tasks';
END IF;
SELECT COUNT(*) INTO v_pending FROM flw_task WHERE instance_id=p_instance AND node_id=p_node AND status=0;
IF (v_pending=0 AND p_trigger IS NULL) OR (v_pending>0 AND p_trigger IS NOT NULL) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 pending cohort waits; completed cohort needs proven last handler';
END IF;
IF p_trigger IS NOT NULL THEN
IF LOCATE(CONCAT(',',p_trigger,','),v_members)=0
OR NOT EXISTS (SELECT 1 FROM flw_task WHERE id=p_trigger AND status=1) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='D09 last handler is not an approved cohort member';
END IF;
SELECT edge_name,assignee_id,assignee_name INTO v_edge,v_user,v_name FROM flw_task WHERE id=p_trigger;
END IF;
SELECT JSON_ARRAYAGG(id) INTO v_handled FROM flw_task
WHERE instance_id=p_instance AND node_id=p_node AND status<>0 AND LOCATE(CONCAT(',',id,','),v_members)>0;
INSERT INTO flw_advance_intent(instance_id,node_id,activation_task_id,activation_task_ids,handled_task_ids,
trigger_task_id,activation_def_id,activation_def_version,activation_apply_round,revision,
edge_name,assignee_id,assignee_name,status,attempts,next_retry_at,last_error,create_time,update_time,create_by)
VALUES(p_instance,p_node,v_root,v_members,
IF(v_handled IS NULL,NULL,CONCAT(',',REPLACE(REPLACE(REPLACE(CAST(v_handled AS CHAR),'[',''),']',''),' ',''),',')),
p_trigger,v_def,v_version,v_round,0,v_edge,v_user,v_name,IF(p_trigger IS NULL,'DONE','PENDING'),0,NOW(),
LEFT(CONCAT('D09_AUDITED_BACKFILL: ',p_audit_ref),255),NOW(),NOW(),LEFT(p_audit_ref,64));
SET v_head=LAST_INSERT_ID();
UPDATE flw_advance_intent SET status='DONE',revision=revision+1,update_time=NOW(),
last_error=LEFT(CONCAT('D09_HANDOFF: successor=',v_head,' audit=',p_audit_ref),255)
WHERE instance_id=p_instance AND node_id=p_node AND activation_task_id IS NULL AND status IN ('PENDING','EXHAUSTED');
COMMIT;
END
-- D09_BACKFILL_END
$$
DELIMITER ;
-- No CALL is executed by this draft. Audit the cohort before an explicitly approved maintenance invocation.
@@ -0,0 +1,45 @@
-- REVIEW DRAFT. No production execution is authorized.
-- Stop ALL legacy writers and take a verified backup before the transactional model cutover.
-- MySQL 5.6: DDL auto-commits. Apply in a maintenance window, then validate indexes before enabling.
ALTER TABLE flw_instance ADD COLUMN source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
ADD COLUMN legacy_slot int NULL DEFAULT 1;
-- Keep every legacy row in slot 1. Do NOT infer its source UUID or fabricate command history.
UPDATE flw_instance SET legacy_slot=1 WHERE source_principal_id IS NULL;
ALTER TABLE flw_instance DROP INDEX uk_flw_instance_biz,
ADD UNIQUE KEY uk_flw_instance_legacy(sch_id,biz_type,biz_id,legacy_slot),
ADD UNIQUE KEY uk_flw_instance_round(sch_id,source_principal_id,biz_type,biz_id,apply_round);
ALTER TABLE flw_usage_grant ADD COLUMN source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NULL;
ALTER TABLE flw_callback_reg ADD COLUMN source_principal_id varchar(36) CHARACTER SET ascii COLLATE ascii_bin NULL;
-- UUID grants require explicit audited re-grant; subscriptions require authenticated re-registration.
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;
-- New formal comparisons require exact string identity. Review collation/index byte limits first.
-- On pre-existing case-insensitive columns, use the following explicit conversion (not an implicit code assumption):
ALTER TABLE flw_instance MODIFY biz_type varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
MODIFY biz_id varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL;
-- Do not enable formal-command.enabled until legacy coordinate migration policy is applied.
-- A clean/new coordinate is supported now; an overlapping legacy coordinate returns LEGACY_COORDINATE_NOT_MIGRATED.
@@ -0,0 +1,15 @@
-- F01/F02. Apply only to the verified FL schema after reviewing SHOW INDEX.
-- No grant INSERT, source backfill, instance deletion or other database changes.
-- Preconditions: source_principal_id and legacy_slot already exist (302216a schema).
-- If the older 3-column uk_flw_instance_biz also exists, it MUST be dropped separately
-- after SHOW INDEX confirms its exact name; otherwise it still blocks distinct sources.
ALTER TABLE flw_instance
DROP INDEX uk_flw_instance_legacy,
ADD UNIQUE KEY uk_flw_instance_legacy
(sch_id, source_principal_id, biz_type, biz_id, legacy_slot);
-- Keep uk_flw_instance_round for the formal command path.
-- Existing NULL-source rows are not reassigned or used as callers' receipts.
-- Rollback: first stop new writers and check cross-source duplicate coordinates.
-- Restoring the old index can FAIL once two sources have created identical coordinates.
-- Never delete those rows to force rollback; restore a verified pre-change backup
-- only under an explicitly approved FL-only recovery plan.

Some files were not shown because too many files have changed in this diff Show More