Compare commits

...
Author SHA1 Message Date
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
chenxing a10f2e054c feat(openapi): FormCenterSchemaClient 走标准 appCode 网关 fallback,application.yml 出厂 base-host 默认内部网关
build / build-push (push) Successful in 1m18s
2026-10-22 17:14:06 +08:00
chenxing 4b4d63e3ba t1~t2+精修+TASK_4: 定义绑定表单UUID/版本成对核验、发起恢复守卫、schema兼容边界草案
build / build-push (push) Successful in 1m10s
含10-22精修: 保存/发布/启用按确切uuid+version远端核验、升版继承两列、取消关联清空版本、缺版本不回落最新。TASK_4=docs/schema-extension-compatibility-next-tasks.md(纯文档)。
2026-10-22 10:03:28 +08:00
ops 60ff464226 fix(ci): 构建容器名唯一化(避免并发构建互相 rm -f 致 exit 137) 2026-10-21 02:26:47 +00:00
ops 61b0ae7b8a ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:58 +00:00
chenxing 2f54f37ab3 C1余量:类型桶查②+域查③红牌(启发式字面量形态匹配,保守边界不执行表达式)
build / build-push (push) Failing after 17m24s
2026-10-20 22:52:43 +08:00
chenxing 97dec39d45 C1第一刀:flw_def.form_uuid启用+发布期form-center schema快照嵌入formContract+表达式键查①红牌(audience按数组解析)
build / build-push (push) Failing after 2m6s
2026-10-20 22:50:58 +08:00
chenxing 93ecc79839 feat(p3.5): oapi 增 GET /tasks/done 与 /cc——已办/抄送按签名方应用过滤(P2-02 接缝)
build / build-push (push) Successful in 1m11s
- 两端点复用 FlowQueryService#pageDone/pageCc;过滤键=验签身份 appKey(请求属性
  yh.app.auth → d.appKey),不收调用方 bizType——天然覆盖消费方新旧 bizType 存量、
  防共享定义跨应用泄漏
- 契约测试 5 例(standalone 无 DB):appKey 接缝下传/bizType 不参与过滤/必参缺失 400 零查询
- 全量 262/262 绿(本地含 DB;Stage S5/P3.5)
2026-10-20 00:16:06 +08:00
chenxing 52c27d88bb [fix] 办理页业务信息走 summaryFields + biz_url 绝对化(BizUrlResolver)
build / build-push (push) Successful in 1m43s
- 详情出参新增 bizUrlAbs:biz_url 裸相对路径按 applyAppCode(by-code)→applyAppKey(by-key)
  查应用注册表 pc_url 拼 base#path(口径同 UserHome 消息中心 jumpUrl),查不到为 null
- 缓存 5min/未命中 60s,不阻断详情接口;biz_url 原值保留给 bare 态父页面接管
2026-10-19 20:09:29 +08:00
chenxing 132919b029 [v2.1] A 应用内审批地基:oapi 增 GET /tasks/by-biz——按单据查当前人待办(0/1/N 均 200 空数组不泄露,校 schId/bizType/assignee 三 match,IT 11 例)
build / build-push (push) Successful in 2m2s
2026-10-19 16:44:35 +08:00
39 changed files with 3936 additions and 196 deletions
+72 -10
View File
@@ -9,13 +9,17 @@ name: build
on:
push:
branches: [ main ]
branches: [ main, codex/core-rc-20261023 ]
workflow_dispatch:
inputs:
cache_bust:
description: '构建前清空 maven m2 缓存'
required: false
default: 'false'
tests_only:
description: '只验证测试和 JAR,不构建推送镜像或通知建档'
required: false
default: 'true'
jobs:
build-push:
@@ -25,7 +29,10 @@ jobs:
options: --network infra-infra-net
steps:
- name: 安装 git / curl(alpine)
run: apk add --no-cache git curl
run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -33,6 +40,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
git -C /tmp/src fetch --depth 1 origin "$GITHUB_SHA"
git -C /tmp/src checkout --detach FETCH_HEAD
test "$(git -C /tmp/src rev-parse HEAD)" = "$GITHUB_SHA"
echo "CI_SOURCE_SHA=$GITHUB_SHA"
cp -a /tmp/src/. .
rm -rf /tmp/src
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
@@ -55,20 +66,70 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: |
set -e
docker rm -f mavenbuild 2>/dev/null || true
docker run -d --name mavenbuild \
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
trap 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true' EXIT
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \
-v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
# 为真实 MySQL 测试创建本次 job 独占的临时数据库;不映射宿主端口、不连接业务库。
DB_CONTAINER="${BUILD_CONTAINER}-db"
cleanup_ci() {
result=$?
trap - EXIT
docker rm -fv "$BUILD_CONTAINER" "$DB_CONTAINER" >/dev/null 2>&1 || true
docker info >/dev/null 2>&1 || { echo 'CI_CONTAINER_CLEANUP=UNVERIFIED' >&2; exit 1; }
if docker inspect "$BUILD_CONTAINER" >/dev/null 2>&1 || docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
echo 'CI_CONTAINER_CLEANUP=FAILED' >&2
exit 1
fi
echo 'CI_CONTAINER_CLEANUP=PASS'
exit "$result"
}
trap cleanup_ci EXIT
export MYSQL_ROOT_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
export MYSQL_PASSWORD=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
docker create --name "$DB_CONTAINER" --network infra-infra-net \
-e MYSQL_DATABASE=flowcenter_ci -e MYSQL_USER=flow_ci \
-e MYSQL_PASSWORD -e MYSQL_ROOT_PASSWORD mysql:8.0 --skip-log-bin >/dev/null
docker cp "${PWD}/ci/flow-test-schema.sql" "$DB_CONTAINER":/docker-entrypoint-initdb.d/001-schema.sql
docker start "$DB_CONTAINER" >/dev/null
ready=0
for n in $(seq 1 60); do
if docker exec "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -h127.0.0.1 -u"$MYSQL_USER" "$MYSQL_DATABASE" -Nse "SELECT 1" >/dev/null 2>&1'; then ready=1; break; fi
sleep 2
done
[ "$ready" -eq 1 ] || { echo 'Isolated CI MySQL did not become ready' >&2; exit 1; }
export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
export CI_TEST_DB_USER=flow_ci CI_TEST_DB_PASSWORD="$MYSQL_PASSWORD"
tables=$(docker exec "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -h127.0.0.1 -u"$MYSQL_USER" "$MYSQL_DATABASE" -Nse "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()"')
[ "$tables" = 14 ] || { echo "Expected 14 CI schema tables, got $tables" >&2; exit 1; }
echo "CI_SCHEMA_TABLES=$tables"
# 先在独立空 schema 验证真实 validate 失败会阻断打包;随后再生成正向候选 JAR。
printf '%s\n' 'CREATE DATABASE flowcenter_ci_invalid;' "GRANT ALL ON flowcenter_ci_invalid.* TO 'flow_ci'@'%';" | \
docker exec -i "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot'
positive_db_url="$CI_TEST_DB_URL"
export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci_invalid?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \
"$BUILD_CONTAINER" sh -c 'cd /build && sh ci/verify-failure-guard.sh'
export CI_TEST_DB_URL="$positive_db_url"
docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \
"$BUILD_CONTAINER" sh -c 'cd /build && sh ci/test-gate.sh clean package'
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
run: |
set -e
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
@@ -79,6 +140,7 @@ jobs:
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
- name: 通知 projman 建档(部署由运维手动触发)
if: ${{ success() && github.ref == 'refs/heads/main' && inputs.tests_only != 'true' }}
run: |
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \
+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` 复制进初始化目录;该文件只含 14 张表的结构,不含业务数据。
镜像初始化阶段建立测试 schema,测试阶段只 validate。每个 job 的数据库容器名和随机凭据独立,
EXIT trap 回收 Maven/MySQL 容器;不访问 160 业务库。
随机凭据仅经容器环境变量传递,不写仓库或日志。Maven 子容器必须获得:
- `CI_TEST_DB_URL`:MySQL JDBC URL;schema 必须为 `flowcenter_ci` 或 `flowcenter_ci_*`。
- `CI_TEST_DB_USER`、`CI_TEST_DB_PASSWORD`:本次数据库的专用账号与密码。
脚本只允许 localhost、127.0.0.1 或 `mavenbuild-*-db` 主机,随后映射为 Spring datasource 环境变量。
`OapiFlowTasksByBizTest` 在任何 cleanDb 前检查 JDBC 实际连接的 host/schema,防止 profile 覆盖错误。
CI 场景必须实际连接 `flowcenter_ci*`,不能悄悄改用默认开发库;11 项继续为真 MySQL 测试。
本地等价运行:在独立测试库按此 schema 初始化后,通过进程环境设置上述三个变量,
再执行 `sh ci/test-gate.sh clean package`。凭据不要写入命令历史或提交到仓库。
本次已在本机 MySQL 8.0.45 做正反等价验证:完整 schema 通过,空 schema 验证失败且不生成 JAR。
三个 workflow 的手动入口新增 `tests_only=true`(默认),可以在 runner 上只验测试/JAR,不推镜像或通知建档。
本机没有 Docker,Gitea runner 的容器启动、镜像拉取和网络连通未实跑;这些仍需在 runner 上验证,
不能将等价 Maven 证据描述为实际 Gitea workflow 已通过。
实体结构变更时应显式刷新 SQL 快照并重新验证;不能把 validate 改为 update 来绕过缺表或缺列。
## 核心 RC 功能分支实跑
`codex/core-rc-20261023` 的 push 只执行测试/JAR;镜像推送与通知同时受 main ref 与 tests_only 条件约束。
检出后核验 `CI_SOURCE_SHA` 等于事件 SHA,不能使用随后更新的 main。
FL 在同一独立 MySQL 容器额外创建空 `flowcenter_ci_invalid`,先运行 `verify-failure-guard.sh`:
要求 11 项真实数据库测试因 validate 缺表报错、Maven 非零且无 JAR;随后对 14 表正常 schema 执行正向门禁。
空库检查的预期失败不属于 380 项正向用例。回收检查打印 `CI_CONTAINER_CLEANUP=PASS`,同时删除本次容器的匿名卷。
本文件是运行约定;只有取得对应远端准确 SHA 的实际日志,才能宣称 runner 已完成验证。
+330
View File
@@ -0,0 +1,330 @@
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_app` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`app_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`app_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`enabled` int NOT NULL,
`sch_id` bigint NOT NULL,
`sort_no` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_app_sch_key` (`sch_id`,`app_key`),
KEY `idx_flw_app_sch_enabled` (`sch_id`,`enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_assignee_map` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`dept_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`dept_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
`user_id` int NOT NULL,
`user_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_map_sch_dept_user` (`sch_id`,`dept_key`,`user_id`),
KEY `idx_flw_map_sch_dept` (`sch_id`,`dept_key`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_attachment` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`field_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`file_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`file_path` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`file_size` bigint DEFAULT NULL,
`instance_id` bigint NOT NULL,
`mime_type` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`web_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_flw_att_inst` (`instance_id`,`sch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_branch` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`branch_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`instance_id` bigint NOT NULL,
`status` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_branch_inst_key` (`instance_id`,`branch_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_callback_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`biz_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_type` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`event` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`event_id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`instance_id` bigint NOT NULL,
`last_error` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
`last_http_status` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`next_retry_time` datetime DEFAULT NULL,
`payload_json` longtext COLLATE utf8mb4_general_ci NOT NULL,
`push_time` datetime DEFAULT NULL,
`reg_id` bigint NOT NULL,
`retry_count` int NOT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
`resp_body` text COLLATE utf8mb4_general_ci,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_log_event` (`event_id`),
KEY `idx_flw_log_scan` (`status`,`next_retry_time`),
KEY `idx_flw_log_inst` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_callback_reg` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`app_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`callback_url` varchar(500) COLLATE utf8mb4_general_ci NOT NULL,
`event_types` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`sch_id` bigint NOT NULL,
`secret` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`status` int NOT NULL,
`base_path` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_reg_sch_app_flow` (`sch_id`,`app_key`,`flow_guid`),
KEY `idx_flw_reg_flow` (`sch_id`,`flow_guid`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_cc` (
`id` bigint NOT NULL AUTO_INCREMENT,
`instance_id` bigint NOT NULL COMMENT '归属办件',
`sch_id` bigint NOT NULL COMMENT '租户键(冗余,列表免 join)',
`node_id` varchar(64) NOT NULL COMMENT '定义节点 id',
`node_name` varchar(128) DEFAULT NULL COMMENT '节点名快照',
`user_id` int NOT NULL COMMENT '接收人 id',
`user_name` varchar(128) DEFAULT NULL COMMENT '接收人姓名快照',
`sent_time` datetime(3) NOT NULL COMMENT '抄送时间(节点激活时刻,毫秒)',
`create_by` varchar(64) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(64) DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_flw_cc_user` (`user_id`,`sent_time`),
KEY `idx_flw_cc_inst` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='抄送留痕(NOTIFY 节点逐接收人一行)';
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_def` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`content_json` longtext COLLATE utf8mb4_general_ci NOT NULL,
`flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`form_id` bigint DEFAULT NULL,
`item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`publish_time` datetime DEFAULT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
`version` int NOT NULL,
`app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`change_log` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`form_uuid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`form_version` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_def_sch_code_ver` (`sch_id`,`code`,`version`),
UNIQUE KEY `uk_flw_def_guid_ver` (`sch_id`,`flow_guid`,`version`),
KEY `idx_flw_def_guid` (`sch_id`,`flow_guid`,`status`,`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_dept` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`dept_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`sch_id` bigint NOT NULL,
`source_group_id` int DEFAULT NULL,
`status` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_dept_sch_srcgroup` (`sch_id`,`source_group_id`),
KEY `idx_flw_dept_sch_status` (`sch_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_dept_member` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`dept_id` bigint NOT NULL,
`status` int NOT NULL,
`user_id` int NOT NULL,
`user_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_dept_member` (`dept_id`,`user_id`),
KEY `idx_flw_dept_member_dept` (`dept_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_instance` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`biz_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_snapshot` longtext COLLATE utf8mb4_general_ci,
`biz_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_type` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`current_nodes` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`def_code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`def_id` bigint NOT NULL,
`def_version` int NOT NULL,
`finish_time` datetime DEFAULT NULL,
`form_data_json` longtext COLLATE utf8mb4_general_ci,
`item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`item_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
`submit_time` datetime DEFAULT NULL,
`submitter_id` int NOT NULL,
`submitter_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`version` int NOT NULL,
`apply_app_code` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_app_key` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`assign_json` longtext COLLATE utf8mb4_general_ci,
`apply_request_token` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_round` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_instance_biz` (`sch_id`,`biz_type`,`biz_id`),
KEY `idx_flw_inst_submitter` (`sch_id`,`submitter_id`,`status`),
KEY `idx_flw_inst_status_time` (`sch_id`,`status`,`submit_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_route_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`attempt` int NOT NULL,
`decide_time` datetime(3) NOT NULL,
`instance_id` bigint NOT NULL,
`node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`outlet` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`raw_response` text COLLATE utf8mb4_general_ci,
`router_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`sch_id` bigint NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_flw_route_log_inst` (`instance_id`,`node_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_router` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`base_path` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`path` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
`router_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`service_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`status` int NOT NULL,
`direct_host` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`reg_app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_router_key` (`router_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_task` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`arrive_time` datetime(3) NOT NULL COMMENT '到达时间(毫秒)',
`assignee_id` int NOT NULL,
`assignee_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`deadline` datetime DEFAULT NULL,
`handle_time` datetime(3) DEFAULT NULL COMMENT '办理时间(毫秒)',
`instance_id` bigint NOT NULL,
`node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`opinion` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
`urge_count` int NOT NULL,
`edge_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_flw_task_inst_status` (`instance_id`,`status`),
KEY `idx_flw_task_assignee` (`assignee_id`,`status`,`arrive_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
+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
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
: "${CI_TEST_DB_URL:?isolated empty MySQL schema URL is required}"
case "$CI_TEST_DB_URL" in */flowcenter_ci_invalid\?*|*/flowcenter_ci_invalid_*\?*) ;; *) echo 'Negative guard requires its own invalid test schema' >&2; exit 2 ;; esac
log=$(mktemp /tmp/flow-ci-negative.XXXXXX)
trap 'rm -f "$log"' EXIT
set +e
sh ci/test-gate.sh clean package >"$log" 2>&1
code=$?
set -e
cat "$log"
echo "NEGATIVE_MVN_EXIT=$code"
[ "$code" -ne 0 ] || { echo 'Empty schema unexpectedly passed' >&2; exit 1; }
grep -q 'Schema-validation: missing table' "$log"
grep -q 'Tests run: 92, Failures: 0, Errors: 11, Skipped: 0' "$log"
[ -z "$(find target -maxdepth 1 -name '*.jar' -print -quit 2>/dev/null)" ] || { echo 'Failed gate produced a JAR' >&2; exit 1; }
echo 'CI_EMPTY_SCHEMA_FAILURE_GUARD=PASS'
+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 验收。
@@ -0,0 +1,118 @@
# FL公共schema扩展兼容边界与下一批任务草案(TASK_4)
日期:2026-10-22。状态:文档检查完成;没有实施显隐/矩阵/量表,也没有放宽FC白名单或修改路由算法。伴随契约:[FC草案](../../../form-center/service/docs/schema-extension-draft.md)。本相对链接跨目录时应以工作区根目录的 `form-center/service/docs/schema-extension-draft.md` 为准。
## 1. HEAD与当前锚点
FL后端HEAD=`2f54f37ab34ca71410ec854f1adcc1da7c4dbe5f`;FC后端HEAD=`b2437a20742bfbfe38a564134904f3e0d7d931a3`;FC前端HEAD=`2e406a88b1d9e09b2ca54a8cf68a3726174f49c0`。均有任务实施后的未提交工作区内容,不能写成已合入。
FL / `src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java` / `FormCenterSchemaClient#toSnapshot(Map)`:下段已与当前HEAD的git show重新对照,当前工作区仍保留这段搬运规则:
```java
field.put("key", f.path("key").asText(null));
field.put("type", f.path("type").asText(null));
JsonNode required = f.get("required");
field.put("required", required == null ? Boolean.FALSE : required.asBoolean(false));
```
同方法对数组options搬运value(缺value时回落label)为enum;顶层快照为formUuid/formVersion/audience/fields。它没有完整保留field.valueSchema、visible_when、pattern、min/max、precision或复合值声明;更没有执行这些规则。`type`字符串被带入不能证明引擎支持复杂值。
FL / `service/FlowDefAdminService#save/#publish/#enable`:当前工作区在保存绑定前核验确切版本,升版继承formUuid/formVersion,取消关联清空坐标;发布以 `fetchSchema(schId,uuid,version)` 核对返回坐标后嵌入contentJson.formContract。此管理行为属于Task1及本轮代码修复,**不能声称存在于上述HEAD**。已发布/停用版本的快照不能原地重写;修改关联走新草稿。旧两参fetchSchema仍是内部方法,不作为新管理链的无版本入口。
FC / `common/FormSchemaValidator#validateField`:
```java
rejectUnknownProps(field, ALLOWED_PROPS, "字段属性(" + key + ")");
```
FC / `common/FormValueValidator#validate`先处理required再分派类型;当前没有显隐计算。FC / `common/FormValueSchemaBuilder#withValueSchemas`只生成读侧机读描述;前端 `types/formSchema.ts` / `ALLOWED_FIELD_PROPS`及 `components/fields/index.ts#fieldWidget`限定当前控件。根未知属性未统一拒绝,不构成安全规则兼容机制。
## 2. 当前边界与未来兼容矩阵
| FC值/候选类型 | 当前schema/快照事实 | 未来FL路由要求(未实施) |
|---|---|---|
| number | JSON number;FC校验边界/precision;FL快照只保留type等少量字段 | 显式数值域及空值语义;不能从缺失边界推定任意数值合法 |
| select/radio | 稳定value字符串;FL有enum | 用value比较,不能按label;value缺失不能静默用展示文案给新契约兜底 |
| multiselect/checkbox | string[];快照有type及enum,但无完整array约束 | 禁止把整个数组当标量eq/gt操作数;若需contains等运算,必须另立版本化运算符及集合语义 |
| upload/image | [{id,fileName,key}];机读形状未完整进入FL快照 | 禁止对象/数组转字符串或比较附件URL作为路由;文件访问权限仍在FC |
| display metadata | 当前无通用跨端透传保证 | 获批后可在专用命名空间保留,不能执行或改变审批判据 |
| visible_when | FC字段属性白名单拒绝,FL无执行器 | 必需规则不受支持则拒绝发布/发起;不得只在编辑器隐藏后继续按旧快照执行 |
| matrix/scale | 未冻结/未放行 | 拒绝复合值标量路由,或只消费显式版本化投影;两种策略实施前定稿 |
当前表达式校验与运行仍按现有引擎行为;上表右列是下一批要求,不冒充已存在的“复杂值安全闸”。不得为了通过候选schema而先修改PHASE1_TYPES、ALLOWED_PROPS或表达式白名单。
## 3. 能力协商与投影候选
下一版formContract应明确schemaContractVersion、requiredCapabilities以及字段完整值形状;具体字段名尚为候选。unsupported required capability必须阻断发布,不能丢掉字段后继续成功。已发布流程继续使用其原快照,不被FC新能力升级暗中改变。
若选择投影,应明确:
- 稳定命名空间及命名冲突规则(候选如`projection.satisfaction_score`,当前表达式是否支持点号必须先核实,不能直接采用)。
- 投影算法版本、输入表单版本、rowKey/枚举值域及顺序,禁止label参与算法。
- 全部可见、部分空值、整题隐藏、不适用、非法复合结构的输出分别定义;hidden不能自动映射为0,null不能自动映射为空字符串。
- 谁计算并校验投影、在哪个可信边界附带它、如何进入revision/hash;不能信任浏览器额外上报的分数。
- 对象键顺序与数组顺序遵循已有规范化;如改变快照成员或哈希规则必须升协议,不原地改fc.canon.v1。
| 场景 | 候选输出/行为 |
|---|---|
| 已知row、有效值、题目可见 | 按已冻结投影算法产出标量/枚举 |
| 题目隐藏 | 输出显式不适用标记或拒绝该路由;不得假装0分,具体方案待定 |
| 必答row缺失 | FC拒绝答卷,不向FL构造“补零”结果 |
| 重复row/未知row/非法枚举 | 拒绝复合值,错误可定位,不执行路由 |
| 旧schema无该题/旧流程无该能力 | 保持旧快照行为;需要新能力则升流程版本 |
## 4. 下一批任务书草案(均未授权实施)
这些文件已分别属于Task1/2或历史功能,实施前必须重新分配写权限;不能在Task3分支顺手追加。以下为成对功能包,**不是可同时执行的互不相交白名单**。正式拆分时要核对真实路径、确定独占文件集和按序依赖。
### E1:公共扩展版本及静态规则校验
背景:先建立能力协商及拒绝边界,避免静默丢规则。
改动候选:FC `common/FormSchemaValidator#validateSchema/#validateField/#validateOptions`、`common/FormValueSchemaBuilder#withValueSchemas`;FC前端 `types/formSchema.ts`、设计器序列化路径;拟新增独立规则AST/能力清单类(名称待实施时确定)。必须同时处理根/group/field/options各层保留与拒绝规则,不能只放宽字段白名单。
行为:先校验契约版本与必需能力,再核对引用/类型/循环/资源预算;未知安全规则拒绝,允许的display metadata仅保留不执行。valueSchema仍由服务端派生。
依赖/约束:重新分配Task1 schema/目录契约相关文件;不改AppRegistry或部门模型,不引入eval。
验收计划:未知根/字段/选项、循环依赖、类型不符、超预算、保留metadata但不执行、旧schema保持原行为。此处仅列计划,本轮不写、不跑测试。
### E2:可见性规则的全写入通道与普通题型回归
背景:显隐不是纯前端效果,必须覆盖自填、匿名、hosted、本人/管理员编辑和导入。
改动候选:FC `service/AnswerService#doSubmit`、`service/AnswerReadService#editBySubmitter`、`service/AnswerAdminService#edit`、`service/AnswerImportService`、公共FormValueValidator;FC前端 `views/Fill.vue`、字段渲染/校验及编辑入口。具体编辑组件路径在正式任务前用rg重新定位,不虚构已存在文件名。
行为:取钉版schema,执行同一规则服务,先visible再required;隐藏值A/B策略必须先获定稿。导入预检和执行使用同一版本;本人/匿名身份边界和hosted本人边界保持;索引/outbox基于实际存储结果更新。
依赖/约束:E1完成且隐藏值策略定稿;Answer/投递/Fill属于Task2,需重新分配;OPEN-T19b原有代填不是此任务取消对象。
验收计划:number=0、false、select value、空数组、多选顺序、upload归属;切换上游使下游隐藏;旧答卷按原版编辑;匿名与hosted隔离;导入同规则、原子回滚与hash一致。
### E3:统计导出与历史解释
改动候选:FC `service/AnswerStatsService`、`AnswerExportService`、`AnswerIndexService`和已有统计/导出前端(路径须正式定位);矩阵渲染与行列编辑组件仅在题型获批后新增。
行为:区分N/E/V/A四类分母;逐答卷版本解释字段/规则,导出不适用与空值不同;历史读取不修改答卷。不跨版本同key暗合并,不把多选计次当人数。
依赖/约束:E2后才有稳定写入语义;不得用当前schema重解释历史数据。矩阵object/array结构、rowKey、算法上限先定稿。
验收计划:版本间增删题、隐藏/可见未答、V=0、多选计数、矩阵每行/整题分母、字典label变动、历史附件及业务扩展保留。
### E4:FC—FL成对快照、复杂值拒绝或投影
改动候选:FC `service/OapiFormService`/机读schema契约;FL `service/FormCenterSchemaClient#toSnapshot`、`FlowDefAdminService#publish`、`FlowInstanceService`输入校验及`engine/FlowDefModel`/相关表达式验证入口;FL前端定义编辑器及表达式提示。调用端与服务端在一个任务中交付,不硬拆端点和调用。
行为:先识别required capabilities,再保存完整契约快照;不支持的安全能力拒绝发布。complex值不走标量比较,或仅使用显式受信投影。新绑定保存/发布均核验确切formUuid/version;旧快照只读,改动走新流程版本。
依赖/约束:E1能力格式稳定,E2/E3值与统计口径稳定;Task1版本绑定、Task2流程发起文件需重新分配;不改部门节点策略,不把矩阵处理塞进回调恢复。
验收计划:array/object标量路由拒绝、投影边界与null、返回坐标不符、撤权、跨版本不可变快照、普通表单仍可发布/发起。资源失败必须有稳定码,不把“网络不可达”误当“不支持能力”。
## 5. 本轮交付检查
- 当前白名单、11类控件、根/group/field/options边界、FC验证与FL快照搬运已逐项对照源码。
- 样例均明确标注候选;隐藏值A/B未被私自定稿,matrix/scale未被写成已实现。
- 数值/单选/多选/附件及所有写入、编辑、导入、统计、导出的版本来源详见FC伴随草案。
- L1状态:**文档检查完成,功能测试未执行**。按用户本轮指令只做编译/跳过测试打包;构建结果记录在根目录docs/tasks/evidence/refinement-20261022,不构成候选功能验收。
- 后续具体测试与生产联调方案另出;本轮不连接真实库/MQ、不执行DDL、不启动对账、不做前端视觉验收。
@@ -120,6 +120,10 @@ public class CallbackDispatcher {
if (event.getData() != null) {
data.putAll(event.getData());
}
// 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。
if (event.getRoundIdentity() != null) {
data.putAll(event.getRoundIdentity());
}
payload.put("data", data);
String payloadJson = objectMapper.writeValueAsString(payload);
@@ -0,0 +1,23 @@
package com.yunhai.flowcenter.callback;
import com.yunhai.flowcenter.engine.FlowEvent;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/** 同步发布阶段冻结轮次身份;不修改引擎或 apply/v2 七支分支。 */
@Component
@RequiredArgsConstructor
public class FlowEventIdentityListener {
private final FlwInstanceRepository instances;
@EventListener
@Order(Ordered.HIGHEST_PRECEDENCE)
public void capture(FlowEvent event) {
instances.findById(event.getInstanceId()).ifPresent(instance ->
event.captureRoundIdentity(instance.getApplyRound(), instance.getApplyRequestToken()));
}
}
@@ -6,6 +6,7 @@ import com.yunhai.flowcenter.entity.FlwCallbackReg;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.model.FlowModels;
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.service.FlowAdminGuard;
import com.yunhai.flowcenter.service.FlowCallbackAdminService;
@@ -45,6 +46,16 @@ public class FlowAdminController {
private final FlowAdminGuard adminGuard;
private final FlowDefAdminService defAdminService;
/**
* 表单中心目录客户端(v3/TASK_1)。
*
* <p><b>字段注入而不是加进构造参数</b>:本类的构造器已被 {@code FlowAdminInstanceHungTest} 与
* {@code FlowDiagramServiceTest} 逐位钉住,而那两份测试不在本任务的可改清单里——多一个入参
* 会让整个测试树编译不过。</p>
*/
@org.springframework.beans.factory.annotation.Autowired
private com.yunhai.flowcenter.service.FormCenterCatalogClient formCenterCatalogClient;
private final FlowMapAdminService mapAdminService;
private final FlowCallbackAdminService callbackAdminService;
private final com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService;
@@ -103,7 +114,29 @@ public class FlowAdminController {
return defAdminService.get(adminGuard.requireAdmin(user, null), id);
}
/** 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;changeLog=升版本改动说明,仅新版本行落库,>500 截断) */
/**
* 表单中心目录代理(v3/TASK_1):编辑器"关联表单"选择器的数据源,浏览器只打本应用同源接口,
* appKey/secret 不在前端出现。校域由 {@link FlowAdminGuard#requireAdmin} 从 CAS 会话派生,
* 前端没有可越界的 schId 入口;中心侧只回"已发布 ∩ 本应用可读"的表单,
* 没授权的表压根不出现(不是"看得见但选不了")。
*/
@GetMapping("/form-center/forms")
public com.yunhai.flowcenter.service.FormCenterCatalogClient.CatalogPage formCenterForms(
BoaUser user,
@RequestParam(value = "appCode", required = false) String appCode,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
return formCenterCatalogClient.listForms(adminGuard.requireAdmin(user, null),
appCode, keyword, page, pageSize);
}
/**
* 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;
* changeLog=升版本改动说明,仅新版本行落库,>500 截断;
* formUuid=关联表单定义 uuid(C1,2026-10-20):null=保持、空串=取消关联、发布后不可改改=升版;
* <b>formVersion=v3 确切版本(与 formUuid 成对,单传一个即拒;取消关联时同批清空)</b>。
*/
@PostMapping("/def/save")
public FlwDef defSave(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
@@ -111,7 +144,18 @@ public class FlowAdminController {
return defAdminService.save(schId, id,
(String) body.get("code"), (String) body.get("name"),
(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"),
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);
}
}
/**
@@ -1,6 +1,9 @@
package com.yunhai.flowcenter.controller.oapi;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.FlowModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.service.FlowQueryService;
import com.yunhai.flowcenter.service.FlowRouterAdminService;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.platform.utils.model.BasePageResult;
@@ -32,7 +35,10 @@ import java.util.List;
* 应用内审批(2026-09 第三步,原"刻意不提供审批动作"立场由该方案修订:登录身份由调用方
* 应用按其 CAS 会话保证并以 operatorId 传入,本端按任务办理人逐字复核,审计链不断):
* - GET /oapi/flow/tasks/todo?schId=&assigneeId=&bizType=&page=&size= → data=分页待办
* - GET /oapi/flow/tasks/by-biz?schId=&bizType=&bizId=&operatorId= → data=当前人在该单据的待办(0/1/N)
* - GET /oapi/flow/instances?schId=&bizType=&status=&page=&size= → data=分页办件
* - GET /oapi/flow/tasks/done?schId=&assigneeId=&page=&size= → data=分页已办(过滤=签名 appKey→d.appKey,P3.5)
* - GET /oapi/flow/cc?schId=&userId=&page=&size= → data=分页抄送我(过滤=签名 appKey→d.appKey,P3.5)
* - POST /oapi/flow/task/{taskId}/approve?schId=&operatorId=&operatorName=(body: opinion/edgeName)
* - POST /oapi/flow/task/{taskId}/reject?schId=&operatorId=&operatorName=(body: opinion 必填)
*/
@@ -43,6 +49,7 @@ public class OapiFlowController {
private final OapiFlowService flowService;
private final FlowRouterAdminService routerAdminService;
private final FlowQueryService queryService;
@GetMapping("/list")
public List<OapiModels.FlowSummaryVo> list(@RequestParam("schId") Long schId) {
@@ -62,6 +69,23 @@ public class OapiFlowController {
return flowService.apply(schId, appKey, body);
}
/**
* 带请求标识/轮次守卫的代发起(v2 §8,TASK_2 C.3)。
*
* <p>与 {@code /apply} 是<b>两条显式分支</b>而不是一个端点加 flag:旧端点行为逐字不变
* (同 bizKey 命中 30/40 仍按 R4 重启推进),新端点声明"我带幂等守卫"——
* 同 {@code requestToken} 重投只回读现状,{@code retryOnly} 命中终态也不重启,
* 陈旧轮次拒绝重启。响应多带一个稳定分支码 {@code branch},消费方按码处置。
* 请求标识与轮次由消费方的持久化出站命令携带(svc-hall {@code flow_apply_command})。</p>
*/
@PostMapping("/apply/v2")
public FlowApplyRetryModels.ApplyResultV2 applyV2(@RequestParam("schId") Long schId,
@RequestAttribute(value = "yh.app.auth",
required = false) String appKey,
@RequestBody FlowApplyRetryModels.ApplyRequestV2 body) {
return flowService.applyV2(schId, appKey, body);
}
/** 发起预览:返回开启发起时指派的节点与候选人(已剔除发起人本人),供子应用提交页选人 */
@GetMapping("/apply/preview")
public OapiModels.ApplyPreviewVo applyPreview(@RequestParam("schId") Long schId,
@@ -158,6 +182,49 @@ public class OapiFlowController {
return flowService.pageTodoTasks(schId, assigneeId, bizType, page, size);
}
/**
* 按业务单据查当前人待办(v2.1 业务详情卡片数据源):与 tasks/todo 互补——那个是"人→单据",
* 这个是"单据→我的待办"。0/1/N 三种计数都由 HTTP 200 + tasks 数组表达,办件不存在与
* 他人待办同样出空(不用于探测)。operatorId 由子应用后端从 CAS 会话取,前端不可传。
*/
@GetMapping("/tasks/by-biz")
public OapiModels.ByBizTasksVo tasksByBiz(
@RequestParam("schId") Long schId,
@RequestParam("bizType") String bizType,
@RequestParam("bizId") String bizId,
@RequestParam("operatorId") Integer operatorId) {
return flowService.tasksByBiz(schId, bizType, bizId, operatorId);
}
/**
* 我的已办(P3.5 应用内审批四页签):过滤接缝=<b>签名方应用身份</b>(d.appKey=验签 appKey),
* 不收调用方 bizType 裸透传——办件挂本应用定义即含(天然覆盖新旧 bizType 存量),共享定义
* 跨应用不泄漏(P2-02 裁决)。assigneeId 由子应用后端从 CAS 会话取。
*/
@GetMapping("/tasks/done")
public BasePageResult<FlowModels.TaskItem> tasksDone(
@RequestParam("schId") Long schId,
@RequestParam("assigneeId") Integer assigneeId,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size,
@RequestAttribute(value = "yh.app.auth", required = false) String appKey) {
return queryService.pageDone(schId, assigneeId, appKey, page, size);
}
/**
* 抄送我的(P3.5,NOTIFY 节点落库行,sent_time 倒序):过滤接缝同 tasks/done
* (d.appKey=签名 appKey,不收 bizType)。userId 由子应用后端从 CAS 会话取。
*/
@GetMapping("/cc")
public BasePageResult<FlowModels.CcItem> cc(
@RequestParam("schId") Long schId,
@RequestParam("userId") Integer userId,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size,
@RequestAttribute(value = "yh.app.auth", required = false) String appKey) {
return queryService.pageCc(schId, userId, appKey, page, size);
}
/** 办件分页(oapi):bizType/status 可选过滤 */
@GetMapping("/instances")
public BasePageResult<OapiModels.InstanceItemVo> instances(
@@ -282,8 +282,12 @@ public final class DefValidator {
}
}
/** 表达式内置变量白名单(ConditionRouteService.builtinEnv 注入的同名集) */
private static final Set<String> EXPRESSION_BUILTINS = new HashSet<>(java.util.Arrays.asList(
/**
* 表达式内置变量白名单(ConditionRouteService.builtinEnv 注入的同名集)。<br>
* 2026-10-20 C1 起 {@link com.yunhai.flowcenter.service.FlowDefAdminService} 的键查①红牌
* 复用本集合(表达式变量 ⊆ 快照字段 ∪ 内置变量)——两处必须是同一份,内置集扩了这边自动跟上。
*/
public static final Set<String> EXPRESSION_BUILTINS = new HashSet<>(java.util.Arrays.asList(
"schId", "instanceId", "instanceVersion", "bizType", "bizId",
"nodeId", "nodeName", "submitterId"));
@@ -69,6 +69,15 @@ public class FlowEvent {
private final String operatorName;
/** 事件明细(result/opinion/rejectNode/task.created 的 n/m 等),可为空 */
private final Map<String, Object> data;
private Map<String, Object> roundIdentity;
public void captureRoundIdentity(Integer round, String requestToken) {
if (roundIdentity != null) return;
Map<String, Object> identity = new HashMap<>();
identity.put("applyRound", round);
identity.put("requestToken", requestToken);
roundIdentity = java.util.Collections.unmodifiableMap(identity);
}
public static FlowEvent of(Type type, Long instanceId) {
return new FlowEvent(type, instanceId, null, null, null, new HashMap<>());
@@ -60,6 +60,20 @@ public class FlwDef extends BaseEntity {
@Column(name = "form_uuid", length = 32)
private String formUuid;
/**
* 关联表单的<b>确切版本</b>(v3/TASK_1):与 {@link #formUuid} 成对,发布期按这组坐标取快照,
* 中心返回的坐标与请求不符即阻断发布。
*
* <p>为什么必须钉版本而不是"发布时取当前发布版":同一条流程线的两次发布可能落到不同的表结构上,
* 而表达式校验、办件回显都按嵌入的 formContract 判——版本不钉住,"这条流程当时用的哪张表"
* 就成了只能猜的历史。存的是服务器侧核验过的版本号。</p>
*
* <p>null 只出现在本次改造之前已经关联了表单的存量行上(C1 第一刀没这列):
* 那种行发布时仍走"当前发布版"的旧口径,改造后新保存/新关联一律成对写入。</p>
*/
@Column(name = "form_version")
private Integer formVersion;
/** §5.1 流程定义 JSON */
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
private String contentJson;
@@ -129,6 +129,26 @@ public class FlwInstance extends BaseEntity {
@Column(name = "version", nullable = false)
private Integer version;
/**
* 最近一次成功受理代发起的<b>请求标识</b>(消费方持久化命令里带的 token,v2 §8,TASK_2 C.3)。
*
* <p>它回答的是"这次 apply 是同一命令的重投,还是新一轮发起"——biz uk 只说得出
* "这个业务键已经有办件了",说不出这句。没有它时,消费方在响应丢失后重发同业务键,
* 命中 30/40 会被 R4 语义重启推进,等于<b>把网络重试当成用户重提</b>。
* 同 token 一律只回读现状、不重启;换 token 才是新一轮。</p>
*/
@Column(name = "apply_request_token", length = 64)
private String applyRequestToken;
/**
* 当前发起轮次(1 起;随新一轮请求标识进位)。
*
* <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
* 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p>
*/
@Column(name = "apply_round")
private Integer applyRound;
/**
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
@@ -0,0 +1,27 @@
package com.yunhai.flowcenter.enums;
import com.yunhai.platform.utils.exception.IErrorCode;
/** 管理面表单绑定契约:消费方按数值码判断恢复动作,不解析 msg。 */
public enum FormBindingErrorCode implements IErrorCode {
VERSION_REQUIRED(46101, "关联表单缺少明确版本"),
INVALID_COORDINATE(46102, "关联表单坐标或版本不合法"),
SCHEMA_UNAVAILABLE(46103, "关联表单 schema 拉取失败,可重试"),
SCHEMA_CONTRACT_VIOLATION(46104, "关联表单 schema 坐标核验失败"),
RESOURCE_UNAVAILABLE(46105, "关联表单或指定发布版本不存在或不可读"),
ACCESS_DENIED(46106, "关联表单访问身份或授权被拒绝");
private final int code;
private final String msg;
FormBindingErrorCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
@Override
public int getCode() { return code; }
@Override
public String getMsg() { return msg; }
}
@@ -0,0 +1,81 @@
package com.yunhai.flowcenter.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
/**
* 代发起的请求标识与轮次守卫(v2 §8,TASK_2 C.3)。
*
* <p><b>为什么是独立 DTO 而不是往 {@code OapiModels.ApplyBody} 加字段</b>(任务书原文要求):
* 旧 ApplyBody 是多个消费应用共用的线上契约,往里塞"重投标识/轮次"会让所有消费方
* 都要重新理解一遍字段语义;更重要的是,请求标识的分支行为<b>只对新端点成立</b>——
* 旧端点保持逐字不变,新端点显式声明"我带守卫"。两侧分支写清楚,比共用一个 DTO 加 if 诚实。</p>
*
* <p>继承旧 body 是为了让 {@code /oapi/flow/apply/v2} 的接收形状与旧端点完全一致
* (消费方只多带三个字段),不是为了共享可变字段。</p>
*/
public final class FlowApplyRetryModels {
private FlowApplyRetryModels() {
}
/** v2 请求体:旧 apply 全字段 + 请求标识/轮次/仅补发开关 */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ApplyRequestV2 extends OapiModels.ApplyBody {
/**
* 本次命令的请求标识(消费方持久化后携带,重投同值)。
* 与行上已落的标识相同 ⇒ 同一命令的重投 ⇒ <b>只回读现状,绝不重启推进</b>。
*/
private String requestToken;
/** 发起轮次(1 起,单调);低于行上 applyRound 的是陈旧轮次迟到重投 */
private Integer attempt;
/**
* true=只补发"确实还没有办件"的情况:命中既有实例一律回读现状,
* 即使处于 30/40 也<b>不</b>触发 R4 重启。恢复链在"查无才补发"之分支上用它,
* 这样"网络重投"与"用户授权重提"在中心侧也是两个不同的事实。
*/
private Boolean retryOnly;
}
/** v2 响应:在旧 {instanceId,duplicated} 之上把"中心判出来的是哪一种命中"说清楚 */
@Data
public static class ApplyResultV2 {
private Long instanceId;
private boolean duplicated;
/**
* 稳定分支码(消费方按码处置,不靠文案):
* <ul>
* <li>{@code CREATED}:新建办件并推进;</li>
* <li>{@code IDEMPOTENT}:在途/已完成(10/20)命中,纯幂等返回;</li>
* <li>{@code SAME_REQUEST}:同 requestToken 的重投命中,<b>未重启</b>;</li>
* <li>{@code STALE_ROUND}:请求轮次陈旧,未重启;</li>
* <li>{@code REPLAY_ONLY}:retryOnly 命中既有实例(含 30/40),未重启;</li>
* <li>{@code RESTARTED}:新一轮授权重提,R4 重启推进。</li>
* </ul>
*/
private String branch;
/** 行上的当前轮次(消费方据此校正自己的命令表认知) */
private Integer applyRound;
/** 同一响应的真实状态/实例版本;分支判据保持不变。 */
private Integer status;
private Integer version;
}
/** 分支码常量(与 {@link ApplyResultV2#branch} 同值域) */
public static final class Branch {
public static final String CREATED = "CREATED";
public static final String IDEMPOTENT = "IDEMPOTENT";
public static final String SAME_REQUEST = "SAME_REQUEST";
public static final String STALE_ROUND = "STALE_ROUND";
public static final String REPLAY_ONLY = "REPLAY_ONLY";
public static final String RESTARTED = "RESTARTED";
public static final String CONCURRENT_CREATED = "CONCURRENT_CREATED";
private Branch() {
}
}
}
@@ -85,6 +85,12 @@ public final class FlowModels {
private String bizId;
private String bizTitle;
private String bizUrl;
/**
* 业务详情的**绝对地址**(bizUrl 为裸相对路径,由 applyAppCode 查应用注册表 pc_url 拼出,
* 口径同消息中心跳转);无法解析为 null ⇒ 前端不渲染"查看业务详情"入口。
* 保留 bizUrl 原值不动:嵌入/去壳态由父页面按自己路由接管,绝对地址只服务流内直开。
*/
private String bizUrlAbs;
/** 业务摘要 JSON(办理页区块渲染;无富文本正文——概览 §十边界) */
private String bizSnapshot;
private String formDataJson;
@@ -3,6 +3,7 @@ package com.yunhai.flowcenter.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -106,6 +107,8 @@ public final class OapiModels {
@Data
public static class InstanceVo {
private Long instanceId;
private Integer applyRound;
private String requestToken;
private Integer status;
private Integer version;
private String nodeSummary;
@@ -266,4 +269,45 @@ public final class OapiModels {
/** 多出口节点办理时所选出口名(单出口可省) */
private String edgeName;
}
/**
* 按业务单据定位的当前人待办(tasks/by-biz 出参,v2.1 应用内审批卡片数据源)。
* 无办件与"有办件但本人无待办"同为 tasks=[] + HTTP 200,出参不区分二者(防探测)。
*/
@Data
public static class ByBizTasksVo {
/** 办件 id;无办件时 null */
private Long instanceId;
private String bizType;
private String bizId;
/** 办件状态;无办件时 null */
private Integer status;
/**
* 办件当前活跃节点——**节点 id** 逗号串,原文照抄 {@code flw_instance.current_nodes},
* 与 {@code instances}/{@code progress} 同口径(不是节点名,渲染人读名称请取
* {@code tasks[].nodeName})。无办件时 null。
*/
private String currentNodes;
/** 当前人 PENDING 任务,按 taskId 升序;无待办为空数组 */
private List<ByBizTaskVo> tasks = new ArrayList<>();
}
/** by-biz 单条待办:只含办理该任务必需的定位信息,不透出他人办理人/意见 */
@Data
public static class ByBizTaskVo {
private Long taskId;
private String nodeId;
private String nodeName;
private String arriveTime;
/** 出口列表;单出口节点为空数组(与 FlowQueryService.detail 的 outlets 口径一致) */
private List<ByBizOutletVo> outlets = new ArrayList<>();
}
/** by-biz 出口(多出口节点办理时须选其一,透传给 approve 的 edgeName) */
@Data
public static class ByBizOutletVo {
private String name;
private String toNodeId;
private String toNodeName;
}
}
@@ -0,0 +1,120 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
* 办件 biz_url 绝对化(办件详情/办理页"查看业务详情")。
*
* <p>bizUrl 自 2026-09-26 起是**裸相对路径**(不含域名/ctx/#,见 UserMsgNotifier 跳转策略):落地基址由
* 接收方按"页面归属"解析。消息链路(UserHome 消息中心)已按 jumpApp→application_info.pc_url 拼;
* 流程中心自己是另一类接收方——用户在流内直接点"查看业务详情"时没有宿主页面可借基址,必须自己解析,
* 否则裸路径会落到 flow-center 自己的 origin 上(实测 /list/detail?serid=78 404)。
*
* <p>解析口径与消息中心逐字一致({@code MsgCenterProxyService#jumpUrl}):绝对地址直通 → 否则
* 按办件快照的 applyAppCode 查应用注册表 pc_url(老数据没有 app_code 时用 applyAppKey 兜底)
* → 丢 fragment、去尾斜杠 → {@code base + "#" + path}。查不到 pc_url 返回 null(前端据此不渲染入口),**不猜**。
*
* <p>缓存:pc_url 属低频变更的注册表数据,按应用标识缓存 5 分钟;查无缓存 60 秒(防脏值刷重复出站)。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class BizUrlResolver {
private static final long HIT_TTL_MS = 5 * 60_000L;
private static final long MISS_TTL_MS = 60_000L;
private final OpenApiAppClient appClient;
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
/**
* @param appCode 页面归属应用(办件 apply_app_code,如 notice)
* @param appKey 验签身份 app_key(老数据 app_code 为空时的兜底)
* @param bizUrl 办件快照里的 biz_url(裸相对路径,或历史存量绝对地址)
* @return 绝对地址;绝对地址原样返回,无法解析返回 null
*/
public String resolve(String appCode, String appKey, String bizUrl) {
if (bizUrl == null || bizUrl.trim().isEmpty()) {
return null;
}
String url = bizUrl.trim();
if (isAbsolute(url)) {
return url;
}
String pcUrl = null;
if (hasText(appCode)) {
pcUrl = pcUrl("code:" + appCode.trim(), appClient::getAppByCode);
}
if (pcUrl == null && hasText(appKey)) {
pcUrl = pcUrl("key:" + appKey.trim(), appClient::getAppByKey);
}
if (pcUrl == null) {
return null;
}
StringBuilder base = new StringBuilder(pcUrl);
int hash = base.indexOf("#");
if (hash >= 0) {
base.setLength(hash);
}
while (base.length() > 0 && base.charAt(base.length() - 1) == '/') {
base.setLength(base.length() - 1);
}
if (base.length() == 0) {
return null;
}
return base + "#" + (url.startsWith("/") ? url : "/" + url);
}
private static boolean isAbsolute(String url) {
return url.startsWith("http://") || url.startsWith("https://");
}
private static boolean hasText(String v) {
return v != null && !v.trim().isEmpty();
}
private String pcUrl(String cacheKey, Function<String, JsonNode> fetcher) {
long now = System.currentTimeMillis();
CacheEntry cached = cache.get(cacheKey);
if (cached != null && cached.expireAt > now) {
return cached.pcUrl;
}
String pcUrl = null;
try {
JsonNode app = fetcher.apply(cacheKey.substring(cacheKey.indexOf(':') + 1));
if (app != null && app.hasNonNull("pcUrl")) {
String v = app.get("pcUrl").asText(null);
if (hasText(v)) {
pcUrl = v.trim();
}
}
if (pcUrl == null) {
log.warn("[flow-center] biz_url 归属应用无 pc_url,跳转入口隐藏:{}", cacheKey);
}
} catch (Exception e) {
// 不可达按 miss 缓存,避免办件详情页每请求打一次 UserBase
log.warn("[flow-center] biz_url 归属应用查址失败(UserBase 不可达?){}:{}", cacheKey, e.toString());
}
cache.put(cacheKey, new CacheEntry(pcUrl, now + (pcUrl == null ? MISS_TTL_MS : HIT_TTL_MS)));
return pcUrl;
}
private static final class CacheEntry {
private final String pcUrl;
private final long expireAt;
private CacheEntry(String pcUrl, long expireAt) {
this.pcUrl = pcUrl;
this.expireAt = expireAt;
}
}
}
@@ -7,6 +7,7 @@ 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;
@@ -43,6 +44,7 @@ public class FlowDefAdminService {
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) {
@@ -112,6 +114,8 @@ public class FlowDefAdminService {
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();
@@ -138,6 +142,8 @@ public class FlowDefAdminService {
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;
}
}
@@ -153,7 +159,7 @@ public class FlowDefAdminService {
if (def.getId() == null) {
def.setChangeLog(cleanLog);
}
def.setContentJson(contentJson);
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());
@@ -169,20 +175,236 @@ public class FlowDefAdminService {
}
}
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布 */
/**
* 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。<br>
* <b>发布后不可改,改=升版本</b>由既有升版分派天然保证(非 DRAFT 直改必走升版新行);
* 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库);
* 非空=设置,保存前按确切版本远端核验,失败不写入。
* <br>v3/TASK_1 追加 {@code formVersion}:与 formUuid <b>成对</b>,规则是——
* ① 两个都给了值=设关联(版本按服务端收到的整数存,之后发布按这组确切坐标核验);
* ② 只给一个=拒(半个坐标既存不下也没法核验,比"存了再说"便宜);
* ③ formUuid 给空串=取消关联,<b>同时清空版本</b>(留着一个没人认领的版本号是下一轮误用的起点);
* ④ formUuid 给 null=这条指令不存在(两个坐标都不动,兼容旧调用面)。
*/
@Transactional
public FlwDef save(Long schId, Long id, String code, String name, String contentJson,
String baseGuid, String appKey, String changeLog, String formUuid, Integer formVersion) {
boolean clearing = formUuid != null && formUuid.trim().isEmpty();
boolean pairing = formUuid != null && !clearing && formVersion == null;
boolean versionOnly = formUuid == null && formVersion != null;
if (pairing || versionOnly) {
throw new BizException(formVersion == null ? FormBindingErrorCode.VERSION_REQUIRED
: FormBindingErrorCode.INVALID_COORDINATE,
"关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid="
+ (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion);
}
if (formUuid != null && !clearing) {
checkedSchema(schId, formUuid.trim(), formVersion, "保存"); // Validate before the first save.
}
FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
if (formUuid != null) {
String clean = formUuid.trim();
saved.setFormUuid(clean.isEmpty() ? null : clean);
saved.setFormVersion(clean.isEmpty() ? null : formVersion);
}
try {
return defRepository.save(saved);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
}
}
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布。<br>
* 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照
* 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验),
* 并做表达式<b>键查①</b>红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试);
* 运行期对 form-center 零依赖。form_uuid 为空(存量线/Mode C)行为逐字不变。 */
@Transactional
public FlwDef publish(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态");
}
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿");
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
Map<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
def.getFormVersion(), "发布");
List<String> problems = new ArrayList<>();
validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
validateExpressionTypesAndDomains(model, snapshot, problems);
if (!problems.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"发布校验失败(表达式三查):\n" + String.join("\n", problems));
}
def.setContentJson(embedFormContract(def.getContentJson(), snapshot));
} else if (def.getFormVersion() != null) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
/**
* 表达式键查①(C1 三查之一,红牌):对每条 EXPRESSION 判断的出边表达式,
* 变量集 ⊆ 快照字段 keys ∪ 内置变量({@link DefValidator#EXPRESSION_BUILTINS})。
* 不执行表达式、不改沙箱(口径与 v2 方案 §4.7 一致)。
*/
private void validateExpressionKeysAgainstSnapshot(FlowDefModel model, Map<String, Object> snapshot,
List<String> problems) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
java.util.Set<String> fieldKeys = new java.util.HashSet<>();
if (fields != null) {
for (Map<String, Object> f : fields) {
Object k = f.get("key");
if (k != null) {
fieldKeys.add(String.valueOf(k));
}
}
}
if (fieldKeys.isEmpty()) {
problems.add("关联表单快照无字段(formContract.fields 为空):请检查表单是否已发布");
return;
}
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !n.expressionJudge()) {
continue;
}
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null || e.condition.trim().isEmpty()) {
continue;
}
List<String> vars;
try {
vars = com.yunhai.flowcenter.engine.ExpressionEvaluator.variableNames(e.condition.trim());
} catch (Exception ex) {
// 语法错已由 DefValidator 拦下,这里理论上到不了;防御口径跳过
continue;
}
for (String v : vars) {
if (!fieldKeys.contains(v) && !DefValidator.EXPRESSION_BUILTINS.contains(v)) {
problems.add("节点「" + n.name + "」出口「" + e.name + "」表达式变量 " + v
+ " 不在关联表单字段集内(快照 " + fieldKeys.size() + " 个字段)");
}
}
}
}
}
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
private String embedFormContract(String contentJson, Map<String, Object> snapshot) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.set("formContract", objectMapper.valueToTree(snapshot));
return objectMapper.writeValueAsString(root);
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "快照嵌入失败:" + e.getMessage());
}
}
// ===================== 三查②③(2026-10-20 C1 第一刀余量项) =====================
/** 数值桶:可与数值字面量比大小的快照字段类型 */
private static final java.util.Set<String> NUMERIC_TYPES =
new java.util.HashSet<>(java.util.Arrays.asList("number"));
/** 值域字面量型(enum 域查③适用):options 收进快照 enum 的类型 */
private static final java.util.Set<String> ENUM_TYPES =
new java.util.HashSet<>(java.util.Arrays.asList("select", "radio"));
/** `字段 op '字面量'` / `字段 op 数值`(op 含两侧空格容忍) */
private static final java.util.regex.Pattern FIELD_STR_EQ = java.util.regex.Pattern.compile(
"([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=)\\s*'([^']*)'");
private static final java.util.regex.Pattern FIELD_NUM_CMP = java.util.regex.Pattern.compile(
"([A-Za-z_][A-Za-z0-9_]*)\\s*(>=|<=|>|<)\\s*(-?\\d+(\\.\\d+)?)");
private static final java.util.regex.Pattern NUM_FIELD_CMP = java.util.regex.Pattern.compile(
"(-?\\d+(\\.\\d+)?)\\s*(>=|<=|>|<)\\s*([A-Za-z_][A-Za-z0-9_]*)");
/**
* 类型桶查②红牌 + 域查③红牌(<b>启发式实现</b>:正则模式匹配字面量比较形态,非 AST——
* v2 §4.7 保守边界:不执行表达式、不做隐式转换,只拦"数值比较 × 非数值字段"与
* "枚举等值 × 字面量不在值域"两类高发错配;不命中的形态留给运行期既有挂起纪律)。
*/
@SuppressWarnings("unchecked")
private void validateExpressionTypesAndDomains(FlowDefModel model, Map<String, Object> snapshot,
List<String> problems) {
List<Map<String, Object>> fields = (List<Map<String, Object>>) snapshot.get("fields");
if (fields == null || fields.isEmpty()) {
return;
}
Map<String, Map<String, Object>> fieldByKey = new HashMap<>();
for (Map<String, Object> f : fields) {
Object k = f.get("key");
if (k != null) {
fieldByKey.put(String.valueOf(k), f);
}
}
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !n.expressionJudge()) {
continue;
}
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null || e.condition.trim().isEmpty()) {
continue;
}
String where = "节点「" + n.name + "」出口「" + e.name + "」";
String expr = e.condition.trim();
// ② 类型桶查:数值比较算子作用于非数值字段 → 红牌
java.util.regex.Matcher m = FIELD_NUM_CMP.matcher(expr);
while (m.find()) {
checkNumericBucket(where, m.group(1), m.group(2), fieldByKey, problems);
}
m = NUM_FIELD_CMP.matcher(expr);
while (m.find()) {
checkNumericBucket(where, m.group(4), m.group(3), fieldByKey, problems);
}
// ② 类型桶查(字符串侧):文本字面量等值比较数值字段 → 红牌
// ③ 域查:枚举字段等值比较的字面量 ∉ 快照 enum 值域 → 红牌
m = FIELD_STR_EQ.matcher(expr);
while (m.find()) {
String key = m.group(1);
String op = m.group(2);
String literal = m.group(3);
Map<String, Object> field = fieldByKey.get(key);
if (field == null) {
continue; // 内置变量或未知键:①已拦/不属本查
}
String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
if (NUMERIC_TYPES.contains(type)) {
problems.add(where + ":字段 " + key + " 是 number 类型,不能与字符串字面量 '"
+ literal + "' 做 " + op + " 比较(类型桶查②)");
} else if (ENUM_TYPES.contains(type) && "==".equals(op)) {
List<String> enums = (List<String>) field.get("enum");
if (enums != null && !enums.isEmpty() && !enums.contains(literal)) {
problems.add(where + ":字段 " + key + " 的值域是 " + enums
+ ",字面量 '" + literal + "' 不在其中(域查③)");
}
}
}
}
}
}
private void checkNumericBucket(String where, String key, String op,
Map<String, Map<String, Object>> fieldByKey, List<String> problems) {
Map<String, Object> field = fieldByKey.get(key);
if (field == null) {
return;
}
String type = field.get("type") == null ? null : String.valueOf(field.get("type"));
if (type != null && !NUMERIC_TYPES.contains(type)) {
problems.add(where + ":字段 " + key + " 是 " + type + " 类型,不能做数值比较 "
+ key + " " + op + " 数字(类型桶查②)");
}
}
/** 停用:不可新发起,存量跑完 */
@Transactional
public void offline(Long schId, Long id) {
@@ -207,11 +429,53 @@ public class FlowDefAdminService {
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "启用");
} else if (def.getFormVersion() != null) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version, String operation) {
if (version == null) {
throw new BizException(FormBindingErrorCode.VERSION_REQUIRED, "关联表单缺少明确版本,"
+ operation + "已阻断,请重新选择表单与版本");
}
if (version < 1 || uuid == null || uuid.trim().isEmpty()) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE,
"关联表单必须选择确切的正整数版本," + operation + "已阻断,请重新选择");
}
try {
return formCenterSchemaClient.fetchSchema(schId, uuid, version);
} catch (FormCenterSchemaClient.SchemaUnavailableException e) {
throw new BizException(FormBindingErrorCode.SCHEMA_UNAVAILABLE,
"关联表单快照拉取失败," + operation + "已阻断(可重试)", e);
} catch (FormCenterSchemaClient.SchemaContractViolationException e) {
throw new BizException(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION,
"关联表单版本核验失败," + operation + "已阻断(请重新选择表单与版本)", e);
} catch (FormCenterSchemaClient.SchemaResourceUnavailableException e) {
throw new BizException(FormBindingErrorCode.RESOURCE_UNAVAILABLE,
"关联表单或指定发布版本不存在或不可读," + operation + "已阻断", e);
} catch (FormCenterSchemaClient.SchemaAccessDeniedException e) {
throw new BizException(FormBindingErrorCode.ACCESS_DENIED,
"关联表单访问身份或授权被拒绝," + operation + "已阻断,请检查授权", e);
}
}
private String withoutFormContract(String contentJson) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.remove("formContract");
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义必须是有效的JSON对象");
}
}
/** 同一流程线全部版本(降序) */
public List<FlwDef> versions(Long schId, String flowGuid) {
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid);
@@ -6,6 +6,7 @@ import com.yunhai.flowcenter.engine.FlowEvent;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.repository.FlwBranchRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
@@ -106,6 +107,15 @@ public class FlowInstanceService {
}
private void restart(FlwInstance instance, ApplyCommand cmd) {
restart(instance, cmd, null, null);
}
/**
* R4 重启推进(语义不变:定义刷新、快照覆盖、旧分支清空、status 回 10、version 自增、轨迹跨轮连续)。
* 新增的两个参数只是<b>随行落库的请求标识与轮次</b>,不改任何节点算法(任务书 C.3 红线:
* 不为修复幂等而重写流程节点算法)。旧 apply 路径传 null ⇒ 行为与历史逐字一致。
*/
private void restart(FlwInstance instance, ApplyCommand cmd, String requestToken, Integer round) {
log.info("[flow-center] R4 重启推进:instance={} 原 status={} biz={}/{}",
instance.getId(), instance.getStatus(), cmd.bizType, cmd.bizId);
instance.setDefId(cmd.def.getId());
@@ -121,6 +131,13 @@ public class FlowInstanceService {
instance.setApplyAppCode(cmd.applyAppCode);
instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName);
// 新一轮才更新请求标识与轮次;同标识重投走不到这里(applyGuarded 已分支挡掉)
if (requestToken != null) {
instance.setApplyRequestToken(requestToken);
}
if (round != null) {
instance.setApplyRound(round);
}
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instance.setFinishTime(null);
@@ -267,4 +284,165 @@ public class FlowInstanceService {
return duplicated;
}
}
// ==================== 请求标识与轮次守卫(v2 §8,TASK_2 C.3) ====================
/**
* 守卫参数。<b>刻意不做成 {@code ApplyCommand} 的新字段</b>:那个 13 参构造被基线集成测试
* {@code FlowEngineIT} 逐位钉住,加参数会让整棵测试树编译失败(同 svc-hall 的 setter 注入先例)。
* 旧 {@link #apply(ApplyCommand)} 路径行为逐字不变,只有 {@link #applyGuarded} 吃这个策略。
*/
@RequiredArgsConstructor
public static class ApplyRetryPolicy {
public final String requestToken;
public final Integer attempt;
public final boolean retryOnly;
}
/** 守卫化结果:除 instanceId/duplicated 外,把"命中的是哪一种情形"落成稳定分支码 */
@RequiredArgsConstructor
public static class GuardedOutcome {
public final Long instanceId;
public final boolean duplicated;
public final String branch;
public final Integer applyRound;
}
/**
* 带守卫的代发起(新 {@code /oapi/flow/apply/v2} 唯一入口)。
*
* <p>判定顺序(每一支都只回读现状,不改流程状态机一行算法):</p>
* <ol>
* <li><b>先核归属</b>:行上的调用应用/提交者/流程线与本次请求不符 ⇒ 拒绝,
* <b>不能把别的应用相同 bizType 键当自己的结果</b>返回(C.3)。
* 应用身份两侧都非空才判(本地无签名的历史行为 null,不因此把存量件判死);</li>
* <li>同 {@code requestToken} 命中 ⇒ {@code SAME_REQUEST}:这是同一次命令的重投,
* 即使行已终态也<b>不重启</b>——那正是"响应丢失后重发"与"用户重提"的分界;</li>
* <li>10/20 命中 ⇒ {@code IDEMPOTENT}(既有语义不变);</li>
* <li>30/40 命中:{@code retryOnly=true} ⇒ {@code REPLAY_ONLY} 不重启;
* 请求轮次 ≤ 行轮次 ⇒ {@code STALE_ROUND} 不重启;
* 否则才是新一轮授权重提 ⇒ {@code RESTARTED}(走既有 R4 重启推进);</li>
* <li>无行 ⇒ 新建 + {@code CREATED}。</li>
* </ol>
*/
@Transactional
public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) {
FlwInstance existing = instanceRepository
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
if (existing != null) {
return settleExisting(existing, cmd, policy);
}
String token = policy == null ? null : trimToNull(policy.requestToken);
Integer round = policy == null || policy.attempt == null ? Integer.valueOf(1) : policy.attempt;
createInstance(cmd, token, round);
return new GuardedOutcome(instanceOf(cmd), false, FlowApplyRetryModels.Branch.CREATED, round);
}
private GuardedOutcome settleExisting(FlwInstance existing, ApplyCommand cmd, ApplyRetryPolicy policy) {
requireSameOwner(existing, cmd);
String token = policy == null ? null : trimToNull(policy.requestToken);
// 行上轮次为 NULL(v2 之前建的存量件)按 1 处理:这样"没有更高轮次"就不能重启别人的旧件,
// 新一轮授权重提(attempt≥2)才走得动 R4——存量行的守卫缺省是**关**而不是开。
int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
if (token != null && token.equals(existing.getApplyRequestToken())) {
log.info("[flow-center] 同请求标识重投命中,只回读现状不重启:instance={} biz={}/{} status={}",
existing.getId(), cmd.bizType, cmd.bizId, existing.getStatus());
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.SAME_REQUEST, existing.getApplyRound());
}
int status = existing.getStatus();
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.IDEMPOTENT, existing.getApplyRound());
}
if (policy != null && policy.retryOnly) {
// 恢复链的"查无才补发"落在已有行上:30/40 也绝不重启(C.2 点名)
log.warn("[flow-center] retryOnly 命中终态办件,拒绝重启:instance={} status={}",
existing.getId(), status);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.REPLAY_ONLY, existing.getApplyRound());
}
int incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt;
if (incomingRound <= currentRound) {
log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}",
existing.getId(), currentRound, incomingRound);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
}
restart(existing, cmd, token, incomingRound);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.RESTARTED, incomingRound);
}
/** 新建实例(与旧 apply 同一段代码形状:不复制第二份推进逻辑) */
private void createInstance(ApplyCommand cmd, String token, Integer round) {
FlwInstance instance = new FlwInstance();
instance.setSchId(cmd.schId);
instance.setDefId(cmd.def.getId());
instance.setDefCode(cmd.def.getCode());
instance.setDefVersion(cmd.def.getVersion());
instance.setBizType(cmd.bizType);
instance.setBizId(cmd.bizId);
instance.setBizTitle(cmd.bizTitle);
instance.setBizUrl(cmd.bizUrl);
instance.setBizSnapshot(cmd.bizSnapshot);
instance.setFormDataJson(cmd.formDataJson);
instance.setAssignJson(cmd.assignJson);
instance.setApplyAppKey(cmd.applyAppKey);
instance.setApplyAppCode(cmd.applyAppCode);
instance.setApplyRequestToken(token);
instance.setApplyRound(round);
instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName);
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instanceRepository.saveAndFlush(instance);
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
}
/**
* 旧 {@link #apply(ApplyCommand)} 的建件段复用(行为逐字不变,只是把 new 出来的那段抽出来)。
* 这里不复用 createInstance 是因为旧路径不写 token/round(保持存量行 NULL,不伪装"有守卫")。
*/
private Long instanceOf(ApplyCommand cmd) {
FlwInstance created = instanceRepository
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
return created == null ? null : created.getId();
}
/**
* 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用
* 相同 bizType 键当自己的结果")。两侧都非空才判——历史行没有 token/appKey 快照,
* 不能因为"当年没记"就把存量件判成别人的。
*/
private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) {
if (existing.getApplyAppKey() != null && cmd.applyAppKey != null
&& !existing.getApplyAppKey().equals(cmd.applyAppKey)) {
throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
}
if (existing.getSubmitterId() != null && cmd.submitterId != null
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符");
}
String existingFlowGuid = defRepository.findById(existing.getDefId())
.map(def -> def.getFlowGuid()).orElse(null);
if (existingFlowGuid != null && cmd.def != null && cmd.def.getFlowGuid() != null
&& !existingFlowGuid.equals(cmd.def.getFlowGuid())) {
throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件绑定的流程线不同(行=" + existingFlowGuid + " 请求=" + cmd.def.getFlowGuid() + ")");
}
}
private static String trimToNull(String raw) {
if (raw == null) {
return null;
}
String value = raw.trim();
return value.isEmpty() ? null : value;
}
}
@@ -48,6 +48,7 @@ public class FlowQueryService {
private final FlwDefRepository defRepository;
private final FlowDeptAdminService deptAdminService;
private final FlowEngine flowEngine;
private final BizUrlResolver bizUrlResolver;
public BasePageResult<FlowModels.InstanceItem> pageMy(Long schId, Integer submitterId,
Integer status, String appKey, Integer page, Integer size) {
@@ -152,6 +153,8 @@ public class FlowQueryService {
detail.setBizId(instance.getBizId());
detail.setBizTitle(instance.getBizTitle());
detail.setBizUrl(instance.getBizUrl());
detail.setBizUrlAbs(bizUrlResolver.resolve(
instance.getApplyAppCode(), instance.getApplyAppKey(), instance.getBizUrl()));
detail.setBizSnapshot(instance.getBizSnapshot());
detail.setFormDataJson(instance.getFormDataJson());
detail.setStatus(instance.getStatus());
@@ -0,0 +1,98 @@
package com.yunhai.flowcenter.service;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 表单中心目录客户端(v3/TASK_1):{@code GET {form-center}/oapi/form/forms},
* 给流程编辑器右侧"关联表单"选择器供数。调用形态对齐 {@link FormCenterSchemaClient}
* (命名目标 {@code form-center},身份=本应用 appKey,签名由 starter 拦截器注入)。
*
* <p>能出现在这里的表单<b>已经过中心侧的 read 授权</b>:没授权给本应用的表压根不在这个目录里,
* 所以编辑器不需要"选了才知道没权限"。反过来,目录里给了 {@code version}=中心当前发布版,
* 保存时按 {@code formUuid + formVersion} 成对落库,发布再按这组确切坐标核验一次
* ({@link FormCenterSchemaClient#fetchSchema(Long, String, Integer)})。</p>
*
* <p>空 data 按"目录不可用"拒而不是回一个空列表——空列表在页面上长得像"这所学校没有可用表单",
* 会把"授权没配"这件事藏起来。</p>
*/
@Slf4j
@Service
public class FormCenterCatalogClient {
/** 服务目录 key(同 FormCenterSchemaClient,两级共用一个目标) */
static final String TARGET = "form-center";
static final String FORMS_PATH = "/oapi/form/forms";
private final YschOpenCall openCall;
public FormCenterCatalogClient(YschOpenCall openCall) {
this.openCall = openCall;
}
/**
* @param schId CAS 会话派生的管理域(由 {@code FlowAdminGuard} 给,不接受前端传值)
* @return {@code {items,total}};{@code total}=中心过滤后可读的总条数
*/
public CatalogPage listForms(Long schId, String appCode, String keyword, Integer page, Integer pageSize) {
Map<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(schId));
if (notBlank(appCode)) {
query.put("appCode", appCode.trim());
}
if (notBlank(keyword)) {
query.put("keyword", keyword.trim());
}
if (page != null) {
query.put("page", String.valueOf(page));
}
if (pageSize != null) {
query.put("pageSize", String.valueOf(pageSize));
}
try {
CatalogPage data = openCall.get(TARGET, FORMS_PATH, query, CatalogPage.class);
if (data == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单中心目录返回空,请稍后重试");
}
return data;
} catch (BizException e) {
throw e;
} catch (RuntimeException e) {
log.warn("[flow-center] 表单中心目录拉取失败 schId={} {}", schId, e.toString());
throw new BizException(ErrorCode.PARAM_ERROR, "表单中心目录不可达(网络/授权未配置):" + e.getMessage());
}
}
private static boolean notBlank(String s) {
return s != null && !s.trim().isEmpty();
}
/** 目录页(形状=中心 GET /oapi/form/forms 的 data) */
@Getter
@Setter
public static class CatalogPage {
private List<CatalogItem> items;
private long total;
}
/** 目录行:选中后按 formUuid + version 成对保存 */
@Getter
@Setter
public static class CatalogItem {
private String formUuid;
private String code;
private String name;
private String appCode;
private Integer version;
private List<String> audience;
}
}
@@ -0,0 +1,205 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。<br>
* GET {目录解析 form-center}/oapi/form/schema?schId=&amp;uuid=&amp;version= ——只取明确发布版本。
* 保存、发布与启用共用精确坐标契约;无 UUID-only 回退。调用形态对齐 YschOpenCall:
* 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。<br>
* <b>只在管理期调用</b>(拉取失败=阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
* audience 解析:<b>契约 v2 C5/G3 起是字符串数组</b>(breaking,T13 切换)——本客户端按数组读取,
* 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。
*/
@Slf4j
@Service
public class FormCenterSchemaClient {
/** 服务目录 key(targets / services 覆盖优先,否则标准 appCode 网关 fallback) */
static final String TARGET = "form-center";
static final String SCHEMA_PATH = "/oapi/form/schema";
/** 快照是发布期一次性动作,超时给足(对照 RouteJudgeClient 的 3s 运行期预算) */
static final int TIMEOUT_MS = 10_000;
static final int MAX_ATTEMPTS = 2;
private final YschOpenCall openCall;
private final ObjectMapper objectMapper;
public FormCenterSchemaClient(YschOpenCall openCall, ObjectMapper objectMapper) {
this.openCall = openCall;
this.objectMapper = objectMapper;
}
/** 快照不可用(网络/信封/形态异常);发布期转 BizException 阻断,可重试 */
public static class SchemaUnavailableException extends RuntimeException {
public SchemaUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
/**
* 快照<b>坐标与请求不符</b>(v3/TASK_1):请求 {@code uuid+v} 却拿回另一张表或另一个版本。
*
* <p>与 {@link SchemaUnavailableException} 分开,是因为重试对它没有意义——同一个请求重放同一个答案,
* 真正要改的是关联(表单换校了、版本行被并了、中心某条分支回落到"当前发布版"了)。
* 发布期一律阻断,且文案要点名"不符"而不是"拉取失败",否则运维会往网络方向查。</p>
*/
public static class SchemaContractViolationException extends RuntimeException {
public SchemaContractViolationException(String message) {
super(message);
}
}
/** FC 将不存在、不可读、未发布的精确版本统一拒绝,不能靠文案区分资源存在性。 */
public static class SchemaResourceUnavailableException extends RuntimeException {
public SchemaResourceUnavailableException(Throwable cause) {
super("表单或指定发布版本不存在或不可读", cause);
}
}
public static class SchemaAccessDeniedException extends RuntimeException {
public SchemaAccessDeniedException(Throwable cause) {
super("表单访问身份或授权被拒绝", cause);
}
}
/**
* 发布期快照的<b>确切版本</b>形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=},
* 并把<b>返回坐标与请求坐标逐件核对</b>——不符即 {@link SchemaContractViolationException}(不重试)。
*
* fields 按分组扁平化,options 值域收进 enum;没有缺省当前版本的重载。
*/
public Map<String, Object> fetchSchema(Long schId, String formUuid, Integer version) {
if (version == null || version < 1 || formUuid == null || formUuid.trim().isEmpty()) {
throw new IllegalArgumentException("按确切版本取快照必须给 uuid 与正整数 version");
}
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
Map<String, String> query = new HashMap<>();
query.put("schId", String.valueOf(schId));
query.put("uuid", formUuid);
query.put("version", String.valueOf(version));
Map<String, Object> data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class);
if (data == null) {
throw new IllegalStateException("schema 返回空 data");
}
Map<String, Object> snapshot = toSnapshot(data);
requireCoordinate(snapshot, formUuid, version);
return snapshot;
} catch (SchemaContractViolationException e) {
throw e;
} catch (DException e) {
int code = e.getCode() == null ? -1 : e.getCode().getCode();
if (code == ErrorCode.PARAM_ERROR.getCode()) {
throw new SchemaResourceUnavailableException(e);
}
if (code == ErrorCode.FORBIDDEN.getCode() || code == ErrorCode.UNAUTHORIZED.getCode()) {
throw new SchemaAccessDeniedException(e);
}
last = e;
} catch (OpenApiException e) {
if (e.getHttpStatus() == 404) {
throw new SchemaResourceUnavailableException(e);
}
last = e;
} catch (RuntimeException e) {
last = e;
log.warn("[flow-center] form-center schema 精确版本拉取失败(第 {}/{} 次):uuid={} v={} {}",
attempt, MAX_ATTEMPTS, formUuid, version, e.toString());
} catch (Exception e) {
last = new IllegalStateException(e.toString(), e);
log.warn("[flow-center] form-center schema 精确版本拉取异常(第 {}/{} 次):uuid={} v={} {}",
attempt, MAX_ATTEMPTS, formUuid, version, e.toString());
}
}
throw new SchemaUnavailableException(
"关联表单 v" + version + " 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last);
}
/** 返回的 formUuid/formVersion 必须与请求一致(快照嵌进 def 版本行后,这就是当时的契约坐标) */
private static void requireCoordinate(Map<String, Object> snapshot, String formUuid, Integer version) {
Object gotUuid = snapshot.get("formUuid");
Object gotVersion = snapshot.get("formVersion");
Integer parsedVersion;
try {
parsedVersion = gotVersion == null ? null : Integer.valueOf(String.valueOf(gotVersion));
} catch (NumberFormatException e) {
throw new SchemaContractViolationException("表单中心返回的版本不是合法整数");
}
if (gotUuid == null || parsedVersion == null || parsedVersion < 1
|| !String.valueOf(gotUuid).equals(formUuid)
|| !parsedVersion.equals(version)) {
throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 "
+ formUuid + " v" + version + ",返回 " + gotUuid + " v" + gotVersion + ")");
}
}
/** SchemaView(data) → 快照节(fields 扁平化 + audience 数组归一) */
private Map<String, Object> toSnapshot(Map<String, Object> data) {
Map<String, Object> snap = new LinkedHashMap<>();
snap.put("formUuid", data.get("formUuid"));
snap.put("formVersion", data.get("version"));
snap.put("audience", normalizeAudience(data.get("audience")));
List<Map<String, Object>> fields = new ArrayList<>();
JsonNode groups = data.get("groups") == null ? null
: objectMapper.valueToTree(data.get("groups"));
if (groups != null && groups.isArray()) {
for (JsonNode group : groups) {
JsonNode groupFields = group.get("fields");
if (groupFields == null || !groupFields.isArray()) {
continue;
}
for (JsonNode f : groupFields) {
Map<String, Object> field = new LinkedHashMap<>();
field.put("key", f.path("key").asText(null));
field.put("type", f.path("type").asText(null));
JsonNode required = f.get("required");
field.put("required", required == null ? Boolean.FALSE : required.asBoolean(false));
JsonNode options = f.get("options");
if (options != null && options.isArray() && options.size() > 0) {
List<String> enums = new ArrayList<>();
for (JsonNode opt : options) {
JsonNode v = opt.has("value") ? opt.get("value") : opt.get("label");
if (v != null) {
enums.add(v.asText());
}
}
field.put("enum", enums);
}
fields.add(field);
}
}
}
snap.put("fields", fields);
return snap;
}
/** audience 数组归一:数组照读;旧单值串包一层(迁移窗口容错);null → 空数组 */
private List<String> normalizeAudience(Object audience) {
List<String> out = new ArrayList<>();
if (audience instanceof Iterable) {
for (Object a : (Iterable<?>) audience) {
if (a != null) {
out.add(String.valueOf(a));
}
}
} else if (audience != null) {
out.add(String.valueOf(audience));
}
return out;
}
}
@@ -12,6 +12,7 @@ import com.yunhai.flowcenter.engine.AssigneeResolver;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwCcRepository;
@@ -25,6 +26,7 @@ import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -90,6 +92,25 @@ public class OapiFlowService {
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
appIdentityProvider;
/**
* v2 发起的事务模板。<b>用 setter/字段注入而不是加进构造器</b>:本类构造被
* {@code OapiFlowApplyDefVersionTest}/{@code OapiFlowEmbedTaskUrlTest} 逐位钉住,
* 多一个参数就打不进来(同 svc-hall ServiceItemService 的 setter 先例)。
* 未装配(老单测直接 new)时 {@link #applyV2} 回落到"直接调用",事务语义由调用方的
* {@code @Transactional} 承担——那条路径只用于测试,不影响生产装配。
*/
private org.springframework.transaction.support.TransactionTemplate transactionTemplate;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setTransactionTemplate(org.springframework.transaction.support.TransactionTemplate template) {
this.transactionTemplate = template;
}
private FlowInstanceService.GuardedOutcome inTransaction(
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
}
/** 已发布流程列表(每条流程线取最高已发布版本) */
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
@@ -144,6 +165,111 @@ public class OapiFlowService {
return vo;
}
/**
* 带请求标识/轮次守卫的代发起({@code POST /oapi/flow/apply/v2},v2 §8,TASK_2 C.3)。
*
* <p>与 {@link #apply} 的三点不同,都是"幂等修复"要求而不是新功能:</p>
* <ol>
* <li><b>事务边界收窄到一次发起</b>:用 {@code TransactionTemplate} 而不是方法级
* {@code @Transactional}——唯一键冲突必须在<b>事务外</b>重新读既有实例
* (撞键后当前事务已被标记回滚,事务内再读不可靠),这与 svc-hall
* {@code AnswerService#executeSubmit} 同一形态;</li>
* <li><b>并发撞键后先核归属再返回</b>:读到的实例若属于另一应用/另一提交者/另一流程线,
* 一律拒绝,<b>不把别人的 bizType+bizId 当自己的结果</b>;</li>
* <li><b>回带稳定分支码</b>(CREATED/IDEMPOTENT/SAME_REQUEST/STALE_ROUND/REPLAY_ONLY/
* RESTARTED/CONCURRENT_CREATED),消费方按码处置,不再猜"duplicated 是哪一种重复"。</li>
* </ol>
*
* <p>已发布定义校验、提交人本校校验与旧路径同一条,不放宽;流程节点算法一行未改。</p>
*/
public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey,
FlowApplyRetryModels.ApplyRequestV2 body) {
if (schId == null || body == null || body.getFlowGuid() == null || body.getBizType() == null
|| body.getBizId() == null || body.getSubmitterId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
}
requireSchoolMember(schId, body.getSubmitterId());
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + body.getFlowGuid()));
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
+ ",当前已发布版本 " + def.getVersion() + ")");
}
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()));
FlowInstanceService.GuardedOutcome outcome;
try {
outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy));
} catch (DataIntegrityViolationException concurrent) {
// 事务边界外重读:另一个并发请求已用同一业务键建好办件
return fromConcurrent(schId, cmd, policy);
}
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(outcome.instanceId);
vo.setDuplicated(outcome.duplicated);
vo.setBranch(outcome.branch);
vo.setApplyRound(outcome.applyRound);
instanceRepository.findById(outcome.instanceId).ifPresent(current -> {
int currentRound = current.getApplyRound() == null ? 1 : current.getApplyRound();
int outcomeRound = outcome.applyRound == null ? 1 : outcome.applyRound;
if (currentRound == outcomeRound) {
vo.setStatus(current.getStatus());
vo.setVersion(current.getVersion());
} // 已被另一请求推进到新轮时,让消费方按实例查询恢复,不混装两轮证据。
});
return vo;
}
/**
* 并发撞键后的收口:重读既有实例 → <b>先核归属</b> → 同 token 记 {@code SAME_REQUEST},
* 否则按幂等命中回读。归属不符就拒绝,绝不返回别人的办件 id。
*/
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
FlowInstanceService.ApplyCommand cmd,
FlowInstanceService.ApplyRetryPolicy policy) {
FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId(
schId, cmd.bizType, cmd.bizId).orElse(null);
if (existing == null) {
throw new BizException(ErrorCode.CUSTOM, "办件创建冲突且读不到既有实例,请稍后重试");
}
if (existing.getApplyAppKey() != null && cmd.applyAppKey != null
&& !existing.getApplyAppKey().equals(cmd.applyAppKey)) {
throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
}
if (existing.getSubmitterId() != null && cmd.submitterId != null
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符");
}
String token = policy.requestToken == null ? null : policy.requestToken.trim();
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(existing.getId());
vo.setDuplicated(true);
vo.setApplyRound(existing.getApplyRound());
vo.setStatus(existing.getStatus());
vo.setVersion(existing.getVersion());
vo.setBranch(token != null && token.equals(existing.getApplyRequestToken())
? FlowApplyRetryModels.Branch.SAME_REQUEST : FlowApplyRetryModels.Branch.CONCURRENT_CREATED);
log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}",
existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch());
return vo;
}
/**
* 调用方 app_key → app_code 一次转换(域间边界:验签域→路由域,只在 apply 做这一次)。
* 查无/不可达降级 null——消息 jumpApp 空、归属回落发送方(现状行为),不阻断 apply;
@@ -349,6 +475,79 @@ public class OapiFlowService {
return toPageResult(p.getTotalElements(), items, page, size);
}
/**
* 按业务单据查当前人待办(v2.1 应用内审批的业务详情卡片数据源):
* 补齐 tasks/todo 缺的那一侧——todo 是"人 → 单据列表",本方法是"单据 → 我的待办",
* 子应用据此判断"当前人是否该办件的待办人",不必再拉全量待办在前端过滤。
* <p>治理口径:
* <ul>
* <li>只返回 {@code assigneeId=operatorId} 且 status=PENDING 的任务;他人待办与"办件不存在"
* 一律出空数组(HTTP 200),出参不区分二者,避免用本端点探测别人的办件;</li>
* <li>租户收敛在定位查询本身({@code findBySchIdAndBizTypeAndBizId}),bizType 不匹配即空;</li>
* <li>{@code outlets} 只为当前人的任务计算,且出边 &gt;1 才透出(与
* {@code FlowQueryService.detail} 的办理面板口径一致——单出口无需选择,不给前端多余分支);</li>
* <li>不透出他人办理人、意见或定义内部 id;taskId 是办理必需的运行时身份,保留。</li>
* </ul>
*/
public OapiModels.ByBizTasksVo tasksByBiz(Long schId, String bizType, String bizId, Integer operatorId) {
if (schId == null || schId <= 0 || operatorId == null || operatorId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/operatorId(均须为正)");
}
String cleanBizType = emptyToNull(bizType);
String cleanBizId = emptyToNull(bizId);
if (cleanBizType == null || cleanBizId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:bizType/bizId");
}
OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo();
vo.setBizType(cleanBizType);
vo.setBizId(cleanBizId);
FlwInstance instance = instanceRepository
.findBySchIdAndBizTypeAndBizId(schId, cleanBizType, cleanBizId).orElse(null);
if (instance == null) {
return vo;
}
vo.setInstanceId(instance.getId());
vo.setStatus(instance.getStatus());
vo.setCurrentNodes(instance.getCurrentNodes());
List<FlwTask> mine = taskRepository
.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode()).stream()
.filter(t -> operatorId.equals(t.getAssigneeId()))
.sorted(java.util.Comparator.comparing(FlwTask::getId))
.collect(java.util.stream.Collectors.toList());
if (mine.isEmpty()) {
return vo;
}
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
FlowDefModel model = def == null ? null : flowEngine.parseModel(def);
for (FlwTask t : mine) {
vo.getTasks().add(toByBizTaskVo(t, model));
}
return vo;
}
private OapiModels.ByBizTaskVo toByBizTaskVo(FlwTask t, FlowDefModel model) {
OapiModels.ByBizTaskVo vo = new OapiModels.ByBizTaskVo();
vo.setTaskId(t.getId());
vo.setNodeId(t.getNodeId());
vo.setNodeName(t.getNodeName());
vo.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString());
if (model == null) {
return vo;
}
List<FlowDefModel.Edge> outs = model.outEdges(t.getNodeId());
if (outs.size() > 1) {
for (FlowDefModel.Edge e : outs) {
FlowDefModel.Node to = model.node(e.to);
OapiModels.ByBizOutletVo outlet = new OapiModels.ByBizOutletVo();
outlet.setName(e.name);
outlet.setToNodeId(e.to);
outlet.setToNodeName(to == null || to.name == null || to.name.isEmpty() ? e.to : to.name);
vo.getOutlets().add(outlet);
}
}
return vo;
}
/**
* 同意(oapi,应用内审批):operatorId 由调用方应用按其登录态传入,本端按任务办理人
* 复核({@code task.assigneeId == operatorId},不满足→业务码拒绝,应用签名不替代"谁在办")。
@@ -924,6 +1123,8 @@ public class OapiFlowService {
public OapiModels.InstanceVo toInstanceVo(FlwInstance instance) {
OapiModels.InstanceVo vo = new OapiModels.InstanceVo();
vo.setInstanceId(instance.getId());
vo.setApplyRound(instance.getApplyRound());
vo.setRequestToken(instance.getApplyRequestToken());
vo.setStatus(instance.getStatus());
vo.setVersion(instance.getVersion());
vo.setBizTitle(instance.getBizTitle());
+2 -1
View File
@@ -74,7 +74,8 @@ yh:
# 绝不能写进本文件:yh.open.services.{key} 是目录第 2 级,平台注入的 base-host 只喂第 3 级兜底、
# 压不过它 —— 写进 jar 会让部署实例去连容器内的 localhost:8060(2026-09-18 事故:
# Connection refused → 所有 /api/* 报 code=6 内部服务错误)。
base-host: http://localhost:8060
# 统一内部网关门地址;本地 UserBase 直连使用显式 services.userbase 覆盖。
base-host: ${YH_OPEN_BASE_HOST:http://open-gateway-service:10013}
base-path: ""
# 服务目录(子应用配置优化方案 §4.2):只在需要覆盖 PlatformService 枚举默认 ctx 时按 key 配置;
# prod 形态(网关域名 + 默认 ctx)下四域零配置。本地联调所需的 userbase 覆盖见上方环境变量。
@@ -0,0 +1,30 @@
-- ============================================================
-- 流程中心 · 代发起请求标识与轮次 DDL(MySQL 5.7 兼容复核版,TASK_2 C.3)
-- 范围:《answer-delivery-v2.md》§8 = flw_instance 加两列 apply_request_token / apply_round
-- 依据:任务书 TASK_2 C.2/C.3——"补充 FL 请求标识/当前轮次守卫时,SH 发送与 FL 校验同任务完成"
-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_flowcenter,ddl-auto=update)由
-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威。
-- 这两列解决什么:biz uk(sch_id,biz_type,biz_id) 只说得出"这个业务键已经有办件了",
-- 说不出"这次 apply 是同一次命令的重投,还是新一轮授权重提"。缺这句时,消费方
-- 在响应丢失后按同业务键补发,命中 30/40 会被 R4 语义重启推进——等于把网络重试
-- 当成用户重提,把已被拒绝/已撤回的办件又跑起来(任务书 C.2 点名禁止)。
-- 存量口径(关键):**不回填、留 NULL**。apply_round 为 NULL 的行在新守卫里按"第 1 轮"处理,
-- 于是"请求轮次 ≤ 行轮次 ⇒ 陈旧轮次不重启"对存量件天然成立——存量行的守卫缺省是
-- <b>关</b>而不是开:想让老件走新一轮,必须由消费方显式带 attempt≥2(用户授权重提)。
-- 回填成 0 会反过来让任何 attempt=1 的重投都能重启存量件,那是最容易埋雷的写法。
-- 索引:不加 token 索引——守卫判据是"读到的那一行自己的列比对",没有按 token 反查的路径;
-- 建索引只会给写入加成本,换不到任何读收益(评审时别问"为什么不索引")。
-- 非幂等:执行前先 `SHOW COLUMNS FROM flw_instance LIKE 'apply_request_token'` 确认列不存在。
-- ============================================================
ALTER TABLE `flw_instance`
ADD COLUMN `apply_request_token` VARCHAR(64) DEFAULT NULL
COMMENT '最近一次受理代发起的请求标识(消费方持久化命令携带);同值重投=同一命令,只回读现状不重启推进',
ADD COLUMN `apply_round` INT DEFAULT NULL
COMMENT '当前发起轮次(1 起、单调);NULL=本列上线前的存量行,守卫按第 1 轮处理(缺省不放开重启)';
-- 自验(执行后逐条核对):
-- SHOW COLUMNS FROM flw_instance LIKE 'apply%'; -- 应只有 apply_app_key/apply_app_code/
-- -- apply_request_token/apply_round 四列
-- SELECT COUNT(*) FROM flw_instance WHERE apply_round IS NOT NULL; -- 刚执行完必须 0(不回填)
-- SELECT COUNT(*) FROM flw_instance WHERE apply_round = 0; -- 必须 0(0 会打开存量重启窗口)
@@ -0,0 +1,12 @@
-- flw_def 增加 form_version(与 form_uuid 成对的"确切版本"坐标)
-- 契约:form-center/service/docs/consumer-access-v3.md §4;依据 VERIFICATION V07 + 原骨架 T05 的确切版本读取部分
-- 业务:发布期按 (form_uuid, form_version) 调 GET /oapi/form/schema?uuid=&version= 取快照,
-- 中心返回的坐标与请求不符即<b>阻断发布</b>;已发布快照不改写,改关联=走新版本(既有升版分派)。
-- 可空口径:存量行(C1 第一刀只有关联、没有版本)留 NULL,发布时仍按"当前发布版"旧口径取快照,
-- 行为逐字不变;本次改造后新保存/新关联一律成对写入。NULL 不代表"任意版本",只代表"这条行是本列之前存的"。
-- 取消关联=两列同时清空(单传一个在保存链路即拒,见 FlowDefAdminService#save)。
-- 本地/测试 ddl-auto=update 会自动加列;生产执行本脚本。
-- MySQL 5.7:无 ADD COLUMN IF NOT EXISTS(重复执行报 Duplicate column name,需在外部判重)。
ALTER TABLE `flw_def`
ADD COLUMN `form_version` INT NULL COMMENT '关联表单的确切版本(与 form_uuid 成对;发布期按此核验快照坐标)' AFTER `form_uuid`;
@@ -0,0 +1,108 @@
package com.yunhai.flowcenter.oapi;
import com.yunhai.flowcenter.controller.oapi.OapiFlowController;
import com.yunhai.flowcenter.model.FlowModels;
import com.yunhai.flowcenter.service.FlowQueryService;
import com.yunhai.flowcenter.service.FlowRouterAdminService;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.platform.utils.model.BasePageResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Collections;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* P3.5 新增 oapi 端点(/oapi/flow/tasks/done、/oapi/flow/cc)的控制器契约测试(standalone,无 DB)。
* 核心断言=<b>过滤接缝</b>(P2-02 裁决):过滤键是验签身份 appKey(请求属性 yh.app.auth →
* queryService 第三参,落 d.appKey 过滤),<b>不收调用方 bizType</b>——bizType 参数即使传了
* 也不参与过滤(防裸透传/跨应用探查)。数据正确性由既有 pageDone/pageCc 的 IT 覆盖,
* 本类只钉 HTTP 契约与接缝。
*/
@DisplayName("P3.5 oapi done/cc - 签名 appKey 过滤接缝契约")
class OapiFlowP35DoneCcContractTest {
private FlowQueryService queryService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
OapiFlowService flowService = mock(OapiFlowService.class);
FlowRouterAdminService routerAdminService = mock(FlowRouterAdminService.class);
queryService = mock(FlowQueryService.class);
OapiFlowController controller = new OapiFlowController(flowService, routerAdminService, queryService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
when(queryService.pageDone(any(), any(), any(), any(), any()))
.thenReturn(emptyPage());
when(queryService.pageCc(any(), any(), any(), any(), any()))
.thenReturn(new BasePageResult<>(Collections.emptyList(), 0, 1, 10, 0, false));
}
private static BasePageResult<FlowModels.TaskItem> emptyPage() {
return new BasePageResult<>(Collections.emptyList(), 0, 1, 10, 0, false);
}
@Test
@DisplayName("done:验签 appKey 作为过滤键下传(d.appKey 接缝),分页参数透传")
void donePassesSigningAppKeyAsFilter() throws Exception {
mockMvc.perform(get("/oapi/flow/tasks/done")
.param("schId", "3").param("assigneeId", "10006")
.param("page", "2").param("size", "10")
.requestAttr("yh.app.auth", "100008"))
.andExpect(status().isOk());
verify(queryService).pageDone(eq(3L), eq(10006), eq("100008"), eq(2), eq(10));
}
@Test
@DisplayName("done:调用方带 bizType 也不参与过滤(防裸透传)")
void doneIgnoresCallerBizType() throws Exception {
mockMvc.perform(get("/oapi/flow/tasks/done")
.param("schId", "3").param("assigneeId", "10006")
.param("bizType", "svc-hall")
.requestAttr("yh.app.auth", "100008"))
.andExpect(status().isOk());
// 第三参(过滤键)必须是签名 appKey,不是 bizType
verify(queryService).pageDone(eq(3L), eq(10006), eq("100008"), Mockito.isNull(), Mockito.isNull());
}
@Test
@DisplayName("done:缺 schId/assigneeId → 400,零查询")
void doneMissingRequiredParams() throws Exception {
mockMvc.perform(get("/oapi/flow/tasks/done").param("schId", "3"))
.andExpect(status().is4xxClientError());
verify(queryService, never()).pageDone(any(), any(), any(), any(), any());
}
@Test
@DisplayName("cc:验签 appKey 过滤键 + userId 维度")
void ccPassesSigningAppKeyAsFilter() throws Exception {
mockMvc.perform(get("/oapi/flow/cc")
.param("schId", "3").param("userId", "10006")
.param("page", "1").param("size", "20")
.requestAttr("yh.app.auth", "100008"))
.andExpect(status().isOk());
verify(queryService).pageCc(eq(3L), eq(10006), eq("100008"), eq(1), eq(20));
}
@Test
@DisplayName("cc:缺 userId → 400,零查询")
void ccMissingUserId() throws Exception {
mockMvc.perform(get("/oapi/flow/cc").param("schId", "3"))
.andExpect(status().is4xxClientError());
verify(queryService, never()).pageCc(any(), any(), any(), any(), any());
}
}
@@ -0,0 +1,272 @@
package com.yunhai.flowcenter.oapi;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.flowcenter.support.FlowTestSupport;
import com.yunhai.flowcenter.support.OapiSignedClient;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* GET /oapi/flow/tasks/by-biz 契约(v2.1 应用内审批·业务详情卡片数据源)。
* 核心是 0/1/N 三种计数语义与"不探测他人办件":办件不存在、错校、错 bizType、他人待办一律
* HTTP 200 + tasks=[],出参不区分(区分即等于告诉调用方"这个单据上有个待办,只是不是你的")。
* 注意:本类必须单类独跑(平台 IT 判据)。
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class OapiFlowTasksByBizTest extends FlowTestSupport {
private static final String APP_KEY = "100018";
private static final String APP_SECRET = "svc-hall-dev-secret-100018";
private static final String BIZ = "notice";
@LocalServerPort
private int port;
@Autowired
private OapiFlowService flowService;
private OapiSignedClient client;
@Autowired
private javax.sql.DataSource dataSource;
@TestConfiguration
static class StubConfig {
@Bean
@Primary
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
return new com.yunhai.flowcenter.stub.StubUserClient();
}
}
@BeforeEach
void setUp() throws Exception {
// 在 cleanDb 之前验证真实连接,不能只相信环境变量已覆盖 profile。
try (java.sql.Connection connection = dataSource.getConnection()) {
String schema = connection.getCatalog();
String host = java.net.URI.create(connection.getMetaData().getURL().substring(5)).getHost();
System.out.println("CI_DB_TARGET=" + host + "/" + schema);
assertTrue("127.0.0.1".equals(host) || "localhost".equals(host)
|| (host != null && host.startsWith("mavenbuild-") && host.endsWith("-db")),
"真库门禁只允许本机或本次 CI 容器");
assertTrue("bsch_flowcenter_test".equals(schema) || "flowcenter_ci".equals(schema)
|| (schema != null && schema.startsWith("flowcenter_ci_")), "拒绝清理非测试 schema");
if (System.getenv("CI_TEST_DB_URL") != null) {
assertTrue(schema.startsWith("flowcenter_ci"), "CI 必须实际连接独立 schema");
}
}
cleanDb();
client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
}
/** 代发起一件并回带 instanceId(bizId 唯一,供 by-biz 定位) */
private Long applyOne(FlwDef def, String bizId, String title) {
ResponseEntity<JsonNode> resp = client.post("/oapi/flow/apply?schId=1", "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"" + BIZ + "\",\"bizId\":\"" + bizId + "\",\"bizTitle\":\"" + title + "\","
+ "\"submitterId\":201,\"submitterName\":\"张三\"}", JsonNode.class);
assertEquals(0, resp.getBody().get("code").asInt(), "代发起应成功:" + resp.getBody());
return resp.getBody().get("data").get("instanceId").asLong();
}
private JsonNode byBiz(String bizId, int operatorId) {
return byBiz(SCH, BIZ, bizId, operatorId);
}
private JsonNode byBiz(long schId, String bizType, String bizId, int operatorId) {
ResponseEntity<JsonNode> resp = client.get("/oapi/flow/tasks/by-biz?schId=" + schId
+ "&bizType=" + bizType + "&bizId=" + bizId + "&operatorId=" + operatorId, JsonNode.class);
assertEquals(200, resp.getStatusCodeValue(), "by-biz 恒 HTTP 200(空结果不报错)");
assertEquals(0, resp.getBody().get("code").asInt(), "业务码须为成功:" + resp.getBody());
return resp.getBody().get("data");
}
/** 签名强制:无签名头 401(与其余 /oapi 端点同纪律) */
@Test
void requiresAppSignature_401() {
assertEquals(401, client.getRaw(
"/oapi/flow/tasks/by-biz?schId=1&bizType=notice&bizId=x&operatorId=101").getStatusCodeValue());
}
/** 缺参/非正数 → 业务码拒绝(身份参数不允许"可选"降级成越权查询) */
@Test
void badParams_rejected() {
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=1&bizType=notice&bizId=x",
JsonNode.class).getBody(), "缺 operatorId 应拒绝");
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=1&bizId=x&operatorId=101",
JsonNode.class).getBody(), "缺 bizType 应拒绝");
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=0&bizType=notice&bizId=x&operatorId=101",
JsonNode.class).getBody(), "schId<=0 应拒绝");
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=1&bizType=notice&bizId=x&operatorId=0",
JsonNode.class).getBody(), "operatorId<=0 应拒绝");
}
/** 0 条·无办件:instanceId/status/currentNodes 均 null,tasks 空数组(不是 null,前端不必判空指针) */
@Test
void noInstance_empty() {
mapDept("ybb", 101, "甲");
saveAndPublishDef("bb1", "无办件", linearDef("ybb", "审核"));
JsonNode data = byBiz("ghost-1", 101);
assertTrue(data.get("instanceId").isNull(), "无办件 instanceId 须 null");
assertTrue(data.get("status").isNull(), "无办件 status 须 null");
assertTrue(data.get("currentNodes").isNull(), "无办件 currentNodes 须 null");
assertEquals(0, data.get("tasks").size());
assertEquals("notice", data.get("bizType").asText(), "入参 bizType 原样回显");
assertEquals("ghost-1", data.get("bizId").asText(), "入参 bizId 原样回显");
}
/** 0 条·办件存在但本人无待办(已办结):仍 200 + 空,且带出办件状态供卡片渲染终态 */
@Test
void finishedInstance_emptyWithStatus() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("bb2", "已办结", linearDef("ybb", "审核"));
Long instanceId = applyOne(def, "done-1", "已办结通知");
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
assertEquals(0, client.post("/oapi/flow/task/" + taskId
+ "/approve?schId=1&operatorId=101&operatorName=甲", "{}", JsonNode.class)
.getBody().get("code").asInt(), "办理人同意应成功");
JsonNode data = byBiz("done-1", 101);
assertEquals(0, data.get("tasks").size(), "办结后本人无待办");
assertEquals(20, data.get("status").asInt(), "终态可展示:回带办件状态");
assertEquals(instanceId, data.get("instanceId").asLong());
}
/** 1 条·单出口:outlets 空数组(与 FlowQueryService.detail 同口径,单出口不给前端多余分支) */
@Test
void oneTask_singleOutlet_noOutlets() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("bb3", "单出口", linearDef("ybb", "审核"));
Long instanceId = applyOne(def, "one-1", "单出口通知");
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
JsonNode data = byBiz("one-1", 101);
assertEquals(1, data.get("tasks").size());
JsonNode task = data.get("tasks").get(0);
assertEquals(taskId, task.get("taskId").asLong());
assertEquals("n1", task.get("nodeId").asText());
assertEquals("审核", task.get("nodeName").asText());
assertNotNull(task.get("arriveTime").asText());
assertEquals(0, task.get("outlets").size(), "单出口不透出 outlets");
assertEquals(10, data.get("status").asInt(), "审批中");
// currentNodes 是办件上的节点 **id** 逗号串(与 /oapi/flow/instances、progress 同一原文口径,
// 本端点不做名字化——改名会让同名字段在两个 oapi 里语义分叉)。人读节点名取 tasks[].nodeName。
assertEquals("n1", data.get("currentNodes").asText());
}
/** 1 条·多出口:outlets 逐字给出 name/toNodeId/toNodeName,供卡片"通过"弹窗选出口 */
@Test
void oneTask_multiOutlet_exposesOutlets() {
mapDept("ybb", 101, "甲");
mapDept("kb", 102, "乙");
FlwDef def = saveAndPublishDef("bb4", "多出口", multiOutletDef("ybb", "kb"));
Long instanceId = applyOne(def, "mo-1", "多出口通知");
JsonNode data = byBiz("mo-1", 101);
assertEquals(instanceId, data.get("instanceId").asLong(), "多出口件同样回带办件定位");
JsonNode task = data.get("tasks").get(0);
assertEquals("入口审核", task.get("nodeName").asText());
JsonNode outlets = task.get("outlets");
assertEquals(2, outlets.size(), "n1 出边 2 条须全部透出");
assertEquals("转乙科", outlets.get(0).get("name").asText());
assertEquals("n2", outlets.get(0).get("toNodeId").asText());
assertEquals("复核", outlets.get(0).get("toNodeName").asText());
assertEquals("通过", outlets.get(1).get("name").asText());
assertEquals("end", outlets.get(1).get("toNodeId").asText());
}
/** N 条:并行两支都指派同一人 → tasks 两条、按 taskId 升序;卡片据此引导列表/任务页,不猜选 */
@Test
void multiTask_returnsAllSorted() {
mapDept("ka", 101, "甲");
mapDept("kb", 101, "甲");
FlwDef def = saveAndPublishDef("bb5", "并行同人", parallelDef("ka", "kb"));
Long instanceId = applyOne(def, "mt-1", "多待办通知");
List<Long> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0)
.stream().map(com.yunhai.flowcenter.entity.FlwTask::getId)
.collect(java.util.stream.Collectors.toList());
assertEquals(2, pending.size(), "前置数据:本人两条并行待办");
JsonNode data = byBiz("mt-1", 101);
JsonNode tasks = data.get("tasks");
assertEquals(2, tasks.size(), "N 条全返回,不合并、不猜选");
long first = tasks.get(0).get("taskId").asLong();
long second = tasks.get(1).get("taskId").asLong();
assertTrue(first < second, "须按 taskId 升序:" + first + " -> " + second);
}
/** 他人待办 → 空(关键防探测:并行两支分属两人时,非办理人查同一 bizId 得到 tasks=[]) */
@Test
void otherAssignee_empty() {
mapDept("ka", 101, "甲");
mapDept("kb", 102, "乙");
FlwDef def = saveAndPublishDef("bb6", "并行他人", parallelDef("ka", "kb"));
Long instanceId = applyOne(def, "ot-1", "他人待办通知");
assertEquals(2, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(),
"前置数据:两条待办分属 101/102");
JsonNode asOutsider = byBiz("ot-1", 999);
assertEquals(0, asOutsider.get("tasks").size(), "非办理人 → 空");
assertNotNull(asOutsider.get("instanceId"), "但办件定位字段照常回带");
assertEquals(1, byBiz("ot-1", 101).get("tasks").size(), "办理人本人查得到");
// 出参不得泄露他人办理人
assertFalse(byBiz("ot-1", 101).toString().contains("102"), "不得透出他人 assigneeId");
assertFalse(byBiz("ot-1", 101).toString().contains("乙"), "不得透出他人姓名");
}
/** 错学校 / 错 bizType → 空(租户与业务类型收敛在定位查询本身) */
@Test
void wrongSchoolOrBizType_empty() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("bb7", "跨租户", linearDef("ybb", "审核"));
applyOne(def, "ws-1", "跨租户通知");
JsonNode otherSchool = byBiz(2L, BIZ, "ws-1", 101);
assertEquals(0, otherSchool.get("tasks").size(), "错学校 → 空");
assertTrue(otherSchool.get("instanceId").isNull(), "错学校不得回带 instanceId");
JsonNode otherType = byBiz(SCH, "svc_item", "ws-1", 101);
assertEquals(0, otherType.get("tasks").size(), "错 bizType → 空");
assertTrue(otherType.get("instanceId").isNull(), "错 bizType 不得回带 instanceId");
}
/** 定义缺失(def 行被清理)→ 任务照常返回,outlets 降级空数组,不 500 */
@Test
void missingDef_degradesWithoutOutlets() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("bb8", "定义缺失", linearDef("ybb", "审核"));
Long instanceId = applyOne(def, "md-1", "定义缺失通知");
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
defRepository.delete(def);
JsonNode task = byBiz("md-1", 101).get("tasks").get(0);
assertEquals(0, task.get("outlets").size(), "无定义时 outlet 计算降级空");
assertEquals(taskId, task.get("taskId").asLong(), "任务本身照常返回");
}
/** 服务层直调:空白 bizId 拒绝(URL 上 bizId= 空串不得退化成"查全部") */
@Test
void service_blankBizParams_rejected() {
assertThrows(BizException.class, () -> flowService.tasksByBiz(1L, "notice", " ", 101));
assertThrows(BizException.class, () -> flowService.tasksByBiz(1L, null, "1", 101));
assertThrows(BizException.class, () -> flowService.tasksByBiz(null, "notice", "1", 101));
}
private static void assertNotEqualsZero(JsonNode body, String message) {
org.junit.jupiter.api.Assertions.assertNotEquals(Integer.valueOf(0),
Integer.valueOf(body.get("code").asInt()), message);
}
}
@@ -0,0 +1,41 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import com.yunhai.platform.openapi.core.*;
import org.junit.jupiter.api.Test;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AppCodeFormCenterRoutingTest {
@Test
void realSdkNeedsOnlyGatewayForCatalogAndExactSchema() throws Exception {
List<String> paths = new ArrayList<>();
HttpServer gateway = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
gateway.createContext("/", exchange -> {
String path = exchange.getRequestURI().getPath(); paths.add(path);
String data = path.endsWith("/schema")
? "{\"formUuid\":\"test-form\",\"version\":3,\"groups\":[],\"audience\":[]}"
: "{\"items\":[],\"total\":0}";
byte[] bytes = ("{\"code\":0,\"data\":" + data + "}").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes); exchange.close();
});
gateway.start();
try {
OpenApiProperties open = new OpenApiProperties();
open.setBaseHost("http://127.0.0.1:" + gateway.getAddress().getPort());
YschOpenCall call = new YschOpenCall(new YschCallProperties(), open, new ObjectMapper(), null);
assertFalse(call.hasTarget("form-center"));
assertEquals(0, new FormCenterCatalogClient(call).listForms(1L, null, null, 1, 20).getTotal());
assertEquals(3, new FormCenterSchemaClient(call, new ObjectMapper())
.fetchSchema(1L, "test-form", 3).get("formVersion"));
assertEquals(java.util.Arrays.asList("/form-center/oapi/form/forms", "/form-center/oapi/form/schema"), paths);
} finally {
gateway.stop(0);
}
}
}
@@ -0,0 +1,363 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.repository.FlwBranchRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 代发起幂等守卫与恢复分支的单测(answer-delivery-v2 §8,TASK_2 C.2/C.3)。
*
* <p>要证的正是任务书点名的那一类事故:<b>消费方响应丢失后按同业务键补发,绝不能被中心
* 当成"用户又重提了一次"</b>。旧形状下 biz uk 命中 30/40 就走 R4 重启推进,把已拒绝/已撤回
* 的办件重新跑起来;新分支用 {@code requestToken}/{@code attempt}/{@code retryOnly}
* 把"同一次命令的重投"与"新一轮授权重提"分成两个事实。</p>
*
* <p>同时钉住三条边界:</p>
* <ul>
* <li>并发唯一键冲突在<b>事务边界外</b>重读,且先核调用应用/提交者/流程线——
* 别的应用相同 bizType 键<b>不作为自己的结果</b>返回;</li>
* <li>存量行({@code apply_round} 为 NULL)按第 1 轮处理,<b>守卫缺省是关</b>:
* 没带更高轮次就重启不了别人的老件;</li>
* <li>既有 R4 重提语义与流程节点算法一行未改({@code RESTARTED} 分支才走 restart)。</li>
* </ul>
*/
@DisplayName("代发起请求标识与轮次守卫 - FL 侧规则单测(TASK_2)")
class FlApplyRecoveryTest {
private static final long SCH = 1L;
private static final String BIZ_TYPE = "svc-hall";
private static final String BIZ_ID = "42";
private static final String FLOW_GUID = "fg-1";
private static final String TOKEN_A = "tok-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private static final String TOKEN_B = "tok-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
private final ObjectMapper mapper = new ObjectMapper();
private FlwInstanceRepository instanceRepository;
private FlwDefRepository defRepository;
private FlwTaskRepository taskRepository;
private FlwBranchRepository branchRepository;
private FlowEngine flowEngine;
private ApplicationEventPublisher eventPublisher;
private FlowInstanceService service;
@BeforeEach
void setUp() {
instanceRepository = mock(FlwInstanceRepository.class);
defRepository = mock(FlwDefRepository.class);
taskRepository = mock(FlwTaskRepository.class);
branchRepository = mock(FlwBranchRepository.class);
flowEngine = mock(FlowEngine.class);
eventPublisher = mock(ApplicationEventPublisher.class);
service = new FlowInstanceService(instanceRepository, defRepository, taskRepository,
branchRepository, flowEngine, eventPublisher);
FlwDef def = def(1L, FLOW_GUID, 3);
when(defRepository.findById(anyLong())).thenReturn(Optional.of(def));
FlowDefModel model = FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}");
when(flowEngine.parseModel(any())).thenReturn(model);
when(instanceRepository.saveAndFlush(any())).thenAnswer(call -> {
FlwInstance row = call.getArgument(0);
row.setId(900L);
return row;
});
}
// ==================== 同一次命令的重投 ====================
@Test
@DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进")
void sameTokenOnRejectedInstanceDoesNotRestart() {
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(existing));
FlowInstanceService.GuardedOutcome outcome =
service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
assertThat(outcome.duplicated).isTrue();
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
verify(flowEngine, never()).startAdvance(any(), any());
verify(branchRepository, never()).deleteByInstance(anyLong());
}
@Test
@DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST")
void sameTokenOnApprovingInstanceIsSameRequest() {
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(existing));
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
.isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
verify(flowEngine, never()).startAdvance(any(), any());
}
@Test
@DisplayName("retryOnly 命中终态(40):恢复链补发也不重启(C.2 点名不把 30/40 当网络重试)")
void retryOnlyNeverRestartsTerminal() {
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.WITHDRAWN.getCode(), TOKEN_A, 1, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(existing));
FlowInstanceService.GuardedOutcome outcome =
service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, true));
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.REPLAY_ONLY);
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.WITHDRAWN.getCode());
verify(flowEngine, never()).startAdvance(any(), any());
}
@Test
@DisplayName("10/20 命中不同 token:仍是纯幂等返回(既有语义逐字不变)")
void approvingOrCompletedStayIdempotent() {
FlwInstance approving = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(approving));
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
.isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
FlwInstance completed = instance(900L, FlwEnums.InstanceStatus.COMPLETED.getCode(), TOKEN_A, 1, "100018");
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
.isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
verify(flowEngine, never()).startAdvance(any(), any());
}
// ==================== 轮次守卫 ====================
@Test
@DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进")
void higherRoundRestartsPerExistingR4Semantics() {
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(existing));
FlowInstanceService.GuardedOutcome outcome =
service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false));
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.RESTARTED);
assertThat(outcome.applyRound).isEqualTo(2);
assertThat(existing.getApplyRequestToken()).isEqualTo(TOKEN_B);
assertThat(existing.getApplyRound()).isEqualTo(2);
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
verify(flowEngine).startAdvance(any(), any());
verify(branchRepository).deleteByInstance(900L);
verify(taskRepository).invalidatePendingByInstance(900L);
}
@Test
@DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启")
void staleRoundIsRejectedForRestart() {
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(existing));
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
.isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND);
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
verify(flowEngine, never()).startAdvance(any(), any());
}
@Test
@DisplayName("存量行(apply_round 为 NULL)按第 1 轮处理:attempt=1 重投重启不了它")
void legacyRowWithoutRoundDefaultsToGuarded() {
FlwInstance legacy = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(legacy));
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch)
.isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND);
assertThat(legacy.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
.isEqualTo(FlowApplyRetryModels.Branch.RESTARTED);
}
// ==================== 归属复核 ====================
@Test
@DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果")
void otherApplicationSameBizKeyIsRefused() {
FlwInstance foreign = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100020");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(foreign));
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
.isInstanceOf(BizException.class).hasMessageContaining("另一调用应用");
}
@Test
@DisplayName("提交者不符 / 流程线不同:同样拒绝(守卫不是可协商的提示)")
void submitterAndFlowLineMustMatch() {
FlwInstance otherSubmitter = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
TOKEN_A, 1, "100018");
otherSubmitter.setSubmitterId(2002);
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(otherSubmitter));
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
.isInstanceOf(BizException.class).hasMessageContaining("提交者");
FlwInstance otherFlow = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
TOKEN_A, 1, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(otherFlow));
when(defRepository.findById(anyLong())).thenReturn(Optional.of(def(2L, "fg-other", 1)));
assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
.isInstanceOf(BizException.class).hasMessageContaining("流程线");
}
// ==================== 新建与并发 ====================
@Test
@DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)")
void createsWhenAbsentAndStampsIdentity() {
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.<FlwInstance>empty());
FlowInstanceService.GuardedOutcome outcome =
service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.CREATED);
assertThat(outcome.duplicated).isFalse();
ArgumentCaptor<FlwInstance> saved = ArgumentCaptor.forClass(FlwInstance.class);
verify(instanceRepository).saveAndFlush(saved.capture());
assertThat(saved.getValue().getApplyRequestToken()).isEqualTo(TOKEN_A);
assertThat(saved.getValue().getApplyRound()).isEqualTo(1);
assertThat(saved.getValue().getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
verify(flowEngine).startAdvance(any(), any());
}
@Test
@DisplayName("并发唯一键撞键:applyV2 在事务外重读既有行并核归属,返回稳定分支码而不是 500")
void concurrentUkCollisionIsResolvedOutsideTransaction() {
// 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.<FlwInstance>empty())
.thenReturn(Optional.of(existing));
assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
.isEqualTo(FlowApplyRetryModels.Branch.CREATED);
// 第二次同 token:读到了并发对手刚建的行 → SAME_REQUEST(不重启、不重复建)
FlowInstanceService.GuardedOutcome second =
service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
assertThat(second.branch).isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
assertThat(second.instanceId).isEqualTo(900L);
}
@Test
@DisplayName("旧 apply(cmd) 路径逐字不变:不写 token/轮次,30/40 仍按 R4 重启(存量兼容)")
void legacyApplyPathIsUnchanged() {
FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
.thenReturn(Optional.of(existing));
FlowInstanceService.ApplyOutcome outcome = service.apply(cmd(null));
assertThat(outcome.isDuplicated()).isTrue();
assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
assertThat(existing.getApplyRequestToken()).isNull();
assertThat(existing.getApplyRound()).isNull();
}
@Test
void callbackIdentityIsFrozenBeforeCommitAndNeverRelabelsOldEvent() throws Exception {
FlwInstance row = instance(900L, 30, TOKEN_A, 1, "100018");
row.setVersion(3);
when(instanceRepository.findById(900L)).thenReturn(Optional.of(row));
com.yunhai.flowcenter.engine.FlowEvent event = com.yunhai.flowcenter.engine.FlowEvent.of(
com.yunhai.flowcenter.engine.FlowEvent.Type.REJECTED, 900L);
new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event);
row.setApplyRound(2); row.setApplyRequestToken(TOKEN_B); row.setVersion(4);
new com.yunhai.flowcenter.callback.FlowEventIdentityListener(instanceRepository).capture(event);
assertThat(event.getRoundIdentity().get("applyRound")).isEqualTo(1);
com.yunhai.flowcenter.repository.FlwCallbackRegRepository regs = mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class);
com.yunhai.flowcenter.callback.CallbackLogStore store = mock(com.yunhai.flowcenter.callback.CallbackLogStore.class);
com.yunhai.flowcenter.entity.FlwCallbackReg reg = new com.yunhai.flowcenter.entity.FlwCallbackReg();
reg.setId(7L);
when(regs.findBySchIdAndFlowGuidAndStatus(SCH, FLOW_GUID, 0)).thenReturn(java.util.Collections.singletonList(reg));
com.yunhai.flowcenter.callback.CallbackDispatcher dispatcher = new com.yunhai.flowcenter.callback.CallbackDispatcher(
instanceRepository, defRepository, regs, mock(com.yunhai.flowcenter.repository.FlwCallbackLogRepository.class),
store, mock(com.yunhai.flowcenter.callback.CallbackPushService.class), new com.yunhai.flowcenter.callback.CallbackProperties(), mapper);
org.springframework.test.util.ReflectionTestUtils.setField(dispatcher, "pushExecutor", mock(java.util.concurrent.ExecutorService.class));
dispatcher.onFlowEvent(event);
ArgumentCaptor<com.yunhai.flowcenter.entity.FlwCallbackLog> log = ArgumentCaptor.forClass(com.yunhai.flowcenter.entity.FlwCallbackLog.class);
verify(store).persist(log.capture());
com.fasterxml.jackson.databind.JsonNode data = mapper.readTree(log.getValue().getPayloadJson()).get("data");
assertThat(data.get("applyRound").asInt()).isEqualTo(1);
assertThat(data.get("requestToken").asText()).isEqualTo(TOKEN_A);
}
// ==================== 夹具 ====================
/** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */
private FlowInstanceService.ApplyCommand cmd(String token) {
return new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID,
"张三的测试事项", "/svc-hall/apply-detail?id=42", "{}", 1001,
"张三", "{}", "100018", "svc-hall", null);
}
private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) {
return new FlowInstanceService.ApplyRetryPolicy(token, attempt, retryOnly);
}
private FlwDef def(Long id, String flowGuid, int version) {
FlwDef def = new FlwDef();
def.setId(id);
def.setSchId(SCH);
def.setFlowGuid(flowGuid);
def.setCode("leave");
def.setName("请假");
def.setVersion(version);
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setContentJson("{\"nodes\":[],\"edges\":[]}");
return def;
}
private FlwInstance instance(Long id, int status, String token, Integer round, String appKey) {
FlwInstance instance = new FlwInstance();
instance.setId(id);
instance.setSchId(SCH);
instance.setDefId(1L);
instance.setDefCode("leave");
instance.setDefVersion(3);
instance.setBizType(BIZ_TYPE);
instance.setBizId(BIZ_ID);
instance.setStatus(status);
instance.setApplyAppKey(appKey);
instance.setApplyRequestToken(token);
instance.setApplyRound(round);
instance.setSubmitterId(1001);
instance.setSubmitterName("张三");
instance.setVersion(0);
return instance;
}
}
@@ -0,0 +1,426 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwRouterRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.core.YschOpenCall;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 流程定义的表单中心绑定单测(v3/TASK_1)。
*
* <p>三组判据:</p>
* <ol>
* <li><b>成对</b>:{@code formUuid} 与 {@code formVersion} 同传或都不传;只传一个即拒;
* 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);</li>
* <li><b>发布取确切版本</b>:带版本的关联走 {@code schema?uuid=&version=};
* <b>中心返回的坐标与请求不符即阻断发布</b>,而不是把错的快照嵌进 def 版本行;
* 存量 UUID-only 行发布与启用同样阻断,必须重新绑定明确版本;</li>
* <li><b>目录客户端的调用形态</b>:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
* 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。</li>
* </ol>
*
* <p>发布与启用按同一错误码分类;表达式三查测试同时使用明确版本。</p>
*/
@DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)")
class FlFormBindingTest {
private static final Long SCH = 27L;
private static final String SIMPLE_JSON = "{\"nodes\":[],\"edges\":[]}";
/** 含 EXPRESSION 判断的合法定义:days/grade 是表单字段,schId 是内置变量(与发布契约测试同形) */
private static final String FLOW_JSON = "{\"start\":\"s\",\"nodes\":["
+ "{\"id\":\"s\",\"type\":\"START\"},"
+ "{\"id\":\"c\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
+ "{\"id\":\"a\",\"type\":\"APPROVAL\",\"name\":\"审1\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[1]}},"
+ "{\"id\":\"e\",\"type\":\"END\"}],"
+ "\"edges\":["
+ "{\"from\":\"s\",\"to\":\"c\"},"
+ "{\"from\":\"c\",\"to\":\"a\",\"name\":\"准\",\"condition\":\"days > 3 && schId > 0\"},"
+ "{\"from\":\"c\",\"to\":\"e\",\"name\":\"否\",\"condition\":\"grade == 'g1'\"},"
+ "{\"from\":\"a\",\"to\":\"e\"}]}";
private FlwDefRepository defRepository;
private com.yunhai.flowcenter.repository.FlwAppRepository appRepository;
private FormCenterSchemaClient schemaClient;
private FlowDefAdminService service;
@BeforeEach
void setUp() {
defRepository = mock(FlwDefRepository.class);
appRepository = mock(com.yunhai.flowcenter.repository.FlwAppRepository.class);
schemaClient = mock(FormCenterSchemaClient.class);
service = new FlowDefAdminService(defRepository,
mock(FlwRouterRepository.class),
mock(FlwCallbackRegRepository.class),
appRepository,
mock(FlwInstanceRepository.class),
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper(),
schemaClient);
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// 归属应用白名单:一律放行(拦截语义由既有专门用例覆盖,本类只管表单坐标)
when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
}
private static FlwDef draft(String formUuid, Integer formVersion) {
FlwDef d = new FlwDef();
d.setId(9L);
d.setSchId(SCH);
d.setFlowGuid("G");
d.setCode("work-leave");
d.setVersion(1);
d.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
d.setAppKey("notice");
d.setContentJson(FLOW_JSON);
d.setFormUuid(formUuid);
d.setFormVersion(formVersion);
return d;
}
/** 快照(formUuid=FU1 / v3),字段够表达式键查用 */
private static Map<String, Object> snapshot(String uuid, int version) {
Map<String, Object> snap = new LinkedHashMap<>();
snap.put("formUuid", uuid);
snap.put("formVersion", version);
snap.put("audience", Arrays.asList("STUDENT"));
snap.put("fields", Arrays.asList(field("days", "number"), field("grade", "select")));
return snap;
}
private static Map<String, Object> field(String key, String type) {
Map<String, Object> f = new LinkedHashMap<>();
f.put("key", key);
f.put("type", type);
f.put("required", Boolean.FALSE);
return f;
}
// ==================== ① 成对规则 ====================
@Test
@DisplayName("成对提交:uuid 与 version 一起落行")
void pairIsSavedTogether() {
when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
null, null, null, "FU1", 3);
assertEquals("FU1", saved.getFormUuid());
assertEquals(Integer.valueOf(3), saved.getFormVersion());
}
@Test
@DisplayName("只传 uuid 或只传 version:拒,且不落库")
void halfCoordinateIsRejected() {
when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
BizException uuidOnly = assertThrows(BizException.class, () -> service.save(SCH, 9L,
"work-leave", "请假", SIMPLE_JSON, null, null, null, "FU1", null));
assertTrue(uuidOnly.getMessage().contains("必须成对提交"), uuidOnly.getMessage());
BizException versionOnly = assertThrows(BizException.class, () -> service.save(SCH, 9L,
"work-leave", "请假", SIMPLE_JSON, null, null, null, null, 4));
assertTrue(versionOnly.getMessage().contains("必须成对提交"), versionOnly.getMessage());
// 半个坐标在保存分派之前就被拦下:这一行一次都没写库
verify(defRepository, never()).save(any());
}
@Test
@DisplayName("取消关联:空串 uuid 同时清空两列(哪怕请求里还带着版本号)")
void unlinkClearsBothColumns() {
FlwDef existing = draft("FU1", 3);
when(defRepository.findById(9L)).thenReturn(Optional.of(existing));
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
null, null, null, " ", 7);
assertSame(existing, saved);
assertNull(saved.getFormUuid());
assertNull(saved.getFormVersion());
}
@Test
@DisplayName("都不传=保持原关联不变(兼容旧调用面,缺省语义不能变成取消)")
void nullMeansKeep() {
FlwDef existing = draft("FU1", 3);
when(defRepository.findById(9L)).thenReturn(Optional.of(existing));
FlwDef saved = service.save(SCH, 9L, "work-leave", "改名", SIMPLE_JSON,
null, null, null, null, null);
assertEquals("FU1", saved.getFormUuid());
assertEquals(Integer.valueOf(3), saved.getFormVersion());
}
// ==================== ② 发布期确切版本 ====================
@Test
@DisplayName("带确切版本发布:按 uuid+version 取快照,并把版本坐标嵌进 def 行")
void publishUsesExactVersion() {
FlwDef def = draft("FU1", 3);
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
FlwDef published = service.publish(SCH, 9L);
verify(schemaClient).fetchSchema(SCH, "FU1", 3);
assertTrue(published.getContentJson().contains("\"formContract\""), published.getContentJson());
assertTrue(published.getContentJson().contains("FU1"));
}
@Test
@DisplayName("坐标不符:发布阻断(错误文案点名核验失败,不是拉取失败)")
void publishBlockedOnCoordinateMismatch() {
FlwDef def = draft("FU1", 3);
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
when(schemaClient.fetchSchema(SCH, "FU1", 3))
.thenThrow(new FormCenterSchemaClient.SchemaContractViolationException(
"表单中心返回的坐标与请求不符(请求 FU1 v3,返回 FU1 v4)"));
BizException e = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertEquals(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION.getCode(), e.getErrorCode().getCode());
assertTrue(e.getMessage().contains("核验失败"), e.getMessage());
assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage());
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
@DisplayName("缺版本:发布和启用均阻断,不回落当前版本")
void missingVersionBlocksPublishAndEnable(boolean enable) {
FlwDef def = draft("FU1", null);
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
assertEquals(FormBindingErrorCode.VERSION_REQUIRED.getCode(), ex.getErrorCode().getCode());
assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
verify(defRepository, never()).save(any());
}
private FlwDef changeStatus(boolean enable) {
return enable ? service.enable(SCH, 9L) : service.publish(SCH, 9L);
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void invalidVersionBlocksPublishAndEnable(boolean enable) {
FlwDef def = draft("FU1", 0);
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
verify(defRepository, never()).save(any());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void orphanVersionBlocksPublishAndEnable(boolean enable) {
FlwDef def = draft(null, 3);
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
verify(defRepository, never()).save(any());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void schemaFailuresHaveDistinctStableCodesAndDoNotWrite(boolean enable) {
FlwDef def = draft("FU1", 3);
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
RuntimeException[] failures = {
new FormCenterSchemaClient.SchemaUnavailableException("timeout", null),
new FormCenterSchemaClient.SchemaContractViolationException("wrong version"),
new FormCenterSchemaClient.SchemaResourceUnavailableException(null),
new FormCenterSchemaClient.SchemaAccessDeniedException(null)
};
FormBindingErrorCode[] codes = {FormBindingErrorCode.SCHEMA_UNAVAILABLE,
FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION, FormBindingErrorCode.RESOURCE_UNAVAILABLE,
FormBindingErrorCode.ACCESS_DENIED};
for (int i = 0; i < failures.length; i++) {
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenThrow(failures[i]);
BizException ex = assertThrows(BizException.class, () -> changeStatus(enable));
assertEquals(codes[i].getCode(), ex.getErrorCode().getCode());
assertSame(failures[i], ex.getCause());
assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
assertEquals(FLOW_JSON, def.getContentJson());
org.mockito.Mockito.reset(schemaClient);
}
verify(defRepository, never()).save(any());
}
@Test
void enableUsesExactVersionAndPreservesPublishedSnapshot() {
FlwDef def = draft("FU1", 3);
def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
def.setContentJson(FLOW_JSON.substring(0, FLOW_JSON.length() - 1)
+ ",\"formContract\":{\"formUuid\":\"FU1\",\"formVersion\":3,\"fields\":[]}}");
String frozenJson = def.getContentJson();
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
FlwDef enabled = service.enable(SCH, 9L);
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), enabled.getStatus());
assertEquals(frozenJson, enabled.getContentJson());
verify(schemaClient).fetchSchema(SCH, "FU1", 3);
}
@ParameterizedTest
@ValueSource(strings = {"oops", "3.5", "3.0", "2147483648", "", "true"})
void malformedVersionAtHttpBoundaryHasStableCode(String value) {
FlowAdminGuard guard = mock(FlowAdminGuard.class);
when(guard.requireAdmin(any(), any())).thenReturn(SCH);
com.yunhai.flowcenter.controller.api.FlowAdminController controller =
new com.yunhai.flowcenter.controller.api.FlowAdminController(guard, service,
null, null, null, null, null, null, new ObjectMapper(), null, null, null);
Map<String, Object> body = new LinkedHashMap<>();
body.put("formUuid", "FU1");
body.put("formVersion", value);
BizException ex = assertThrows(BizException.class, () -> controller.defSave(null, body));
assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode());
verify(defRepository, never()).save(any());
org.mockito.Mockito.verifyNoInteractions(schemaClient);
}
@Test
@DisplayName("未关联表单的流程:一次都不碰表单中心")
void noBindingNoCall() {
FlwDef def = draft(null, null);
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
service.publish(SCH, 9L);
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
}
// ==================== ③ 客户端形态 ====================
@Test
@DisplayName("精确版本请求:query 带 version;返回坐标不符即 SchemaContractViolationException")
void schemaClientSendsVersionAndChecksCoordinate() {
YschOpenCall openCall = mock(YschOpenCall.class);
FormCenterSchemaClient client = new FormCenterSchemaClient(openCall, new ObjectMapper());
Map<String, Object> data = new LinkedHashMap<>();
data.put("formUuid", "FU1");
data.put("version", 3);
data.put("code", "leave-form");
data.put("appCode", "svc-hall");
data.put("audience", Collections.singletonList("STUDENT"));
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
when(openCall.get(eq("form-center"), eq("/oapi/form/schema"), any(), eq(Map.class))).thenReturn(data);
Map<String, Object> snap = client.fetchSchema(SCH, "FU1", 3);
assertEquals("FU1", snap.get("formUuid"));
assertEquals(3, snap.get("formVersion"));
verify(openCall).get(eq("form-center"), eq("/oapi/form/schema"), query.capture(), eq(Map.class));
assertEquals("3", query.getValue().get("version"));
assertEquals("FU1", query.getValue().get("uuid"));
assertEquals(String.valueOf(SCH), query.getValue().get("schId"));
data.put("version", 4);
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
() -> client.fetchSchema(SCH, "FU1", 3));
// 坐标不符不重试:两次调用=成功一次 + 不符一次;若按可重试口径处理会变成三次
verify(openCall, org.mockito.Mockito.times(2))
.get(eq("form-center"), eq("/oapi/form/schema"), any(), eq(Map.class));
}
@Test
@DisplayName("精确版本形态不给 version:直接拒(缺版本=旧口径,不许混着调)")
void schemaClientRequiresExplicitVersion() {
FormCenterSchemaClient client = new FormCenterSchemaClient(mock(YschOpenCall.class), new ObjectMapper());
assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(SCH, "FU1", null));
}
@Test
@DisplayName("目录客户端:命名目标 form-center,query 带 CAS 校域与分页;空 data 报不可用")
void catalogClientQueryShapeAndEmptyData() {
YschOpenCall openCall = mock(YschOpenCall.class);
FormCenterCatalogClient client = new FormCenterCatalogClient(openCall);
FormCenterCatalogClient.CatalogPage page = new FormCenterCatalogClient.CatalogPage();
FormCenterCatalogClient.CatalogItem item = new FormCenterCatalogClient.CatalogItem();
item.setFormUuid("FU1");
item.setVersion(3);
item.setName("请假单");
page.setItems(Arrays.asList(item));
page.setTotal(1);
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
eq(FormCenterCatalogClient.CatalogPage.class))).thenReturn(page);
FormCenterCatalogClient.CatalogPage got = client.listForms(SCH, "svc-hall", "请假", 2, 20);
assertEquals(1, got.getItems().size());
assertEquals("FU1", got.getItems().get(0).getFormUuid());
verify(openCall).get(eq("form-center"), eq("/oapi/form/forms"), query.capture(),
eq(FormCenterCatalogClient.CatalogPage.class));
assertEquals(String.valueOf(SCH), query.getValue().get("schId"));
assertEquals("svc-hall", query.getValue().get("appCode"));
assertEquals("请假", query.getValue().get("keyword"));
assertEquals("2", query.getValue().get("page"));
assertEquals("20", query.getValue().get("pageSize"));
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
eq(FormCenterCatalogClient.CatalogPage.class))).thenReturn(null);
BizException e = assertThrows(BizException.class,
() -> client.listForms(SCH, null, null, null, null));
assertTrue(e.getMessage().contains("空"), e.getMessage());
}
@Test
@DisplayName("目录不可达(网络/未授权)转可诊断 BizException,不静默给空列表")
void catalogUnreachableIsSurfaced() {
YschOpenCall openCall = mock(YschOpenCall.class);
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
eq(FormCenterCatalogClient.CatalogPage.class))).thenThrow(new IllegalStateException("connect refused"));
FormCenterCatalogClient client = new FormCenterCatalogClient(openCall);
BizException e = assertThrows(BizException.class, () -> client.listForms(SCH, null, null, 1, 20));
assertTrue(e.getMessage().contains("不可达"), e.getMessage());
}
}
@@ -0,0 +1,227 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.repository.FlwAppRepository;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwRouterRepository;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* publish() 的 C1 契约测试(2026-10-20 第一刀:发布期快照 + 表达式键查①红牌)。<br>
* 覆盖:① 关联表单(form_uuid 非空)发布成功=content_json 顶层嵌 formContract 且 status=1;
* ② 表达式变量 ∉ 快照字段 ∪ 内置变量 → 红牌阻断(点名节点/出口/变量);③ form_uuid 为空(存量线)
* 不拉快照、行为不变;④ 快照拉取失败=发布阻断(可重试语义)。
*/
class FlowDefAdminServicePublishContractTest {
private static final Long SCH = 27L;
/** 含 CONDITION(EXPRESSION) 节点的合法定义:days/grade 是表单字段、schId 是内置变量 */
private static final String JSON = "{\"start\":\"s\",\"nodes\":["
+ "{\"id\":\"s\",\"type\":\"START\"},"
+ "{\"id\":\"c\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
+ "{\"id\":\"a\",\"type\":\"APPROVAL\",\"name\":\"审1\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[1]}},"
+ "{\"id\":\"e\",\"type\":\"END\"}],"
+ "\"edges\":["
+ "{\"from\":\"s\",\"to\":\"c\"},"
+ "{\"from\":\"c\",\"to\":\"a\",\"name\":\"准\",\"condition\":\"days > 3 && schId > 0\"},"
+ "{\"from\":\"c\",\"to\":\"e\",\"name\":\"否\",\"condition\":\"grade == 'g1'\"},"
+ "{\"from\":\"a\",\"to\":\"e\"}]}";
private FlwDefRepository defRepository;
private FormCenterSchemaClient schemaClient;
private FlowDefAdminService service;
private final ObjectMapper mapper = new ObjectMapper();
@BeforeEach
void setUp() {
defRepository = mock(FlwDefRepository.class);
schemaClient = mock(FormCenterSchemaClient.class);
service = new FlowDefAdminService(defRepository,
mock(FlwRouterRepository.class),
mock(FlwCallbackRegRepository.class),
mock(FlwAppRepository.class),
mock(FlwInstanceRepository.class),
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
mapper,
schemaClient);
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
}
private FlwDef draft(String formUuid) {
FlwDef d = new FlwDef();
d.setId(9L);
d.setSchId(SCH);
d.setFlowGuid("g1");
d.setCode("work-leave");
d.setVersion(1);
d.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
d.setAppKey("notice");
d.setContentJson(JSON);
d.setFormUuid(formUuid);
d.setFormVersion(formUuid == null ? null : 3);
return d;
}
/** 快照:days(number)/grade(select enum g1,g2)/reason(text) */
private Map<String, Object> snapshot() {
Map<String, Object> snap = new LinkedHashMap<>();
snap.put("formUuid", "FU1");
snap.put("formVersion", 3);
snap.put("audience", Arrays.asList("STUDENT", "TEACHER"));
snap.put("fields", Arrays.asList(
field("days", "number", true, null),
field("grade", "select", false, Arrays.asList("g1", "g2")),
field("reason", "text", true, null)));
return snap;
}
private static Map<String, Object> field(String key, String type, boolean required, List<String> enums) {
Map<String, Object> f = new LinkedHashMap<>();
f.put("key", key);
f.put("type", type);
f.put("required", required);
if (enums != null) {
f.put("enum", enums);
}
return f;
}
@Test
void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
FlwDef published = service.publish(SCH, 9L);
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
JsonNode contract = mapper.readTree(published.getContentJson()).get("formContract");
assertNotNull(contract, "content_json 顶层必须嵌 formContract");
assertEquals("FU1", contract.get("formUuid").asText());
assertEquals(3, contract.get("formVersion").asInt());
assertEquals(3, contract.get("fields").size(), "fields 扁平化嵌入");
assertTrue(contract.get("audience").isArray(), "audience 按数组解析入快照");
}
@Test
void unknownExpressionVariableIsRedCard() {
FlwDef d = draft("FU1");
// 出口条件改成引用快照外变量
d.setContentJson(JSON.replace("grade == 'g1'", "level == 'high'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage());
assertTrue(ex.getMessage().contains("不在关联表单字段集内"), "键查①给可定位的问题行");
verify(defRepository, never()).save(any());
}
@Test
void numericCompareOnTextFieldIsRedCardTypeBucket() {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "reason > 3"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"),
"text 字段做数值比较要点名(类型桶查②):" + ex.getMessage());
}
@Test
void stringLiteralEqualsOnNumberFieldIsRedCardTypeBucket() {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "days == 'three'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"),
"number 字段与字符串字面量等值要拦:" + ex.getMessage());
}
@Test
void enumLiteralOutOfDomainIsRedCard() {
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "grade == 'g9'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"),
"枚举字面量不在值域要点名(域查③):" + ex.getMessage());
}
@Test
void numericFieldWithNumericLiteralPassesAllChecks() {
// days(number) > 3 与内置 schId > 0:happy path(publishEmbeds…已覆盖主链),此处锁数值正向桶
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "days >= 2.5"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
}
@Test
void builtinVariablesPassKeyCheck() {
// days 与 schId(内置)都合法 → 上面的 happy path 已覆盖;此处单独断言纯内置表达式
FlwDef d = draft("FU1");
d.setContentJson(JSON.replace("grade == 'g1'", "bizType == 'x'"));
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot());
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
}
@Test
void legacyDefWithoutFormUuidSkipsSnapshotEntirely() {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft(null)));
FlwDef published = service.publish(SCH, 9L);
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动");
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
}
@Test
void schemaUnavailableBlocksPublishAsRetryableBizError() {
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
when(schemaClient.fetchSchema(SCH, "FU1", 3))
.thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null));
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
assertEquals(com.yunhai.flowcenter.enums.FormBindingErrorCode.SCHEMA_UNAVAILABLE.getCode(),
ex.getErrorCode().getCode());
assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage());
verify(defRepository, never()).save(any());
}
}
@@ -51,7 +51,8 @@ class FlowDefAdminServiceSaveTest {
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper());
new ObjectMapper(),
mock(com.yunhai.flowcenter.service.FormCenterSchemaClient.class));
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// 归属应用白名单:非空 appKey 一律放行(校验拦截语义由专门用例覆盖)
when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
@@ -43,7 +43,8 @@ class FlowDefAdminServiceUpdateMetaTest {
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper());
new ObjectMapper(),
mock(com.yunhai.flowcenter.service.FormCenterSchemaClient.class));
when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
}
@@ -0,0 +1,197 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.openapi.core.OpenApiErrorCode;
import com.yunhai.platform.utils.exception.DException;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link FormCenterSchemaClient} 单元测试(2026-10-20 C1 第一刀)。<br>
* 覆盖:① SchemaView(data) → 快照节的 fields 扁平化(分组展开、enum 收 options 值域);
* ② audience <b>数组解析</b>(契约 v2 C5/G3 breaking)+ 旧单值串容错;③ 失败重试 2 次后转
* SchemaUnavailableException(发布阻断语义)。
*/
class FormCenterSchemaClientTest {
private YschOpenCall openCall;
private FormCenterSchemaClient client;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
client = new FormCenterSchemaClient(openCall, new ObjectMapper());
}
@SuppressWarnings("unchecked")
private Map<String, Object> schemaData(String uuid, int version, List<String> audience) {
Map<String, Object> data = new HashMap<>();
data.put("formUuid", uuid);
data.put("version", version);
data.put("code", "leave-form");
data.put("appCode", "svc-hall");
data.put("audience", audience);
data.put("groups", Arrays.asList(
map("key", "base", "label", "基本信息", "fields", Arrays.asList(
map("key", "days", "label", "天数", "type", "number", "required", true),
map("key", "grade", "label", "年级", "type", "select", "required", false,
"options", Arrays.asList(map("label", "高一", "value", "g1"),
map("label", "高二", "value", "g2"))))),
map("key", "extra", "label", "补充", "fields", Arrays.asList(
map("key", "reason", "label", "事由", "type", "text", "required", true)))));
return data;
}
private static Map<String, Object> map(Object... kv) {
Map<String, Object> m = new HashMap<>();
for (int i = 0; i + 1 < kv.length; i += 2) {
m.put((String) kv[i], kv[i + 1]);
}
return m;
}
@Test
@SuppressWarnings("unchecked")
void flattensGroupsIntoFieldsAndCollectsEnumFromOptions() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER")));
Map<String, Object> snap = client.fetchSchema(27L, "U1", 3);
assertEquals("U1", snap.get("formUuid"));
assertEquals(3, snap.get("formVersion"));
assertEquals(Arrays.asList("STUDENT", "TEACHER"), snap.get("audience"));
List<Map<String, Object>> fields = (List<Map<String, Object>>) snap.get("fields");
assertEquals(3, fields.size(), "两个分组的字段要扁平化到一处");
Map<String, Object> grade = fields.stream()
.filter(f -> "grade".equals(f.get("key"))).findFirst().orElseThrow(AssertionError::new);
assertEquals("select", grade.get("type"));
assertEquals(Boolean.FALSE, grade.get("required"));
assertEquals(Arrays.asList("g1", "g2"), grade.get("enum"), "options 值域收进 enum(域查③的输入)");
}
@Test
void legacySingleStringAudienceIsWrappedAsArray() {
Map<String, Object> data = schemaData("U1", 3, null);
data.put("audience", "STUDENT");
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
Map<String, Object> snap = client.fetchSchema(27L, "U1", 3);
assertEquals(Arrays.asList("STUDENT"), snap.get("audience"),
"C5 切换窗口的旧单值串按一元素数组容错");
}
@Test
void retriesTwiceThenThrowsUnavailable() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new RuntimeException("connect refused"));
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
.get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@Test
@SuppressWarnings("unchecked")
void nullDataTreatedAsFailure() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null);
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
.get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"0", "-1", "4", "3.5", "wrong", "2147483648"})
void malformedOrWrongReturnedVersionIsContractFailureWithoutRetry(String returnedVersion) {
Map<String, Object> data = schemaData("U1", 3, Arrays.asList("STUDENT"));
data.put("version", returnedVersion);
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", "U2"})
void missingOrWrongReturnedUuidIsContractFailureWithoutRetry(String returnedUuid) {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenReturn(schemaData(returnedUuid, 3, Arrays.asList("STUDENT")));
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@ParameterizedTest
@ValueSource(ints = {0, -1})
void illegalRequestedVersionMakesNoRemoteCall(int version) {
assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(27L, "U1", version));
org.mockito.Mockito.verifyNoInteractions(openCall);
}
@Test
void resourceRejectionUsesEnvelopeCodeAndDoesNotRetry() {
// SDK 实际携带 OpenApiErrorCode,不会转换成本地 ErrorCode 枚举;文案刻意无关键词。
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new DException(new OpenApiErrorCode(2, "opaque")));
assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@ParameterizedTest
@ValueSource(ints = {3, 4})
void authRejectionDoesNotBlindlyRetry(int code) {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new DException(new OpenApiErrorCode(code, "opaque")));
assertThrows(FormCenterSchemaClient.SchemaAccessDeniedException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@Test
void http404DoesNotBlindlyRetry() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new OpenApiException("opaque", 404, ""));
assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class));
}
@Test
void internalRemoteFailureRemainsRetryable() {
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
.thenThrow(new DException(new OpenApiErrorCode(6, "opaque")));
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
() -> client.fetchSchema(27L, "U1", 3));
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
.get(anyString(), anyString(), anyMap(), eq(Map.class));
}
}
@@ -47,6 +47,7 @@ class OapiFlowApplyDefVersionTest {
private FlowInstanceService instanceService;
private AssigneeResolver assigneeResolver;
private OapiFlowService service;
private FlwInstanceRepository instances;
@BeforeEach
@SuppressWarnings("unchecked")
@@ -54,7 +55,8 @@ class OapiFlowApplyDefVersionTest {
defRepository = mock(FlwDefRepository.class);
instanceService = mock(FlowInstanceService.class);
assigneeResolver = mock(AssigneeResolver.class);
service = new OapiFlowService(defRepository, mock(FlwInstanceRepository.class),
instances = mock(FlwInstanceRepository.class);
service = new OapiFlowService(defRepository, instances,
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class),
mock(FlowEngine.class), assigneeResolver, new ObjectMapper(),
@@ -150,4 +152,20 @@ class OapiFlowApplyDefVersionTest {
assertEquals(Integer.valueOf(1), vo.getDefVersion());
assertTrue(vo.getNodes() == null || vo.getNodes().isEmpty());
}
@Test
void v2ResponseCarriesRealRejectedStatusAndStableBranch() {
stubDef(3);
com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2 body = new com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyRequestV2();
body.setFlowGuid(FLOW_GUID); body.setBizType("svc-hall"); body.setBizId("42"); body.setSubmitterId(21);
body.setRequestToken("token-1"); body.setAttempt(1);
when(instanceService.applyGuarded(any(), any())).thenReturn(
new FlowInstanceService.GuardedOutcome(500L, true, "SAME_REQUEST", 1));
com.yunhai.flowcenter.entity.FlwInstance row = new com.yunhai.flowcenter.entity.FlwInstance();
row.setId(500L); row.setStatus(30); row.setVersion(4); row.setApplyRound(1);
when(instances.findById(500L)).thenReturn(Optional.of(row));
com.yunhai.flowcenter.model.FlowApplyRetryModels.ApplyResultV2 result = service.applyV2(SCH_ID, "100009", body);
assertEquals("SAME_REQUEST", result.getBranch()); assertEquals(Integer.valueOf(1), result.getApplyRound());
assertEquals(Integer.valueOf(30), result.getStatus()); assertEquals(Integer.valueOf(4), result.getVersion());
}
}