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

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

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

- 源码/测试/资源:沿用已审计工作树内容(96 项相对远端为新增或更新),含未跟踪实现
  CallbackFactStore、CallbackSourcePolicy、VariableContract、FormalApplyCommandService、
  FlowActorContextGate 等;C01 续验夹具修正一并入内。
- CI:保留远端 11e0778 的门禁强化(CI_SOURCE_SHA 核对、容器回收验证、空 schema 反向门禁、
  main ref 才推镜像);ci/flow-test-schema.sql 按当前实体重新生成(14→20 表,补 6 新表与
  6 新列,回调日志复合唯一键 uk_flw_log_event_reg),workflow 表数断言同步 14→20。
- 依据:docs/tasks/evidence/FL-final-audit-20261027(194 项哈希/版本核对)。
2026-10-27 12:05:08 +08:00
chenxing 11e077867b Run mandatory RC gates with isolated CI failure checks
build / build-push (push) Successful in 4m2s
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:52:02 +08:00
chenxing f7819b14e2 Carry actual flow state and freeze callback round identity
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:52:00 +08:00
chenxing 7eeaea55f8 Enforce exact published form version contracts
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
2026-10-23 10:51:47 +08:00
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
chenxing 8ceb2a4247 [audit] P6.3 F5-4 reroute 增 useLatestDef 僵尸逃生门:按当前已发布最高版本重跑+快照刷新+trace 留痕(默认 false 现行为逐字不变)
build / build-push (push) Successful in 11m48s
2026-10-18 08:18:26 +08:00
chenxing ad87005169 [audit] P6.2 F9 embed/url 支持 mode=task 直跳任务办理页(taskId 必填,无 token CAS 页;front-url fail-fast 纪律不变) 2026-10-18 08:11:28 +08:00
chenxing 448c8d36f4 [audit] P6.1 F11-4 台账路由挂起可观测:pageAdmin 增 hung 过滤 + 行级 routeHung 红标(口径=max attempt 路由行 outlet 仍 NULL,防误报) 2026-10-18 08:05:32 +08:00
chenxing f4121b2c1e [ysch-flow] S2.7 apply 可选回带期望定义版本(版本竞态人类可读报错,⭐)
preview 回带 defVersion,apply 支持可选 expectedDefVersion:两者不一致时直接业务码拒绝并说
「流程已更新,请刷新后重新选择审批人(期望 x,当前已发布 y)」,而不是让选好的 nodeId/候选
在新版本里对不上、报成「指派节点不存在」(《方案》§8.3 补强 2 / §18 决策 8)。
检查点放在 renderAssignJson 之前,引擎与指派校验都不被触发,不污染幂等键。
opt-in 设计:不传该字段的存量调用方(svc-hall / notice / work-leave)行为逐字不变——
已由「withoutExpectedVersion_behavesExactlyAsBefore」钉住。
OapiModels 两处为纯新增字段;新增 5 例单测(不传/传对/传旧/preview 回带/无指派节点),
mvn -o test -Dtest=!*IT 176 例全绿(原 171 + 新 5)。
2026-10-18 01:16:48 +08:00
chenxing 82ffe2f030 [ysch-flow] 环境修复:appearance 件补显式版本(本分支 BOM 未托管该件)
yh-appearance-spring-boot-starter 只由 BasePlatform 的 feat/appearance-sdk 分支及其 BOM 托管,
本次接入件工作基于 feat/flow-embed(BOM 无条目),不写版本则 mvn package 直接依赖解析失败、
本仓无法打包(notice/svc-hall/UserBase 三仓同状)。jar 本体已在本地仓库,补显式版本即恢复;
appearance 分支合流后应删本行回落 BOM 托管。不改任何业务代码与密钥段。
2026-10-18 01:08:07 +08:00
chenxing 008aee3c88 [ysch-flow] S2.3 抄送接收人只读可见(F7 前置必修补丁)
requireInstanceVisible 此前只放行提交人/办理人/本校管理员,抄送接收人点开工作台
"抄送我的"行或抄送消息回落链接一律 403——"抄送中心自留、子应用零存储"因此不闭环。
新增 flw_cc 行命中判定(existsByInstanceIdAndUserId,按 instanceId 维度不泛化到本校),
只授予可见、办理权仍在 FlowTaskService 按 assigneeId 复核;中心用户面四处调用点
(详情/进度/出图)自动同口径。embed/url 签发侧 javadoc 同步可见性口径(oapi 层仍只认
schId,用户级判定在子应用后端,跨校与陌生人断言未放宽)。
守卫五态单测:提交人/办理人/管理员/抄送人/陌生人 + 抄送命中范围。
2026-10-17 23:01:01 +08:00
chenxing 81d9a075e8 [ysch-flow] S2.2 结果/退回消息改走 bizUrl 优先策略(F8 缺口①)
notifyResult/notifyReturned 此前把 jumpPath 硬编码 /instance/{id}、jumpApp 硬编码 null,
消息归属因此落在流程中心,四类消息(待办/抄送/结果/退回)口径不一致。现与 task/cc
同策略并抽出 jumpPath/jumpApp 两个共用判定:bizUrl 非空→直达业务方详情页且归属申报
给 apply 调用方 applyAppCode;bizUrl 空(存量办件)→仍回落中心页且归属留空。
单测 8 例(新增 4 例:结果带/不带 bizUrl、退回、抄送)。
2026-10-17 23:00:59 +08:00
chenxing 775b2392fa feat(callback): 对接健康度体检接口(议题二 P2)
build / build-push (push) Successful in 2m38s
用 flw_callback_reg / flw_callback_log 两张已有表拼装「这个应用接得好不好」,零新增表:

- FlwCallbackLogRepository 增 3 个只读聚合:countByStatusSinceForRegs / countByStatusForRegs / countHungForRegs(按注册集合过滤,挂起口径与管理端「挂起」列表一致:status=0 且 retryCount >= hangAlertRetries)
- 新增 AppHealthVo(model 包):appKey/appName/score/level/summary/checks(List<CheckItem>),检查项用 List 承载便于未来增删
- FlowCallbackAdminService.health(schId, appNames) 按 appKey 归组(同一应用多个 flowGuid 注册合并体检),六项检查加权算分:登记地址为私网/开发机 -40、base_path 缺失 -10、近 7 日成功率 <99% 或 =0% -15/-30、死信 -20、挂起 -12、注册停用 -8、无推送记录 -5;level 取最差项,问题应用 bad>warn>ok 置顶、同级按分升序
- FlowAdminController 增 GET /api/flow/admin/callback/health;应用名从 appAdminService.list(schId) 补齐,缺失回落 appKey

健康度面板与管理端三个列表(注册/日志/挂起死信)共用同一口径,避免两处数字打架。mvn -o package 通过。
2026-10-15 14:51:19 +08:00
chenxing a9aeca744d chore: 实施前基线快照(条件路由/接入改造前)
FlwDef 表单关联列 form_id(Long) -> form_uuid(String,32):对齐表单中心对外身份;M1 仍恒 null、二期对接启用。
2026-10-14 18:12:31 +08:00
chenxing fd4e207ce0 feat: 发起时指派——assignPolicy 配置/assign_json 快照/引擎收窄/preview+apply 契约
build / build-push (push) Successful in 2m8s
2026-10-13 12:05:11 +08:00
chenxing c02bdc5eca fix(trace): 科室视图抄送行补遮蔽——ccTraceRow 补 fillCcScope 取 node.receivers 落 assigneeType/deptKey/deptName(NOTIFY 用 receivers 而非 assignee,直接复用 fillAssigneeScope 会空填),applyAssigneeScope 抽公共落值段;FlowEngineIT 增 ccTraceRows_carryDeptScope_forDeptView + 原 notify 用例补 USERS 断言;FlowTestSupport 增 notifyDeptDef。mvn test 209 全绿
build / build-push (push) Successful in 49s
2026-10-12 20:45:29 +08:00
chenxing 62aff51fed feat(condition): SERVICE 判断收口——flw_router 增 direct_host/reg_app_key 两列(登记即持有,跨应用覆盖拒绝);新端点 POST /oapi/flow/router/reg(应用签名即归属);RouteJudgeClient 对 direct_host 行跳过服务目录解析直连(TargetSpec.fromUrl),存量手工行走目录解析零变化;契约测试与 SQL 留档 2026-10-12 17:28:13 +08:00
160 changed files with 21257 additions and 560 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" = 20 ] || { echo "Expected 20 CI schema tables, got $tables" >&2; exit 1; }
echo "CI_SCHEMA_TABLES=$tables"
# 先在独立空 schema 验证真实 validate 失败会阻断打包;随后再生成正向候选 JAR。
printf '%s\n' 'CREATE DATABASE flowcenter_ci_invalid;' "GRANT ALL ON flowcenter_ci_invalid.* TO 'flow_ci'@'%';" | \
docker exec -i "$DB_CONTAINER" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot'
positive_db_url="$CI_TEST_DB_URL"
export CI_TEST_DB_URL="jdbc:mysql://${DB_CONTAINER}:3306/flowcenter_ci_invalid?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"
docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \
"$BUILD_CONTAINER" sh -c 'cd /build && sh ci/verify-failure-guard.sh'
export CI_TEST_DB_URL="$positive_db_url"
docker exec -e CI_TEST_DB_URL -e CI_TEST_DB_USER -e CI_TEST_DB_PASSWORD \
"$BUILD_CONTAINER" sh -c 'cd /build && sh ci/test-gate.sh clean package'
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
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` 复制进初始化目录;该文件只含 20 张表的结构,不含业务数据。
镜像初始化阶段建立测试 schema,测试阶段只 validate。每个 job 的数据库容器名和随机凭据独立,
EXIT trap 回收 Maven/MySQL 容器;不访问 160 业务库。
随机凭据仅经容器环境变量传递,不写仓库或日志。Maven 子容器必须获得:
- `CI_TEST_DB_URL`:MySQL JDBC URL;schema 必须为 `flowcenter_ci` 或 `flowcenter_ci_*`。
- `CI_TEST_DB_USER`、`CI_TEST_DB_PASSWORD`:本次数据库的专用账号与密码。
脚本只允许 localhost、127.0.0.1 或 `mavenbuild-*-db` 主机,随后映射为 Spring datasource 环境变量。
`OapiFlowTasksByBizTest` 在任何 cleanDb 前检查 JDBC 实际连接的 host/schema,防止 profile 覆盖错误。
CI 场景必须实际连接 `flowcenter_ci*`,不能悄悄改用默认开发库;11 项继续为真 MySQL 测试。
本地等价运行:在独立测试库按此 schema 初始化后,通过进程环境设置上述三个变量,
再执行 `sh ci/test-gate.sh clean package`。凭据不要写入命令历史或提交到仓库。
本次已在本机 MySQL 8.0.45 做正反等价验证:完整 schema 通过,空 schema 验证失败且不生成 JAR。
三个 workflow 的手动入口新增 `tests_only=true`(默认),可以在 runner 上只验测试/JAR,不推镜像或通知建档。
本机没有 Docker,Gitea runner 的容器启动、镜像拉取和网络连通未实跑;这些仍需在 runner 上验证,
不能将等价 Maven 证据描述为实际 Gitea workflow 已通过。
实体结构变更时应显式刷新 SQL 快照并重新验证;不能把 validate 改为 update 来绕过缺表或缺列。
## 核心 RC 功能分支实跑
`codex/core-rc-20261023` 的 push 只执行测试/JAR;镜像推送与通知同时受 main ref 与 tests_only 条件约束。
检出后核验 `CI_SOURCE_SHA` 等于事件 SHA,不能使用随后更新的 main。
FL 在同一独立 MySQL 容器额外创建空 `flowcenter_ci_invalid`,先运行 `verify-failure-guard.sh`:
要求 11 项真实数据库测试因 validate 缺表报错、Maven 非零且无 JAR;随后对 20 表正常 schema 执行正向门禁。
空库检查的预期失败不属于 380 项正向用例。回收检查打印 `CI_CONTAINER_CLEANUP=PASS`,同时删除本次容器的匿名卷。
本文件是运行约定;只有取得对应远端准确 SHA 的实际日志,才能宣称 runner 已完成验证。
+471
View File
@@ -0,0 +1,471 @@
-- FL 集成测试独立 schema:当前 116 个实体映射出的全部 20 张表结构,不含任何业务数据。
-- 生成方式:2026-10-27 用当前代码在隔离 MySQL 8.0.45 以 ddl-auto=create 建库后导结构(mysqldump --no-data)。
-- 实体结构变更时必须重新生成本文件并重跑 ci/test-gate.sh;不得改 ddl-auto 来绕过缺表/缺列。
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_advance_intent` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`activation_apply_round` int DEFAULT NULL,
`activation_def_id` bigint DEFAULT NULL,
`activation_def_version` int DEFAULT NULL,
`activation_task_id` bigint DEFAULT NULL,
`activation_task_ids` longtext COLLATE utf8mb4_general_ci,
`assignee_id` int DEFAULT NULL,
`assignee_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`attempts` int NOT NULL,
`edge_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`handled_task_ids` longtext COLLATE utf8mb4_general_ci,
`instance_id` bigint NOT NULL,
`last_error` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`next_retry_at` datetime DEFAULT NULL,
`node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`revision` bigint NOT NULL,
`status` varchar(16) COLLATE utf8mb4_general_ci NOT NULL,
`trigger_task_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_advance_activation` (`activation_task_id`),
KEY `idx_flw_advance_intent_due` (`status`,`next_retry_at`),
KEY `idx_flw_advance_intent_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_app` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`app_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`app_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`enabled` int NOT NULL,
`sch_id` bigint NOT NULL,
`sort_no` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_app_sch_key` (`sch_id`,`app_key`),
KEY `idx_flw_app_sch_enabled` (`sch_id`,`enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_apply_command` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`biz_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`canonical_hash` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`canonical_version` int NOT NULL,
`def_id` bigint DEFAULT NULL,
`def_version` int DEFAULT NULL,
`flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`instance_id` bigint DEFAULT NULL,
`reject_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`reject_detail` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`request_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`apply_round` int DEFAULT NULL,
`sch_id` bigint NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL,
`state` varchar(16) COLLATE utf8mb4_general_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_command_request` (`sch_id`,`source_principal_id`,`request_id`),
KEY `idx_flw_command_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_assignee_map` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`dept_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`dept_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
`user_id` int NOT NULL,
`user_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_map_sch_dept_user` (`sch_id`,`dept_key`,`user_id`),
KEY `idx_flw_map_sch_dept` (`sch_id`,`dept_key`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_attachment` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`field_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`file_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`file_path` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`file_size` bigint DEFAULT NULL,
`instance_id` bigint NOT NULL,
`mime_type` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`web_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_flw_att_inst` (`instance_id`,`sch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_biz_coordinate` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`active_instance_id` bigint DEFAULT NULL,
`biz_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`biz_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`last_instance_id` bigint DEFAULT NULL,
`last_round` int NOT NULL,
`sch_id` bigint NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_coord` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_branch` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`branch_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`instance_id` bigint NOT NULL,
`status` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_branch_inst_key` (`instance_id`,`branch_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_callback_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`biz_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_type` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`event` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`event_id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`instance_id` bigint NOT NULL,
`last_error` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
`last_http_status` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`next_retry_time` datetime DEFAULT NULL,
`payload_json` longtext COLLATE utf8mb4_general_ci NOT NULL,
`push_time` datetime DEFAULT NULL,
`reg_id` bigint NOT NULL,
`resp_body` text COLLATE utf8mb4_general_ci,
`retry_count` int NOT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_log_event_reg` (`event_id`,`reg_id`),
KEY `idx_flw_log_scan` (`status`,`next_retry_time`),
KEY `idx_flw_log_inst` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_callback_reg` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`app_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`base_path` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`callback_url` varchar(500) COLLATE utf8mb4_general_ci NOT NULL,
`event_types` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`sch_id` bigint NOT NULL,
`secret` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL,
`status` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_reg_sch_app_flow` (`sch_id`,`app_key`,`flow_guid`),
KEY `idx_flw_reg_flow` (`sch_id`,`flow_guid`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_cc` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`instance_id` bigint NOT NULL,
`node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`sent_time` datetime(3) NOT NULL,
`user_id` int NOT NULL,
`user_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_flw_cc_user` (`user_id`,`sent_time`),
KEY `idx_flw_cc_inst` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_command_source` (
`id` varchar(36) COLLATE utf8mb4_general_ci NOT NULL,
`app_id` int NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_def` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`change_log` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`content_json` longtext COLLATE utf8mb4_general_ci NOT NULL,
`flow_guid` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`form_uuid` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`form_version` int DEFAULT NULL,
`item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`line_offline` int NOT NULL DEFAULT '0',
`name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`publish_time` datetime DEFAULT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
`version` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_def_guid_ver` (`sch_id`,`flow_guid`,`version`),
KEY `idx_flw_def_guid` (`sch_id`,`flow_guid`,`status`,`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_dept` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`dept_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`sch_id` bigint NOT NULL,
`source_group_id` int DEFAULT NULL,
`status` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_dept_sch_srcgroup` (`sch_id`,`source_group_id`),
KEY `idx_flw_dept_sch_status` (`sch_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_dept_member` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`dept_id` bigint NOT NULL,
`status` int NOT NULL,
`user_id` int NOT NULL,
`user_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_dept_member` (`dept_id`,`user_id`),
KEY `idx_flw_dept_member_dept` (`dept_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_instance` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`apply_app_code` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_app_key` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_payload_hash` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_request_token` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`apply_round` int DEFAULT NULL,
`assign_json` longtext COLLATE utf8mb4_general_ci,
`biz_id` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_snapshot` longtext COLLATE utf8mb4_general_ci,
`biz_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_type` varchar(32) COLLATE utf8mb4_general_ci DEFAULT NULL,
`biz_url` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`current_nodes` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`def_code` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`def_id` bigint NOT NULL,
`def_version` int NOT NULL,
`event_seq_counter` int DEFAULT NULL,
`finish_time` datetime DEFAULT NULL,
`form_data_json` longtext COLLATE utf8mb4_general_ci,
`item_code` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`item_title` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
`legacy_slot` int DEFAULT NULL,
`sch_id` bigint NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL,
`status` int NOT NULL,
`submit_time` datetime DEFAULT NULL,
`submitter_id` int NOT NULL,
`submitter_name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`version` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_instance_legacy` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`,`legacy_slot`),
UNIQUE KEY `uk_flw_instance_round` (`sch_id`,`source_principal_id`,`biz_type`,`biz_id`,`apply_round`),
KEY `idx_flw_inst_submitter` (`sch_id`,`submitter_id`,`status`),
KEY `idx_flw_inst_status_time` (`sch_id`,`status`,`submit_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_route_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`attempt` int NOT NULL,
`decide_time` datetime(3) NOT NULL,
`instance_id` bigint NOT NULL,
`node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`outlet` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`raw_response` text COLLATE utf8mb4_general_ci,
`router_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`sch_id` bigint NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_flw_route_log_inst` (`instance_id`,`node_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_router` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`base_path` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`direct_host` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`name` varchar(128) COLLATE utf8mb4_general_ci NOT NULL,
`path` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
`reg_app_key` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`router_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`service_key` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`status` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_router_key` (`router_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_state_event` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`apply_round` int DEFAULT NULL,
`dispatched` int NOT NULL,
`event_id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`event_seq` int NOT NULL,
`event_type` varchar(32) COLLATE utf8mb4_general_ci NOT NULL,
`instance_id` bigint NOT NULL,
`operator_id` int DEFAULT NULL,
`payload_json` longtext COLLATE utf8mb4_general_ci NOT NULL,
`sch_id` bigint NOT NULL,
`task_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_state_event_id` (`event_id`),
KEY `idx_flw_state_event_instance` (`instance_id`),
KEY `idx_flw_state_event_dispatch` (`dispatched`,`create_time`),
KEY `idx_flw_state_event_seq` (`instance_id`,`event_seq`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_task` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`arrive_time` datetime(3) NOT NULL,
`assignee_id` int NOT NULL,
`assignee_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`deadline` datetime DEFAULT NULL,
`edge_name` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`handle_time` datetime(3) DEFAULT NULL,
`instance_id` bigint NOT NULL,
`node_id` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`node_name` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`opinion` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`status` int NOT NULL,
`urge_count` int NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_flw_task_inst_status` (`instance_id`,`status`),
KEY `idx_flw_task_assignee` (`assignee_id`,`status`,`arrive_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `flw_usage_grant` (
`id` bigint NOT NULL AUTO_INCREMENT,
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`app_id` int NOT NULL,
`app_key_snapshot` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL,
`app_name_snapshot` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL,
`enabled` int NOT NULL,
`flow_guid` varchar(64) COLLATE utf8mb4_general_ci NOT NULL,
`remark` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`sch_id` bigint NOT NULL,
`source_principal_id` varchar(36) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_flw_usage_grant` (`sch_id`,`flow_guid`,`app_id`),
KEY `idx_flw_usage_grant_sch_flow` (`sch_id`,`flow_guid`),
KEY `idx_flw_usage_grant_app` (`app_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
# 真实 MySQL 测试必须使用本次任务的独立 schema,禁止默认连开发/生产库。
: "${CI_TEST_DB_URL:?CI_TEST_DB_URL is required}"
: "${CI_TEST_DB_USER:?CI_TEST_DB_USER is required}"
: "${CI_TEST_DB_PASSWORD:?CI_TEST_DB_PASSWORD is required}"
case "$CI_TEST_DB_URL" in jdbc:mysql://*) ;; *) echo 'Expected a MySQL test URL' >&2; exit 2 ;; esac
address=${CI_TEST_DB_URL#jdbc:mysql://}
host=${address%%/*}; host=${host%%:*}
case "$host" in localhost|127.0.0.1|mavenbuild-*-db) ;; *) echo 'Database host is outside isolated CI allowlist' >&2; exit 2 ;; esac
schema=${address#*/}; schema=${schema%%\?*}
case "$schema" in flowcenter_ci|flowcenter_ci_*) ;; *) echo 'Database schema is outside isolated CI prefix' >&2; exit 2 ;; esac
export SPRING_DATASOURCE_URL="$CI_TEST_DB_URL"
export SPRING_DATASOURCE_USERNAME="$CI_TEST_DB_USER"
export SPRING_DATASOURCE_PASSWORD="$CI_TEST_DB_PASSWORD"
TESTS=FlApplyRecoveryTest,FlFormBindingTest,OapiFlowApplyDefVersionTest,CallbackPropertiesTest,FlowDefAdminServicePublishContractTest,FormCenterSchemaClientTest,OapiFlowTasksByBizTest,OapiFlowP35DoneCcContractTest
# Surefire 的选择器部分匹配也可能成功;每个指定类必须随源码存在。
oldIFS=$IFS
IFS=,
for test in $TESTS; do
[ -n "$(find src/test/java -type f -name "$test.java" -print -quit)" ] || { echo "Missing required test class: $test" >&2; exit 2; }
done
IFS=$oldIFS
if [ "$#" -eq 0 ]; then set -- test; fi
exec mvn -B "$@" -Dtest="$TESTS" -DfailIfNoTests=true -Dsurefire.failIfNoSpecifiedTests=true -DforkCount=0 -Dspring.jpa.hibernate.ddl-auto=validate < /dev/null
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
: "${CI_TEST_DB_URL:?isolated empty MySQL schema URL is required}"
case "$CI_TEST_DB_URL" in */flowcenter_ci_invalid\?*|*/flowcenter_ci_invalid_*\?*) ;; *) echo 'Negative guard requires its own invalid test schema' >&2; exit 2 ;; esac
log=$(mktemp /tmp/flow-ci-negative.XXXXXX)
trap 'rm -f "$log"' EXIT
set +e
sh ci/test-gate.sh clean package >"$log" 2>&1
code=$?
set -e
cat "$log"
echo "NEGATIVE_MVN_EXIT=$code"
[ "$code" -ne 0 ] || { echo 'Empty schema unexpectedly passed' >&2; exit 1; }
grep -q 'Schema-validation: missing table' "$log"
# 只钉“缺表必须让真实库用例报 error、其余断言不得失败”这一契约,不钉具体用例总数:
# 门禁类会随被测类新增用例而变化(2026-10-27 已由 92 增至 101),写死总数会把正常演进变成红灯。
summary=$(sed -n 's/.*Tests run: \([0-9]*\), Failures: \([0-9]*\), Errors: \([0-9]*\), Skipped: \([0-9]*\).*/\1 \2 \3 \4/p' "$log" | tail -1)
[ -n "$summary" ] || { echo 'Negative gate produced no Surefire summary' >&2; exit 1; }
set -- $summary
echo "NEGATIVE_SUMMARY tests=$1 failures=$2 errors=$3 skipped=$4"
[ "$1" -gt 0 ] || { echo 'No tests ran' >&2; exit 1; }
[ "$2" -eq 0 ] || { echo 'Empty schema caused assertion failures, not schema errors' >&2; exit 1; }
[ "$3" -gt 0 ] || { echo 'Empty schema did not produce any schema error' >&2; exit 1; }
[ -z "$(find target -maxdepth 1 -name '*.jar' -print -quit 2>/dev/null)" ] || { echo 'Failed gate produced a JAR' >&2; exit 1; }
echo 'CI_EMPTY_SCHEMA_FAILURE_GUARD=PASS'
@@ -0,0 +1,105 @@
# FL 后端核心修复执行回执 — Codex
执行日期:2026-10-27(Asia/Shanghai)。范围依据:《FL后端核心修复任务书_Codex_无人值守版.md》。
结论:**已核实**线级守卫的取反缺陷已作最小修复,测试改为真实调用生产 Service;定向测试 43/43 通过。**待核实(环境阻塞)**真实数据库提交后刷新回读、HTTP 联调及真实 AFTER_COMMIT 行为。本回执不代表整个 FL 终审通过。
## 1. 基线与范围
- 仓库:`D:/SoftProject/yunhai/flow-center/service`。
- **已核实**分支 `main`,HEAD `a10f2e054c2422ae2dfde82926ae567ad9096093`。这是本地仓库基线,不是本次核实过的线上运行版本。
- **已核实**开工时 `git status --short` 共 104 项;`FlowDefAdminService.java` 已有修改,`FlDefLineOfflineTest.java` 已存在但未跟踪。均按已有 WIP 保护。
- 开工状态和已跟踪差异见 [baseline-status.txt](evidence/core-fix-20261027/baseline-status.txt)、[baseline-tracked.patch](evidence/core-fix-20261027/baseline-tracked.patch);两个目标文件的原始快照也保存在同目录。
- 已读上级 `D:/SoftProject/yunhai/AGENTS.md`、`docs/云海AI代理探索与性能纪律.md`、`docs/云海项目导航索引.md`。入口所指 `docs/云海平台架构画像_资产与文档基线盘点.md` 在指定路径不存在,未据此推断环境事实。
- 任务书所列线上验收结论按**历史材料记载**引用:新实例 152–157 的回调 36/36 对账成功;本轮未重新访问生产或重跑矩阵,也未取得那些报告的独立正文。
## 2. 本轮代码差异
仅包含三份 Java 文件;以开工 WIP 为基线的独立差异见 [task-only.patch](evidence/core-fix-20261027/task-only.patch)。该补丁不是相对 HEAD 的完整发布补丁;应用前应保留原 WIP。
| 文件 | 本轮修改与原因 |
|---|---|
| `src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java:302` | 在存在性查询前补 `!`,改为缺失时拒绝。保留 null/空白拒绝、trim 和 `(schId, flowGuid)` 查询范围。生产代码仅改这一处判断。 |
| `src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java` | 删除复制业务逻辑的 `FlwDefAdminServiceSpy`,用已有构造辅助方法创建真实 `FlowDefAdminService`;补齐停发/恢复非法 GUID、空值、空白、错误学校、trim、发布内容及在途协作隔离断言。 |
| `src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java` | 增加新实例路径定向测试:真实创建 Service、引擎、任务 Service、激活意图 Service 和监听器串接;repository、人员解析和发布器为 mock。 |
未改 pom、生产配置、前端、FC、Notice、UserBase、网关或 SQL;未新增回填机制,未关闭 D09,未改状态机或事件契约。已有这些路径的差异属于开工 WIP。
## 3. 真实调用与缺陷捕获证据
先仅替换测试替身,保留错误生产判断,执行原 7 项停发测试:**已核实**失败 1、错误 1,跳过 0。
- `lineOfflineDelegatesBulkUpdate`:真实存在的 `g-line` 被生产 `requireLine` 抛错拒绝。
- `unknownLineRejected`:不存在的 `g-nope` 未抛异常,断言失败。
原始输出见 [red-test.log](evidence/core-fix-20261027/red-test.log)。随后才修复生产判断并补测试。当前测试字段类型是 `FlowDefAdminService`,由 `new FlowDefAdminService(...)` 实例化,直接调用其公开 `offlineLine` / `enableLine`;被测类和被测方法均未 mock/spy,也没有测试替身里的守卫副本。这一真实失败足以证明重新写反判断会被捕获。
## 4. 测试结果及实际验证层级
| 范围 | 结果 | 状态与限制 |
|---|---:|---|
| `FlDefLineOfflineTest` | 14/14 | **已核实**真实管理 Service:已有线双路径通过,分别委托批量更新 1/0;未知 GUID、null、空串、两种空白和错误学校双路径拒绝且不更新;trim;发布版本、内容及在途协作隔离;新发起/预览停发守卫。repository 为 mock。 |
| `FlNewInstancePathTest` | 1/1 | **已核实**新实例创建预期办理人的 pending 任务;任务创建事件通过生产监听器/Service 登记本次真实任务成员、定义 ID 和轮次;合法审批记录意图并推进到完成。把定义线标志置为停发后,在途任务仍使用原 defId/version 完成;未查询最新已发布版本替换原版本。 |
| `FlApplyRecoveryTest#createsWhenAbsentAndStampsIdentity` | 1/1 | **已核实**新建实例分支、token/轮次、审批中状态与 START 委托。仅执行这个方法。 |
| `FlAdvanceIntentRecoveryTest` | 11/11 | **已核实**生产激活结算 Service 的推进/重试/代次保护定向回归。 |
| `FlStateEventRecoveryTest` | 7/7 | **已核实**生产事实记录器的事件身份及冻结载荷等断言。 |
| `CallbackRecoveryDeliveryTest` | 9/9 | **已核实**生产分发/恢复逻辑和接收契约的内存协作测试;不是线上网络送达验证。 |
| 合计 | **43/43** | Failures 0 / Errors 0 / Skipped 0;Maven BUILD SUCCESS,43.529 秒。 |
完整输出见 [targeted-test.log](evidence/core-fix-20261027/targeted-test.log),同目录保存了这六个类的 Surefire XML 和文本报告,避免后续运行覆盖证据。Windows Maven 输出部分中文因原工具链编码显示乱码,测试名、数量及 XML 可复核。
新路径测试直接调用真实业务代码;测试显式驱动同步监听器和后提交入口,并仅初始化 Spring synchronization 以校验激活批次完整性。**没有真实事务、Spring AOP、JPA 锁或数据库提交;不能把这个测试描述为真实 AFTER_COMMIT/数据库集成通过。**
## 5. 实际命令
在 service 仓库执行:
```powershell
git status --short
git branch --show-current
git rev-parse HEAD
git diff --binary
mvn -version
# 生产缺陷仍存在,仅将测试改为调用真实 Service;预期失败,exit=1
mvn -o '-Dmaven.repo.local=D:\SoftProject\yunhai\.m2\repository' '-Dtest=FlDefLineOfflineTest' test
# 修复后定向验证;exit=0
mvn -o '-Dmaven.repo.local=D:\SoftProject\yunhai\.m2\repository' '-Dtest=FlDefLineOfflineTest,FlNewInstancePathTest,FlApplyRecoveryTest#createsWhenAbsentAndStampsIdentity,FlAdvanceIntentRecoveryTest,FlStateEventRecoveryTest,CallbackRecoveryDeliveryTest' test
git diff --check -- src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
git diff --no-index --check docs/evidence/core-fix-20261027/FlDefLineOfflineTest.before.java.txt src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
git apply --reverse --check docs/evidence/core-fix-20261027/task-only.patch
```
**已核实**JDK `1.8.0_121`、Maven `3.9.16`;复用了既有私有依赖本地仓库,离线构建测试可用,没有变更依赖或全局配置。末项只检查补丁,不应用补丁;最终 exit=0,见 [patch-check.txt](evidence/core-fix-20261027/patch-check.txt)。补丁初次导出为 CRLF 导致检查失败,改为 UTF-8/LF 后通过,Java 源码不受此导出修正影响。
## 6. 环境阻塞与未处理事项
**已核实**使用 `Get-Command docker`、环境变量存在性检查、`System.Net.Sockets.TcpClient.ConnectAsync('127.0.0.1', port)` 检查本地已知测试入口;证据见 [environment.txt](evidence/core-fix-20261027/environment.txt):
- Docker 命令不可用,`CI_TEST_DB_URL` / `SPRING_DATASOURCE_URL` 未配置。
- `127.0.0.1:3306` 与 `127.0.0.1:13429` 未连接成功。这仅说明所查入口不可用,不声称全机不存在其他数据库。
- 当前 `application-test.yml` 指向本机测试库,`FlowEngineIT` 又要求端口 13429 / `fl_d09_20261026`;共享 `FlowTestSupport.cleanDb()` 含全表删除。未启动此类 IT,未尝试使用开发或生产库替代。
因此以下为**待核实(环境阻塞)**:停发提交后 `line_offline=1`、恢复提交后 `line_offline=0` 的数据库刷新/回读;真实仓储批量更新的全版本/学校隔离;真实事务下新实例、任务推进与回调提交链。所需条件是明确隔离、可写且专用的测试数据库及连接参数,后续测试应只创建新测试记录并限定清理范围,禁止直接运行共享清库 helper。
**已核实(静态检查)**仓储 `updateLineOffline` JPQL 仅更新匹配 `schId + flowGuid` 的 `lineOffline`,不更新发布状态、版本、内容或实例。数据库行为仍需上项补证。
未运行 C01 八项、`CallbackDispatchIT` 七项或完整 CI;本次只有一个管理守卫字符变更,定向回归已覆盖直接影响。历史任务 339、notice.id=78、旧激活意图回填、历史事件补发、重置数据、D14 均不在范围。未发现需要扩展异常语义的已证实新路径缺陷。
## 7. WorkBuddy 联调说明(准备完成,未执行外部写入)
在获准的隔离测试环境、使用新数据,按以下顺序复验:
1. 用本校管理员创建/发布一条测试流程线,先发起新实例并记录 defId/version、任务 ID;确认该任务在 `flw_advance_intent.activation_task_ids` 中有本次激活证明。
2. `POST /api/flow/admin/def/offlineLine`,JSON `{"flowGuid":"实际测试GUID"}`。学校来自登录管理员上下文,不从请求体指定。期望返回受影响行数;读本校该 GUID 全版本 `flw_def.line_offline` 应全为 1。
3. 刷新页面并用 `GET /api/flow/admin/def/versions?flowGuid=...` 回读,仍为停发;新发起和选人预览应提示停发。继续合法办理第 1 步的新实例,在途仍应按原版本推进完成。
4. `POST /api/flow/admin/def/enableLine`,同一 JSON。期望受影响行数,刷新/数据库回读全版本 `line_offline=0`;之后新发起使用最高已发布版。
5. 对照测试前后各版本 `status/version/content_json` 及原实例 `def_id/def_version` 应保持不变;空白、不存在 GUID 和另一学校管理员访问应被拒绝且不更新。
路由如有服务 context-path,再加环境配置中的前缀。观察 `flw_instance`、`flw_task`、`flw_advance_intent` 的新记录身份/状态,以及对应新 `flw_state_event`、`flw_callback_log` 的稳定 event_id 和投递状态;本次用少量定向新事件,无需重跑六类 36 项矩阵。若出现 D09 无激活证明,先核对是否确为新建实例及其 TASK_CREATED 注册,禁止对旧任务绕过 fail-closed。
## 8. 交付边界
已交付:最小生产修复、真实调用回归测试、新实例路径测试、实际红/绿日志、可复核代码差异与本回执。未执行历史回填、生产数据库操作、部署或 Git commit/push。数据库与环境联调阻塞不影响已完成代码工作;最终 FL 代码审计仍待前端交付和 WorkBuddy 集成复验后另行安排。
@@ -0,0 +1,259 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.AssigneeResolver;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwCcRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 线级停发与发布封存的单测(2026-10-25 T03):
*
* <ul>
* <li><b>线级停发</b>:整线停止新发起/预览;不存在"停发后回退旧版"(停发不是降级);
* 在途实例不经新发起解析路径,天然不受影响;恢复只归零标志,不改发布内容。</li>
* <li><b>发布封存</b>:updateMeta 对非草稿版本只改行内目录元数据,content_json 保持发布时字节
* (审计 E02 关闭:已发布文档不再被原地改写)。</li>
* </ul>
*/
@DisplayName("线级停发/恢复 + 发布封存(T03)")
class FlDefLineOfflineTest {
private static final Long SCH = 1L;
private static final String GUID = "g-line";
private FlwDefRepository defRepository;
private FlwDefAdminServiceSpy adminService;
private OapiFlowService oapiService;
private FlwInstanceRepository instances;
private com.yunhai.flowcenter.service.FlowInstanceService instanceService;
/** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */
static class FlwDefAdminServiceSpy {
final FlwDefRepository defRepository;
FlwDefAdminServiceSpy(FlwDefRepository defRepository) {
this.defRepository = defRepository;
}
int offlineLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
}
int enableLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
"流程线不存在:" + flowGuid);
}
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
}
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
defRepository = mock(FlwDefRepository.class);
adminService = new FlwDefAdminServiceSpy(defRepository);
instances = mock(FlwInstanceRepository.class);
instanceService = mock(FlowInstanceService.class);
oapiService = new OapiFlowService(defRepository, instances,
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
mock(FlwCallbackRegRepository.class), instanceService, mock(FlowTaskService.class),
mock(FlowEngine.class), mock(AssigneeResolver.class), new ObjectMapper(),
mock(OpenApiUserClient.class), mock(OpenApiAppClient.class),
mock(DiagramTokenService.class), mock(ObjectProvider.class));
when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(eq(SCH), eq(GUID)))
.thenReturn(Optional.of(def(9, 3, 0)));
}
// ==================== 线级停发/恢复(管理面委托) ====================
@Test
@DisplayName("offlineLine:批量置 1;enableLine:批量归零")
void lineOfflineDelegatesBulkUpdate() {
when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3);
assertThat(adminService.offlineLine(SCH, GUID)).isEqualTo(3);
verify(defRepository).updateLineOffline(SCH, GUID, 1);
when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3);
assertThat(adminService.enableLine(SCH, GUID)).isEqualTo(3);
verify(defRepository).updateLineOffline(SCH, GUID, 0);
}
@Test
@DisplayName("不存在的流程线:拒绝且不出更新")
void unknownLineRejected() {
assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope"))
.isInstanceOf(BizException.class)
.hasMessageContaining("流程线不存在");
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
}
// ==================== 新发起/预览的线级守卫 ====================
@Test
@DisplayName("线停发:applyV2 新发起被拒且不建实例(不回退旧版)")
void applyRejectedWhenLineOffline() {
FlwDef offline = def(9, 3, 1);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(offline));
FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2();
body.setFlowGuid(GUID);
body.setBizType("svc-hall");
body.setBizId("42");
body.setSubmitterId(7);
assertThatThrownBy(() -> oapiService.applyV2(SCH, "100018", body))
.isInstanceOf(BizException.class)
.hasMessageContaining("停发");
verify(instanceService, never()).applyGuarded(any(), any());
}
@Test
@DisplayName("线在线:新发起照常解析最高已发布版")
void applyPassesWhenLineOnline() {
FlwDef online = def(9, 3, 0);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(online));
when(instanceService.applyGuarded(any(), any()))
.thenReturn(new FlowInstanceService.GuardedOutcome(
500L, false, FlowApplyRetryModels.Branch.CREATED, 1));
FlowApplyRetryModels.ApplyRequestV2 body = new FlowApplyRetryModels.ApplyRequestV2();
body.setFlowGuid(GUID);
body.setBizType("svc-hall");
body.setBizId("42");
body.setSubmitterId(7);
FlowApplyRetryModels.ApplyResultV2 vo = oapiService.applyV2(SCH, "100018", body);
assertThat(vo.getInstanceId()).isEqualTo(500L);
ArgumentCaptor<FlowInstanceService.ApplyCommand> cmd =
ArgumentCaptor.forClass(FlowInstanceService.ApplyCommand.class);
verify(instanceService).applyGuarded(cmd.capture(), any());
assertThat(cmd.getValue().def.getId()).isEqualTo(9L);
}
@Test
@DisplayName("预览同口径:线停发拒绝(选人预览不是新发起旁路)")
void previewRejectedWhenLineOffline() {
FlwDef offline = def(9, 3, 1);
when(defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
eq(SCH), eq(GUID), eq(FlwEnums.DefStatus.PUBLISHED.getCode())))
.thenReturn(Optional.of(offline));
assertThatThrownBy(() -> oapiService.applyPreview(SCH, GUID, 7))
.isInstanceOf(BizException.class)
.hasMessageContaining("停发");
}
// ==================== 发布封存(updateMeta) ====================
@Test
@DisplayName("updateMeta 对已发布版本:行 name/appKey 可改,content_json 保持发布字节")
void updateMetaFreezesPublishedDocument() throws Exception {
FlowDefAdminService service = newFlowDefAdminService();
FlwDef published = def(9, 3, FlwEnums.DefStatus.PUBLISHED.getCode());
published.setName("旧名");
String originalJson = published.getContentJson();
when(defRepository.findById(9L)).thenReturn(Optional.of(published));
when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0));
ReflectionTestUtils.setField(service, "appRepository", appRepo(true));
FlwDef saved = service.updateMeta(SCH, 9L, "新名", null);
assertThat(saved.getName()).isEqualTo("新名");
assertThat(saved.getContentJson()).isEqualTo(originalJson);
}
@Test
@DisplayName("updateMeta 对草稿:content_json 顶层 name 照旧同步(草稿文档可变)")
void updateMetaStillSyncsDraftDocument() throws Exception {
FlowDefAdminService service = newFlowDefAdminService();
FlwDef draft = def(9, 3, 0);
draft.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
draft.setName("旧名");
draft.setContentJson("{\"name\":\"旧名\",\"nodes\":[],\"edges\":[]}");
when(defRepository.findById(9L)).thenReturn(Optional.of(draft));
when(defRepository.save(any())).thenAnswer(c -> c.getArgument(0));
ReflectionTestUtils.setField(service, "appRepository", appRepo(true));
FlwDef saved = service.updateMeta(SCH, 9L, "新名", null);
assertThat(saved.getContentJson()).contains("\"新名\"");
}
// ==================== fixtures ====================
private FlowDefAdminService newFlowDefAdminService() {
// 依赖协作全部 mock(构造器形参多,反射装配与生产 Spring 无关,只测本类逻辑)
return new FlowDefAdminService(
defRepository,
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
appRepo(true),
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper(),
mock(FormCenterSchemaClient.class));
}
private com.yunhai.flowcenter.repository.FlwAppRepository appRepo(boolean enabled) {
com.yunhai.flowcenter.repository.FlwAppRepository repo =
mock(com.yunhai.flowcenter.repository.FlwAppRepository.class);
when(repo.isEnabledApp(anyLong(), anyString())).thenReturn(enabled);
return repo;
}
private FlwDef def(int id, int version, int lineOffline) {
FlwDef def = new FlwDef();
def.setId((long) id);
def.setSchId(SCH);
def.setFlowGuid(GUID);
def.setCode("leave");
def.setName("请假");
def.setVersion(version);
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setLineOffline(lineOffline);
def.setContentJson("{\"nodes\":[], \"edges\":[], \"start\":\"n1\"}");
return def;
}
}
@@ -0,0 +1,462 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.DefValidator;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.VariableContract;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 流程定义管理(M1:JSON 文本编辑模式,概览 §9.2):
* 保存=草稿直改/已发布升版本(flowGuid 跨版本不变);发布跑静态校验(把"任务无主"拦在发布期)。
*/
@Service
@RequiredArgsConstructor
public class FlowDefAdminService {
private final FlwDefRepository defRepository;
private final com.yunhai.flowcenter.repository.FlwRouterRepository routerRepository;
private final com.yunhai.flowcenter.repository.FlwCallbackRegRepository regRepository;
private final com.yunhai.flowcenter.repository.FlwAppRepository appRepository;
private final com.yunhai.flowcenter.repository.FlwInstanceRepository instanceRepository;
private final FlowDeptAdminService deptAdminService;
private final FlowEngine flowEngine;
private final ObjectMapper objectMapper;
private final FormCenterSchemaClient formCenterSchemaClient;
/** 定义列表:status/appKey 可选过滤(各流程线各版本都展示;appKey 精确匹配,2026-10-10 按应用筛选) */
public List<FlwDef> list(Long schId, Integer status, String appKey) {
List<FlwDef> rows = status != null
? defRepository.findBySchIdAndStatusOrderByUpdateTimeDesc(schId, status)
: defRepository.findBySchIdOrderByUpdateTimeDesc(schId);
String key = appKey == null ? null : appKey.trim();
if (key != null && !key.isEmpty()) {
rows = rows.stream().filter(d -> key.equals(d.getAppKey())).collect(Collectors.toList());
}
return rows;
}
/** 应用清单("按应用筛选"下拉数据源):本校 flw_def 已标注 ∪ 已注册回调的应用,去重排序 */
public List<String> distinctAppKeys(Long schId) {
TreeSet<String> keys = new TreeSet<>();
keys.addAll(defRepository.findDistinctAppKeys(schId));
keys.addAll(regRepository.findDistinctAppKeysBySchId(schId));
return new ArrayList<>(keys);
}
/** 全量列表(管理端首页:各流程线最新版本+状态聚合展示简化为全量返回) */
public List<FlwDef> listAll(Long schId) {
return defRepository.findPublishedLatestPerGuid(schId);
}
public FlwDef get(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
return def;
}
/**
* 保存(2026-10-10 起唯一键为 uk(sch,flow_guid,version),编码降级为可重名的分类标签):
* - id 非空:草稿直改;已发布/停用=复制升版本(同 flowGuid,version=max+1)
* - id 空 + baseGuid 非空:在该流程线上开新版本草稿(version=max+1,flowGuid 不变)——前端"升版本"
* - id 空:永远新开一条流程线(flowGuid 生成,version=1);同编码多条线合法,前端保存前有软提示
* appKey=归属应用(筛选维度,2026-10-12 拍板必填):新建必须显式传;升版本/直改可继承源行,
* 但源行为空时必须显式补填——存量 NULL 线随编辑自然收敛(白名单只校验显式传入值,继承值不重校)
* changeLog=升版本改动说明(议题二③):只随新建行落库(三个建新行分支),草稿直改行不写也不清空
*/
@Transactional
public FlwDef save(Long schId, Long id, String code, String name, String contentJson,
String baseGuid, String appKey, String changeLog) {
String cleanLog = (changeLog == null || changeLog.trim().isEmpty()) ? null
: changeLog.trim().substring(0, Math.min(500, changeLog.trim().length()));
String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim();
// 归属应用白名单(2026-10-10 应用设置):显式传入非空时必须 ∈ flw_app(sch, enabled=1);
// 继承值不重校(既成事实,应用停用不应卡死升版本)。不在校池/未开启的应用由此拦下(拍板 D2:不做存量兜底)
if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试");
}
// JSON 先解析再落库(语法错误不进库)
FlowDefModel parsed = FlowDefModel.parse(objectMapper, contentJson);
if (code == null || code.trim().isEmpty() || name == null || name.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "code/name 必填");
}
FlwDef def;
if (id == null && baseGuid != null && !baseGuid.trim().isEmpty()) {
// 在既有流程线上开新版本草稿(appKey 缺省继承源线)
FlwDef base = defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, baseGuid.trim())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "baseGuid 流程线不存在"));
def = new FlwDef();
def.setSchId(schId);
def.setFlowGuid(baseGuid.trim());
def.setCode(code.trim());
def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1);
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey());
def.setFormUuid(base.getFormUuid());
def.setFormVersion(base.getFormVersion());
} else if (id == null) {
// 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用)
def = new FlwDef();
def.setSchId(schId);
def.setFlowGuid(UUID.randomUUID().toString().replace("-", "").toLowerCase());
def.setCode(code.trim());
def.setVersion(1);
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
def.setAppKey(cleanAppKey);
} else {
def = requireDef(schId, id);
if (!def.getCode().equals(code.trim())) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程编码不可修改");
}
if (cleanAppKey != null) {
def.setAppKey(cleanAppKey);
}
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
// 升版本:复制行,保持 flowGuid
FlwDef next = new FlwDef();
next.setSchId(schId);
next.setFlowGuid(def.getFlowGuid());
next.setCode(def.getCode());
next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1);
next.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
next.setAppKey(cleanAppKey != null ? cleanAppKey : def.getAppKey());
next.setFormUuid(def.getFormUuid());
next.setFormVersion(def.getFormVersion());
def = next;
}
}
def.setCode(code.trim());
def.setName(name.trim());
// 归属应用必填(2026-10-12 拍板):各分支归一后落库值不得为空——新建必须显式传;
// 升版本/直改继承源行,源行为空的存量线编辑时强制补填(19 条存量随编辑自然收敛)
if (def.getAppKey() == null || def.getAppKey().trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"归属应用必填:请选择归属应用后再保存(应用需先在 流程管理 > 应用设置 中开启)");
}
// 改动说明只写新建行(新建/baseGuid 开版/已发布复制升版三分支 id 必为 null);草稿直改不动
if (def.getId() == null) {
def.setChangeLog(cleanLog);
}
def.setContentJson(withoutFormContract(contentJson)); // Only publish may author a contract snapshot.
// 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主)
if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) {
def.setCode(parsed.code.trim());
}
if (parsed.name != null && !parsed.name.trim().isEmpty()) {
def.setName(parsed.name.trim());
}
try {
return defRepository.save(def);
} catch (DataIntegrityViolationException e) {
// 撞唯一键 (sch,flow_guid,version):并发升版本同一线时可能撞号,转可理解提示(原样抛出=笼统"内部服务错误")
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
}
}
/**
* 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。<br>
* <b>发布后不可改,改=升版本</b>由既有升版分派天然保证(非 DRAFT 直改必走升版新行);
* 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库);
* 非空=设置,保存前按确切版本远端核验,失败不写入。
* <br>v3/TASK_1 追加 {@code formVersion}:与 formUuid <b>成对</b>,规则是——
* ① 两个都给了值=设关联(版本按服务端收到的整数存,之后发布按这组确切坐标核验);
* ② 只给一个=拒(半个坐标既存不下也没法核验,比"存了再说"便宜);
* ③ formUuid 给空串=取消关联,<b>同时清空版本</b>(留着一个没人认领的版本号是下一轮误用的起点);
* ④ formUuid 给 null=这条指令不存在(两个坐标都不动,兼容旧调用面)。
*/
@Transactional
public FlwDef save(Long schId, Long id, String code, String name, String contentJson,
String baseGuid, String appKey, String changeLog, String formUuid, Integer formVersion) {
boolean clearing = formUuid != null && formUuid.trim().isEmpty();
boolean pairing = formUuid != null && !clearing && formVersion == null;
boolean versionOnly = formUuid == null && formVersion != null;
if (pairing || versionOnly) {
throw new BizException(formVersion == null ? FormBindingErrorCode.VERSION_REQUIRED
: FormBindingErrorCode.INVALID_COORDINATE,
"关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid="
+ (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion);
}
if (formUuid != null && !clearing) {
checkedSchema(schId, formUuid.trim(), formVersion, "保存"); // Validate before the first save.
}
FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
if (formUuid != null) {
String clean = formUuid.trim();
saved.setFormUuid(clean.isEmpty() ? null : clean);
saved.setFormVersion(clean.isEmpty() ? null : formVersion);
}
try {
return defRepository.save(saved);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试");
}
}
/** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布。<br>
* 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照
* 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验),
* 并做表达式<b>键查①</b>红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试);
* 运行期对 form-center 零依赖;新发布的无绑定版本必须显式声明 variables。 */
@Transactional
public FlwDef publish(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态");
}
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿");
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
Map<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
def.getFormVersion(), "发布");
model.variables = VariableContract.fromSnapshot(snapshot);
VariableContract.validateDefinition(model, true);
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
def.setContentJson(embedFormContract(def.getContentJson(), snapshot, model.variables));
} else if (def.getFormVersion() != null) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
}
if (def.getFormUuid() == null || def.getFormUuid().trim().isEmpty()) {
VariableContract.validateDefinition(model, true);
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
private String embedFormContract(String contentJson, Map<String, Object> snapshot,
List<FlowDefModel.Variable> variables) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.set("formContract", objectMapper.valueToTree(snapshot));
root.set("variables", objectMapper.valueToTree(variables));
return objectMapper.writeValueAsString(root);
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "快照嵌入失败:" + e.getMessage());
}
}
/** 停用:不可新发起,存量跑完 */
@Transactional
public void offline(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
if (def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "草稿不能停用后启用,请先通过发布校验");
}
def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
defRepository.save(def);
}
/**
* 线级停发(2026-10-25 T03):整条 flowGuid 停止新发起/预览,在途实例按各自固定 defId 原版跑完。
* 与版本级 {@link #offline} 正交(可叠加);批量 UPDATE 全版本行,读任一行即读线状态。
* 不改写任何发布内容字节,恢复走 {@link #enableLine}。
*/
@Transactional
public int offlineLine(Long schId, String flowGuid) {
requireLine(schId, flowGuid);
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
}
/**
* 线级恢复(T03):只恢复线的可用性——line_offline 归零;各版本 status/发布内容/版本指针
* 一律不动(停发期间发布新版不受影响,恢复后新发起即用当时最高已发布版)。
*/
@Transactional
public int enableLine(Long schId, String flowGuid) {
requireLine(schId, flowGuid);
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
}
private void requireLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
}
}
/**
* 启用(2026-09-18):只对"停用"版恢复为已发布,与 offline 对称。
* 复用发布期的静态校验(把"任务无主"重新拦一次)——若建的时候某科室还有启用办理人、之后
* 被停用,这里会点名报错(如"科室 X 无启用办理人"),管理员在办理人映射页点"启用"即可解。
*/
@Transactional
public FlwDef enable(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "该流程已是发布状态,无需启用");
}
if (def.getStatus() != FlwEnums.DefStatus.DISABLED.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "仅停用的流程可启用;草稿请走发布");
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "启用");
} else if (def.getFormVersion() != null) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version, String operation) {
if (version == null) {
throw new BizException(FormBindingErrorCode.VERSION_REQUIRED, "关联表单缺少明确版本,"
+ operation + "已阻断,请重新选择表单与版本");
}
if (version < 1 || uuid == null || uuid.trim().isEmpty()) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE,
"关联表单必须选择确切的正整数版本," + operation + "已阻断,请重新选择");
}
try {
return formCenterSchemaClient.fetchSchema(schId, uuid, version);
} catch (FormCenterSchemaClient.SchemaUnavailableException e) {
throw new BizException(FormBindingErrorCode.SCHEMA_UNAVAILABLE,
"关联表单快照拉取失败," + operation + "已阻断(可重试)", e);
} catch (FormCenterSchemaClient.SchemaContractViolationException e) {
throw new BizException(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION,
"关联表单版本核验失败," + operation + "已阻断(请重新选择表单与版本)", e);
} catch (FormCenterSchemaClient.SchemaResourceUnavailableException e) {
throw new BizException(FormBindingErrorCode.RESOURCE_UNAVAILABLE,
"关联表单或指定发布版本不存在或不可读," + operation + "已阻断", e);
} catch (FormCenterSchemaClient.SchemaAccessDeniedException e) {
throw new BizException(FormBindingErrorCode.ACCESS_DENIED,
"关联表单访问身份或授权被拒绝," + operation + "已阻断,请检查授权", e);
}
}
private String withoutFormContract(String contentJson) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.remove("formContract");
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义必须是有效的JSON对象");
}
}
/** 同一流程线全部版本(降序) */
public List<FlwDef> versions(Long schId, String flowGuid) {
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid);
}
/** 在途办件数(议题二④):flowGuid → 审批中(10)办件数;口径=def_id 精确匹配该线各版本;请求的 guid 全部有键(无在途=0) */
public Map<String, Long> inflightCounts(Long schId, Collection<String> flowGuids) {
Map<String, Long> result = new HashMap<>();
if (flowGuids == null || flowGuids.isEmpty()) return result;
flowGuids.forEach(g -> result.put(g, 0L));
List<FlwDef> defs = defRepository.findBySchIdAndFlowGuidIn(schId, flowGuids);
if (defs.isEmpty()) return result;
Map<Long, String> idToGuid = new HashMap<>();
defs.forEach(d -> idToGuid.put(d.getId(), d.getFlowGuid()));
instanceRepository.countApprovingByDefIds(schId, idToGuid.keySet()).forEach(r -> {
Long defId = ((Number) r[0]).longValue();
Long cnt = ((Number) r[1]).longValue();
result.merge(idToGuid.get(defId), cnt, Long::sum);
});
return result;
}
/**
* 元数据原地编辑(2026-10-10 四议题·议题2,拍板 D4):已发布/停用/草稿均可只改 name/appKey
* 而<b>不升版本</b>——结构改动仍走 save 的升版本路径,两者完全隔离(本方法不碰
* code/flow_guid/version/status,不进升版本分支)。appKey 校验同 save 口径(显式传入须在白名单)。
* content_json 顶层 name 同步更新(只动顶层字段不动结构,消除"行 name 与 JSON name 的脏窗口";
* 亦修复 API 直调方经 parsed.name 覆盖行字段时读到旧名的口径)。
* 并发说明:与升版本路径无共享可变行(升版本建新行),不加锁。
*/
@Transactional
public FlwDef updateMeta(Long schId, Long id, String name, String appKey) {
FlwDef def = requireDef(schId, id);
if (name == null || name.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程名称不能为空");
}
String cleanName = name.trim();
String cleanAppKey = (appKey == null || appKey.trim().isEmpty()) ? null : appKey.trim();
if (cleanAppKey != null && !appRepository.isEnabledApp(schId, cleanAppKey)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"归属应用未开启或不存在(" + cleanAppKey + "):请在 流程管理 > 应用设置 中开启后重试");
}
def.setName(cleanName);
def.setAppKey(cleanAppKey);
// 发布封存(2026-10-25 T03):非草稿版本的执行文档不可变——行 name 照改(目录元数据),
// content_json 顶层 name 保持发布时字节,消除"已发布文档被原地改写"的旁路
//(审计 E02:updateMeta 曾同步改已发布 contentJson 顶层 name)。
if (def.getStatus() != null && def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName));
}
try {
return defRepository.save(def);
} catch (DataIntegrityViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时编辑,请刷新后重试");
}
}
/** contentJson 顶层 name 同步(ObjectNode 保字段顺序;结构零改动),返回新 JSON 串 */
private String syncTopLevelName(String contentJson, String name) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.put("name", name);
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
// contentJson 在 save 期已保证可解析;防御口径:解析失败不同步(行 name 已更新,JSON 维持原值)
return contentJson;
}
}
private Set<String> enabledDeptKeys(Long schId) {
// 2026-10-10 科室管理切源:发布校验白名单改查 flw_dept/flw_dept_member(拍板 D1)
return deptAdminService.enabledDeptKeys(schId);
}
/** CONDITION routerKey 发布校验白名单(flw_router 启用键全集,全局不分校) */
private Set<String> enabledRouterKeys() {
return routerRepository.findByStatusOrderByIdAsc(1).stream()
.map(com.yunhai.flowcenter.entity.FlwRouter::getRouterKey)
.collect(Collectors.toSet());
}
private FlwDef requireDef(Long schId, Long id) {
FlwDef def = defRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义不存在:" + id));
if (!def.getSchId().equals(schId)) {
throw new BizException(ErrorCode.FORBIDDEN, "流程定义不属于该学校");
}
return def;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
M .gitea/workflows/build.yml
M pom.xml
M src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java
M src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java
M src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java
M src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
M src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java
M src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
M src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java
M src/main/java/com/yunhai/flowcenter/engine/DefValidator.java
M src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java
M src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java
M src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
M src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
M src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
M src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
M src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
M src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
M src/main/java/com/yunhai/flowcenter/model/OapiModels.java
M src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java
M src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
M src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java
M src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
M src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
M src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java
M src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java
M src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java
M src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
M src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java
M src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java
M src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java
M src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java
M src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java
M src/test/resources/application-test.yml
?? ci/
?? docs/form-binding-contract.md
?? src/main/java/com/yunhai/flowcenter/callback/CallbackFactStore.java
?? src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java
?? src/main/java/com/yunhai/flowcenter/callback/FlowEventIdentityListener.java
?? src/main/java/com/yunhai/flowcenter/callback/FlwStateEventRecorder.java
?? src/main/java/com/yunhai/flowcenter/callback/FormalCoordinateReleaseListener.java
?? src/main/java/com/yunhai/flowcenter/config/FlowSourceScopeConfiguration.java
?? src/main/java/com/yunhai/flowcenter/controller/oapi/FormalApplyCommandController.java
?? src/main/java/com/yunhai/flowcenter/engine/VariableContract.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwAdvanceIntent.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwApplyCommand.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwBizCoordinate.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwCommandSource.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwStateEvent.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwUsageGrant.java
?? src/main/java/com/yunhai/flowcenter/enums/FormBindingErrorCode.java
?? src/main/java/com/yunhai/flowcenter/model/FormalApplyModels.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwAdvanceIntentRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwApplyCommandRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwBizCoordinateRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwCommandSourceRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwStateEventRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java
?? src/main/java/com/yunhai/flowcenter/service/ApplyVariableValidator.java
?? src/main/java/com/yunhai/flowcenter/service/FlowActorContextGate.java
?? src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java
?? src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java
?? src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java
?? src/main/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizer.java
?? src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java
?? src/main/resources/sql/20261025_add_flw_def_line_offline.sql
?? src/main/resources/sql/20261025_add_flw_instance_payload_hash.sql
?? src/main/resources/sql/20261025_create_flw_state_event_and_intent.sql
?? src/main/resources/sql/20261025_create_flw_usage_grant.sql
?? src/main/resources/sql/20261025_drop_event_seq_uk.sql
?? src/main/resources/sql/20261025_fix_flw_callback_log_uk.sql
?? src/main/resources/sql/20261026_d09_activation_intent_draft.sql
?? src/main/resources/sql/20261026_formal_commands_draft.sql
?? src/test/java/com/yunhai/flowcenter/callback/CallbackConcurrentPushIT.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackFactDispatchIT.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackRecoveryDeliveryTest.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java
?? src/test/java/com/yunhai/flowcenter/callback/FlStateEventRecoveryTest.java
?? src/test/java/com/yunhai/flowcenter/engine/AssigneeResolverEligibilityTest.java
?? src/test/java/com/yunhai/flowcenter/engine/FlAdvanceIntentRecoveryTest.java
?? src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java
?? src/test/java/com/yunhai/flowcenter/service/FlActorContextGateTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java
?? src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java
?? src/test/java/com/yunhai/flowcenter/service/FormalFlowHttpFixtureMain.java
?? src/test/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizerTest.java
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest
-------------------------------------------------------------------------------
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.69 s - in com.yunhai.flowcenter.callback.CallbackRecoveryDeliveryTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.callback.FlStateEventRecoveryTest
-------------------------------------------------------------------------------
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.01 s - in com.yunhai.flowcenter.callback.FlStateEventRecoveryTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest
-------------------------------------------------------------------------------
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.471 s - in com.yunhai.flowcenter.engine.FlAdvanceIntentRecoveryTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.service.FlApplyRecoveryTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.093 s - in com.yunhai.flowcenter.service.FlApplyRecoveryTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.service.FlDefLineOfflineTest
-------------------------------------------------------------------------------
Tests run: 14, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.394 s - in com.yunhai.flowcenter.service.FlDefLineOfflineTest
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.yunhai.flowcenter.service.FlNewInstancePathTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.078 s - in com.yunhai.flowcenter.service.FlNewInstancePathTest
@@ -0,0 +1,6 @@
Checked: 2026-10-27 Asia/Shanghai
Docker command available: False
CI_TEST_DB_URL configured: False
SPRING_DATASOURCE_URL configured: False
127.0.0.1:3306 OPEN=False
127.0.0.1:13429 OPEN=False
@@ -0,0 +1,3 @@
4CD39FBEC874DB0A2B5EF20CA5F6A713ADF7E8D9E3B834DF4ECCDFEE51435E04 D:\SoftProject\yunhai\flow-center\service\src\main\java\com\yunhai\flowcenter\service\FlowDefAdminService.java
DB6BEBF60CD77FEF1C696527C23B65170E8EAFC108D980352361BA01EB1A0EB0 D:\SoftProject\yunhai\flow-center\service\src\test\java\com\yunhai\flowcenter\service\FlDefLineOfflineTest.java
E925A3D742801E7666FAC9C4ED1E628F8C5C91C4E9B5ED5A6B2755F1567F29DE D:\SoftProject\yunhai\flow-center\service\src\test\java\com\yunhai\flowcenter\service\FlNewInstancePathTest.java
@@ -0,0 +1,107 @@
M .gitea/workflows/build.yml
M pom.xml
M src/main/java/com/yunhai/flowcenter/callback/CallbackDispatcher.java
M src/main/java/com/yunhai/flowcenter/callback/CallbackLogStore.java
M src/main/java/com/yunhai/flowcenter/callback/CallbackPushService.java
M src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
M src/main/java/com/yunhai/flowcenter/controller/api/FlowUserController.java
M src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
M src/main/java/com/yunhai/flowcenter/engine/AssigneeResolver.java
M src/main/java/com/yunhai/flowcenter/engine/DefValidator.java
M src/main/java/com/yunhai/flowcenter/engine/FlowAdvanceService.java
M src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java
M src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java
M src/main/java/com/yunhai/flowcenter/engine/FlowEvent.java
M src/main/java/com/yunhai/flowcenter/engine/FlowNodeAdvanceListener.java
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackLog.java
M src/main/java/com/yunhai/flowcenter/entity/FlwCallbackReg.java
M src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
M src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
M src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
M src/main/java/com/yunhai/flowcenter/model/OapiModels.java
M src/main/java/com/yunhai/flowcenter/repository/FlwCallbackLogRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwCcRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwDefRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java
M src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java
M src/main/java/com/yunhai/flowcenter/service/FlowCallbackAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
M src/main/java/com/yunhai/flowcenter/service/FlowRouterAdminService.java
M src/main/java/com/yunhai/flowcenter/service/FlowTaskService.java
M src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
M src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
M src/test/java/com/yunhai/flowcenter/callback/CallbackDispatchIT.java
M src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java
M src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java
M src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java
M src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
M src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java
M src/test/java/com/yunhai/flowcenter/service/FlowRouterAdminServiceRerouteDefTest.java
M src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java
M src/test/java/com/yunhai/flowcenter/service/OapiFlowApplyDefVersionTest.java
M src/test/java/com/yunhai/flowcenter/stub/StubUserClient.java
M src/test/java/com/yunhai/flowcenter/support/FlowTestSupport.java
M src/test/resources/application-test.yml
?? ci/
?? "docs/FL\345\220\216\347\253\257\346\240\270\345\277\203\344\277\256\345\244\215\346\211\247\350\241\214\345\233\236\346\211\247-Codex.md"
?? docs/evidence/
?? docs/form-binding-contract.md
?? src/main/java/com/yunhai/flowcenter/callback/CallbackFactStore.java
?? src/main/java/com/yunhai/flowcenter/callback/CallbackSourcePolicy.java
?? src/main/java/com/yunhai/flowcenter/callback/FlowEventIdentityListener.java
?? src/main/java/com/yunhai/flowcenter/callback/FlwStateEventRecorder.java
?? src/main/java/com/yunhai/flowcenter/callback/FormalCoordinateReleaseListener.java
?? src/main/java/com/yunhai/flowcenter/config/FlowSourceScopeConfiguration.java
?? src/main/java/com/yunhai/flowcenter/controller/oapi/FormalApplyCommandController.java
?? src/main/java/com/yunhai/flowcenter/engine/VariableContract.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwAdvanceIntent.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwApplyCommand.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwBizCoordinate.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwCommandSource.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwStateEvent.java
?? src/main/java/com/yunhai/flowcenter/entity/FlwUsageGrant.java
?? src/main/java/com/yunhai/flowcenter/enums/FormBindingErrorCode.java
?? src/main/java/com/yunhai/flowcenter/model/FormalApplyModels.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwAdvanceIntentRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwApplyCommandRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwBizCoordinateRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwCommandSourceRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwStateEventRepository.java
?? src/main/java/com/yunhai/flowcenter/repository/FlwUsageGrantRepository.java
?? src/main/java/com/yunhai/flowcenter/service/ApplyVariableValidator.java
?? src/main/java/com/yunhai/flowcenter/service/FlowActorContextGate.java
?? src/main/java/com/yunhai/flowcenter/service/FlowOapiSourceScope.java
?? src/main/java/com/yunhai/flowcenter/service/FlowSourcePrincipalResolver.java
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantAdminService.java
?? src/main/java/com/yunhai/flowcenter/service/FlowUsageGrantGuard.java
?? src/main/java/com/yunhai/flowcenter/service/FormalApplyCommandService.java
?? src/main/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizer.java
?? src/main/java/com/yunhai/flowcenter/service/UserBaseActivationClient.java
?? src/main/resources/sql/20261025_add_flw_def_line_offline.sql
?? src/main/resources/sql/20261025_add_flw_instance_payload_hash.sql
?? src/main/resources/sql/20261025_create_flw_state_event_and_intent.sql
?? src/main/resources/sql/20261025_create_flw_usage_grant.sql
?? src/main/resources/sql/20261025_drop_event_seq_uk.sql
?? src/main/resources/sql/20261025_fix_flw_callback_log_uk.sql
?? src/main/resources/sql/20261026_d09_activation_intent_draft.sql
?? src/main/resources/sql/20261026_formal_commands_draft.sql
?? src/test/java/com/yunhai/flowcenter/callback/CallbackConcurrentPushIT.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackFactDispatchIT.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackRecoveryDeliveryTest.java
?? src/test/java/com/yunhai/flowcenter/callback/CallbackSourcePolicyTest.java
?? src/test/java/com/yunhai/flowcenter/callback/FlStateEventRecoveryTest.java
?? src/test/java/com/yunhai/flowcenter/engine/AssigneeResolverEligibilityTest.java
?? src/test/java/com/yunhai/flowcenter/engine/FlAdvanceIntentRecoveryTest.java
?? src/test/java/com/yunhai/flowcenter/service/D11AssigneeEligibilityIT.java
?? src/test/java/com/yunhai/flowcenter/service/FlActorContextGateTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlApplyAssignAndVariableTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
?? src/test/java/com/yunhai/flowcenter/service/FlUsageGrantTest.java
?? src/test/java/com/yunhai/flowcenter/service/FormalApplyCommandIT.java
?? src/test/java/com/yunhai/flowcenter/service/FormalFlowHttpFixtureMain.java
?? src/test/java/com/yunhai/flowcenter/service/FormalPayloadCanonicalizerTest.java
@@ -0,0 +1 @@
git apply --reverse --check task-only.patch exit=0
@@ -0,0 +1,281 @@
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
index da109e3..3837c1a 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
@@ -299,7 +299,7 @@ public class FlowDefAdminService {
private void requireLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
- || defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
+ || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
}
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
index 76bbf34..3cff9e9 100644
--- a/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
+++ b/src/test/java/com/yunhai/flowcenter/service/FlDefLineOfflineTest.java
@@ -18,6 +18,9 @@ import com.yunhai.platform.openapi.base.OpenApiUserClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
@@ -33,7 +36,9 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
@@ -53,43 +58,17 @@ class FlDefLineOfflineTest {
private static final String GUID = "g-line";
private FlwDefRepository defRepository;
- private FlwDefAdminServiceSpy adminService;
+ private FlowDefAdminService adminService;
private OapiFlowService oapiService;
private FlwInstanceRepository instances;
private com.yunhai.flowcenter.service.FlowInstanceService instanceService;
- /** 仅暴露 protected/私有依赖注入的薄壳:真实 FlowDefAdminService 构造依赖过多,用 spy 隔离外部协作 */
- static class FlwDefAdminServiceSpy {
- final FlwDefRepository defRepository;
- FlwDefAdminServiceSpy(FlwDefRepository defRepository) {
- this.defRepository = defRepository;
- }
- int offlineLine(Long schId, String flowGuid) {
- if (flowGuid == null || flowGuid.trim().isEmpty()
- || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
- .isPresent()) {
- throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
- "流程线不存在:" + flowGuid);
- }
- return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
- }
- int enableLine(Long schId, String flowGuid) {
- if (flowGuid == null || flowGuid.trim().isEmpty()
- || !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
- .isPresent()) {
- throw new BizException(com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR,
- "流程线不存在:" + flowGuid);
- }
- return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
- }
- }
-
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
defRepository = mock(FlwDefRepository.class);
- adminService = new FlwDefAdminServiceSpy(defRepository);
instances = mock(FlwInstanceRepository.class);
+ adminService = newFlowDefAdminService();
instanceService = mock(FlowInstanceService.class);
oapiService = new OapiFlowService(defRepository, instances,
mock(FlwTaskRepository.class), mock(FlwCcRepository.class),
@@ -121,9 +100,61 @@ class FlDefLineOfflineTest {
assertThatThrownBy(() -> adminService.offlineLine(SCH, "g-nope"))
.isInstanceOf(BizException.class)
.hasMessageContaining("流程线不存在");
+ assertThatThrownBy(() -> adminService.enableLine(SCH, "g-nope"))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("流程线不存在");
+ verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
+ }
+
+ @ParameterizedTest
+ @NullAndEmptySource
+ @ValueSource(strings = {" ", "\t\r\n"})
+ void blankGuidRejectedOnBothPaths(String guid) {
+ assertThatThrownBy(() -> adminService.offlineLine(SCH, guid))
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
+ assertThatThrownBy(() -> adminService.enableLine(SCH, guid))
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
+ verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
+ verify(defRepository, never()).findFirstBySchIdAndFlowGuidOrderByVersionDesc(any(), any());
+ }
+
+ @Test
+ void otherSchoolCannotChangeExistingLine() {
+ assertThatThrownBy(() -> adminService.offlineLine(2L, GUID))
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
+ assertThatThrownBy(() -> adminService.enableLine(2L, GUID))
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线不存在");
+ verify(defRepository, times(2)).findFirstBySchIdAndFlowGuidOrderByVersionDesc(2L, GUID);
verify(defRepository, never()).updateLineOffline(anyLong(), anyString(), anyInt());
}
+ @Test
+ void guidTrimmedForBothLookupAndUpdate() {
+ when(defRepository.updateLineOffline(SCH, GUID, 1)).thenReturn(3);
+ when(defRepository.updateLineOffline(SCH, GUID, 0)).thenReturn(3);
+ assertThat(adminService.offlineLine(SCH, " " + GUID + "\t")).isEqualTo(3);
+ assertThat(adminService.enableLine(SCH, "\t" + GUID + " ")).isEqualTo(3);
+ verify(defRepository).updateLineOffline(SCH, GUID, 1);
+ verify(defRepository).updateLineOffline(SCH, GUID, 0);
+ }
+
+ @Test
+ void lineToggleDoesNotChangePublishedDefinitionOrInflightInstances() {
+ FlwDef published = def(9, 3, 0);
+ String frozenJson = published.getContentJson();
+ when(defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(SCH, GUID))
+ .thenReturn(Optional.of(published));
+
+ adminService.offlineLine(SCH, GUID);
+ adminService.enableLine(SCH, GUID);
+
+ assertThat(published.getVersion()).isEqualTo(3);
+ assertThat(published.getStatus()).isEqualTo(FlwEnums.DefStatus.PUBLISHED.getCode());
+ assertThat(published.getContentJson()).isEqualTo(frozenJson);
+ verify(defRepository, never()).save(any());
+ verifyNoInteractions(instances, instanceService);
+ }
+
// ==================== 新发起/预览的线级守卫 ====================
@Test
@@ -229,7 +260,7 @@ class FlDefLineOfflineTest {
mock(com.yunhai.flowcenter.repository.FlwRouterRepository.class),
mock(com.yunhai.flowcenter.repository.FlwCallbackRegRepository.class),
appRepo(true),
- mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
+ instances,
mock(FlowDeptAdminService.class),
mock(FlowEngine.class),
new ObjectMapper(),
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
new file mode 100644
--- /dev/null
+++ b/src/test/java/com/yunhai/flowcenter/service/FlNewInstancePathTest.java
@@ -0,0 +1,122 @@
+package com.yunhai.flowcenter.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.flowcenter.engine.*;
+import com.yunhai.flowcenter.entity.*;
+import com.yunhai.flowcenter.repository.*;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import javax.persistence.EntityManager;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** Real service/engine/activation code with repository mocks. Does not prove DB or transaction behavior. */
+class FlNewInstancePathTest {
+ @Test
+ void freshInstanceCreatesProvenTaskAndApprovesWhileLineIsOffline() {
+ ObjectMapper mapper = new ObjectMapper();
+ FlwDefRepository defs = mock(FlwDefRepository.class);
+ FlwInstanceRepository instances = mock(FlwInstanceRepository.class);
+ FlwTaskRepository tasks = mock(FlwTaskRepository.class);
+ FlwBranchRepository branches = mock(FlwBranchRepository.class);
+ FlwAdvanceIntentRepository intents = mock(FlwAdvanceIntentRepository.class);
+ AssigneeResolver assignees = mock(AssigneeResolver.class);
+ ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class);
+ List<FlowEvent> events = new ArrayList<>();
+ doAnswer(call -> { events.add(call.getArgument(0)); return null; })
+ .when(publisher).publishEvent(any(FlowEvent.class));
+ FlowEngine engine = new FlowEngine(mapper, tasks, branches, mock(FlwCcRepository.class),
+ assignees, mock(ConditionRouteService.class), publisher);
+ FlowInstanceService apply = new FlowInstanceService(instances, defs, tasks, branches, engine, publisher);
+ FlowTaskService handle = new FlowTaskService(mapper, tasks, instances, branches, defs,
+ assignees, engine, publisher);
+ FlowAdvanceService advance = new FlowAdvanceService(instances, defs, tasks, engine, intents);
+ ReflectionTestUtils.setField(advance, "entityManager", mock(EntityManager.class));
+ FlowNodeAdvanceListener listener = new FlowNodeAdvanceListener(advance, intents);
+
+ FlwDef def = new FlwDef();
+ def.setId(7L); def.setSchId(1L); def.setFlowGuid("fresh-line");
+ def.setCode("fresh"); def.setVersion(1); def.setStatus(1); def.setLineOffline(0);
+ def.setContentJson("{\"start\":\"start\",\"nodes\":["
+ + "{\"id\":\"start\",\"type\":\"START\"},"
+ + "{\"id\":\"review\",\"type\":\"APPROVAL\",\"name\":\"Review\",\"countersign\":\"ALL\"},"
+ + "{\"id\":\"end\",\"type\":\"END\"}],"
+ + "\"edges\":[{\"from\":\"start\",\"to\":\"review\"},{\"from\":\"review\",\"to\":\"end\"}]}");
+ when(defs.findById(7L)).thenReturn(Optional.of(def));
+ when(assignees.resolve(eq(1L), any())).thenReturn(Collections.singletonList(new AssigneeRef(201, "Reviewer")));
+ AtomicReference<FlwInstance> created = new AtomicReference<>();
+ when(instances.saveAndFlush(any())).thenAnswer(call -> {
+ FlwInstance instance = call.getArgument(0);
+ if (instance.getId() == null) instance.setId(900L);
+ created.set(instance); return instance;
+ });
+ when(instances.findById(900L)).thenAnswer(call -> Optional.ofNullable(created.get()));
+ AtomicReference<FlwTask> task = new AtomicReference<>();
+ when(tasks.save(any())).thenAnswer(call -> {
+ FlwTask row = call.getArgument(0); row.setId(33L); task.set(row); return row;
+ });
+ when(tasks.findById(33L)).thenAnswer(call -> Optional.ofNullable(task.get()));
+ when(tasks.findByInstanceIdAndStatus(900L, 0)).thenAnswer(call ->
+ task.get().getStatus() == 0 ? Collections.singletonList(task.get()) : Collections.emptyList());
+ when(tasks.claimAndFinish(eq(33L), eq(1), any(), any(), any())).thenAnswer(call -> {
+ task.get().setStatus(1); task.get().setEdgeName(call.getArgument(4)); return 1;
+ });
+ AtomicReference<FlwAdvanceIntent> activation = new AtomicReference<>();
+ when(intents.save(any())).thenAnswer(call -> {
+ FlwAdvanceIntent row = call.getArgument(0); row.setId(80L); activation.set(row); return row;
+ });
+ when(intents.findByIdForUpdate(80L)).thenAnswer(call -> Optional.of(activation.get()));
+ when(intents.findByInstanceIdAndNodeIdOrderByIdDesc(900L, "review"))
+ .thenAnswer(call -> Collections.singletonList(activation.get()));
+ when(intents.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(900L, "review"))
+ .thenAnswer(call -> Optional.of(activation.get()));
+
+ FlowInstanceService.ApplyCommand command = new FlowInstanceService.ApplyCommand(
+ 1L, def, "test", "fresh-20261027", "Fresh", "/test", "{}", 101, "Applicant",
+ null, "100001", "notice", null);
+ FlowInstanceService.GuardedOutcome outcome = apply.applyGuarded(command,
+ new FlowInstanceService.ApplyRetryPolicy("fresh-token", 1, false));
+ assertThat(outcome.duplicated).isFalse();
+ assertThat(created.get().getCurrentNodes()).isEqualTo("review");
+ assertThat(task.get().getStatus()).isZero();
+ assertThat(task.get().getAssigneeId()).isEqualTo(201);
+ FlowEvent taskCreated = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_CREATED)
+ .findFirst().orElseThrow(AssertionError::new);
+ // Explicit listener harness: test synchronizations only, no real transaction/AFTER_COMMIT claim.
+ TransactionSynchronizationManager.initSynchronization();
+ try {
+ listener.persistIntent(taskCreated);
+ TransactionSynchronizationManager.getSynchronizations().forEach(sync -> sync.beforeCommit(false));
+ } finally {
+ TransactionSynchronizationManager.clearSynchronization();
+ }
+ assertThat(activation.get().containsTask(task.get().getId())).isTrue();
+ assertThat(activation.get().getActivationDefId()).isEqualTo(7L);
+ assertThat(activation.get().getActivationApplyRound()).isEqualTo(1);
+
+ // Existing tasks resolve their pinned definition even when the line is stopped for new applications.
+ def.setLineOffline(1);
+ assertThat(handle.handle(1L, 33L, true, "Approved", 201).getStatus()).isEqualTo(1);
+ FlowEvent handled = events.stream().filter(e -> e.getType() == FlowEvent.Type.TASK_HANDLED)
+ .findFirst().orElseThrow(AssertionError::new);
+ listener.persistIntent(handled);
+ listener.onTaskHandled(handled);
+ assertThat(activation.get().getStatus()).isEqualTo("DONE");
+ assertThat(activation.get().getLastError()).isNull();
+ assertThat(created.get().getStatus()).isEqualTo(20);
+ assertThat(created.get().getCurrentNodes()).isNullOrEmpty();
+ assertThat(created.get().getDefId()).isEqualTo(7L);
+ assertThat(created.get().getDefVersion()).isEqualTo(1);
+ assertThat(events).extracting(FlowEvent::getType)
+ .contains(FlowEvent.Type.SUBMITTED, FlowEvent.Type.TASK_CREATED,
+ FlowEvent.Type.TASK_HANDLED, FlowEvent.Type.COMPLETED);
+ verify(defs, never()).findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(any(), any(), any());
+ }
+}
+38
View File
@@ -0,0 +1,38 @@
# 流程定义精确表单版本契约(2026-10-23,R2 / R1)
流程关联表单时必须同时绑定 `formUuid` 与正整数 `formVersion`。保存显式关联、发布、启用统一调用
`GET /oapi/form/schema?schId=&uuid=&version=`,核对返回 UUID 和版本;不回落到当前版本。
未关联表单的独立流程(Mode C)仍可使用;孤立版本号不是未关联流程,必须拒绝。
UUID-only 存量定义不能发布或启用,必须重新选择明确版本。当前开发阶段不做存量迁移。
本轮不修改任何数据库;部署前核查 `def68` 的现有绑定即可。
FC 精确 schema 只返回 `publish_time != null` 的版本行。草稿不能作为发布契约。
已发布历史版本仍可按坐标读取,不要求它是头表当前版本;停用/软删的历史读取与原授权边界保持。
FC 将不存在、不可读、未发布版本统一为 `PARAM_ERROR(2)`,FL 不通过文案猜测资源存在性。
## 稳定错误码与恢复动作
API 信封 `code` 使用下列数值;`msg` 仅用于展示,消费方不得解析文案判断重试。
| code / 名称 | 含义 | 恢复动作 |
|---|---|---|
| 46101 `VERSION_REQUIRED` | 已指定 UUID,未指定版本 | 重新绑定;发布/启用均阻断,不调用 FC |
| 46102 `INVALID_COORDINATE` | 半个坐标、版本非正整数/非整数/溢出 | 修正请求;不盲目重试 |
| 46103 `SCHEMA_UNAVAILABLE` | 网络、空响应、服务端故障等拉取失败 | 可重试;客户端每次最多请求 2 次 |
| 46104 `SCHEMA_CONTRACT_VIOLATION` | 返回 UUID/版本缺失、不合法或不匹配 | 阻断;检查契约或重新选择坐标,不盲目重试 |
| 46105 `RESOURCE_UNAVAILABLE` | FC 业务码 2 或 HTTP 404,指定发布资源不存在或不可读 | 检查资源/发布版本/读授权,不盲目重试 |
| 46106 `ACCESS_DENIED` | SDK 身份/授权业务码 3、4 | 修复身份/授权后重试,不自动重放 |
坐标核验失败、资源不可用与身份拒绝均不在客户端重试循环内重复请求。
`SchemaUnavailableException` 与 `SchemaContractViolationException` 在公共边界分别翻译成错误码,
保留 cause;不存在被公共包装吞掉后无法命中的分类 catch。
启用只核验绑定,不覆盖此前发布的 `formContract` 快照;运行期继续使用流程版本里的原快照。
## 验证与边界
`FlFormBindingTest` 覆盖发布/启用缺版本、非法版本、孤立版本、四类失败码和不落库、启用保留快照。
`FlowDefAdminServicePublishContractTest` 全部关联夹具带 v3,原表达式键查、类型桶查、域查红牌继续验证。
`FormCenterSchemaClientTest` 覆盖不合法返回版本、UUID 不符、按业务码分类及重试次数。
FC `OapiSchemaPublishedVersionTest` 验证真实服务层的草稿阻断和历史已发布版可读;其中仓储是桩,
不是数据库或 160 验收。
@@ -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、不启动对账、不做前端视觉验收。
+12
View File
@@ -56,6 +56,10 @@
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-appearance-spring-boot-starter</artifactId>
<!-- 显式版本(2026-10-17 过夜任务环境修复,与 NoticeService 同因):该件只由 BasePlatform
的 feat/appearance-sdk 分支及其 BOM 托管,本次接入件工作基于 feat/flow-embed(BOM 无此条目),
不写版本则依赖解析直接失败、本仓无法打包。appearance 分支合流后应删本行回落 BOM。 -->
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- 平台 auth starter(0928 后快照)的 WeChatAuthAutoConfiguration 签名引用 StringRedisTemplate,
data-redis 为 optional,接入前提=应用自带(见该 starter pom 注释);缺失时 bean 签名解析直接
@@ -96,6 +100,14 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- R2(Codex D04):正常/恢复投递信封与真实接收端 FlowCallbackReceiver 的契约测试用。
仅 test 域——生产件不引入(FL 是回调生产方不是消费方);测试上下文显式关装配
(application-test.yml yh.flow-callback.enabled=false),接收端由测试手工构造。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-flow-callback-spring-boot-starter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -13,6 +13,7 @@ import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
@@ -28,10 +29,22 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 回调分发器(概览 §8.4 可靠性设计):引擎 AFTER_COMMIT 事件 → 匹配 flw_callback_reg
* (flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
* 回调分发器(概览 §8.4 可靠性设计;2026-10-25 R2 返修):引擎 AFTER_COMMIT 事件 →
* 匹配 flw_callback_reg(flowGuid + 订阅事件)→ 落 flw_callback_log(status=0) → 异步首推。
* 落库成功即视为主业务完成,推送失败不影响审批(异步解耦是特性)。
* @Scheduled(fixedDelay=30s) 扫 (status=0, next_retry_time<=now) 重推;死信留管理端手工重推。
*
* <p>R2(Codex D04)投递可靠性口径:
* <ul>
* <li><b>统一冻结信封</b>——事实行在状态事务内冻结完整投递体(含 eventId/eventSeq/flowGuid);
* 正常分发优先复用冻结体,崩溃恢复分发原样复用同一冻结体,两条路径消息体同构;恢复体
* 对 R2 前存量事实行按列值幂等补齐 eventId/eventSeq/flowGuid 后投出。</li>
* <li><b>未可靠建行不记账</b>——任一匹配订阅方的投递行建行失败(含持久化异常)时,事实行
* 不标记 dispatched:正常链不标记(恢复扫描补建),恢复链保持 dispatched=0 等下轮重扫;
* 异步首推的提交失败不回退建行结果(行已在,重试扫描接手)。</li>
* <li><b>并发幂等靠数据库唯一键</b>——(event_id, reg_id) 复合唯一约束兜底 exists→insert 竞态,
* DuplicateKeyException 一律视为"已建行"幂等成功。</li>
* </ul>
*/
@Slf4j
@Component
@@ -48,6 +61,17 @@ public class CallbackDispatcher {
private final CallbackPushService pushService;
private final CallbackProperties properties;
private final ObjectMapper objectMapper;
private final com.yunhai.flowcenter.repository.FlwStateEventRepository stateEventRepository;
private final CallbackFactStore factStore;
@org.springframework.beans.factory.annotation.Autowired
private CallbackSourcePolicy sourcePolicy;
private boolean self(FlwCallbackReg reg, FlwInstance instance) {
if (instance.getSourcePrincipalId() == null) return true; // legacy cutover is a separate migration
if (sourcePolicy == null) throw new IllegalStateException("Missing formal callback source policy");
return sourcePolicy.matches(reg, instance);
}
private volatile ExecutorService pushExecutor;
@@ -70,14 +94,129 @@ public class CallbackDispatcher {
}
try {
dispatch(event);
markFactDispatched(event);
} catch (Exception e) {
// 分发失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程
log.error("[flow-center] 回调分发异常:instance={} event={} {}",
// 分发/建行失败只影响回调(对账兜底会追平),绝不能反噬引擎主流程;
// R2:不标记 dispatched——恢复扫描稍后按事实行补建缺失的投递行
log.error("[flow-center] 回调分发异常(事实保持待补分发):instance={} event={} {}",
event.getInstanceId(), event.getType().getCode(), e.toString(), e);
}
}
private void dispatch(FlowEvent event) {
/** T05:事实行分发完成记账(崩溃窗口恢复扫描据此区分"已分发"与"待补分发") */
private void markFactDispatched(FlowEvent event) {
if (event.getStableEventId() == null) {
return; // 无事实行(存量发布路径/记录器缺席),维持旧行为
}
try {
// Cross the separate bean's proxy: the completed business transaction cannot commit this write.
factStore.markDispatched(event.getStableEventId());
} catch (Exception e) {
log.warn("[flow-center] 事件事实分发记账失败(重扫会幂等补):eventId={} {}",
event.getStableEventId(), e.toString());
}
}
/**
* T05 崩溃窗口恢复扫描:状态事务已提交但分发未及执行(进程被杀/分发异常)的事实行,
* 过缓冲期后按存量事实补分发——幂等(同 eventId 已建投递行则跳过),不重放旧轮身份
* (载荷即事实快照)。R2:任一订阅方建行失败则保持 dispatched=0,下轮重扫补齐。
*/
@Scheduled(fixedDelay = 60_000)
public void sweepUndispatchedFacts() {
try {
java.util.List<com.yunhai.flowcenter.entity.FlwStateEvent> due =
stateEventRepository.findTop50ByDispatchedAndCreateTimeLessThanEqualOrderByCreateTimeAsc(
0, java.time.LocalDateTime.now().minusSeconds(60));
for (com.yunhai.flowcenter.entity.FlwStateEvent fact : due) {
executor().submit(() -> dispatchFact(fact));
}
} catch (Exception e) {
log.warn("[flow-center] 事件事实补分发扫描异常:{}", e.toString());
}
}
@SuppressWarnings("unchecked")
private void dispatchFact(com.yunhai.flowcenter.entity.FlwStateEvent fact) {
try {
FlwInstance instance = instanceRepository.findById(fact.getInstanceId()).orElse(null);
if (instance == null) {
return;
}
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
if (def == null) {
return;
}
Map<String, Object> stored = objectMapper.readValue(fact.getPayloadJson(), Map.class);
java.util.List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
instance.getSchId(), def.getFlowGuid(), 0);
boolean allDelivered = true;
for (FlwCallbackReg reg : regs) {
if (!self(reg, instance)) continue;
if (!subscribed(reg.getEventTypes(), fact.getEventType())) {
continue;
}
if (logRepository.existsByEventIdAndRegId(fact.getEventId(), reg.getId())) {
continue; // 幂等:崩溃前已建的投递行不重建
}
try {
persistFactDelivery(reg, instance, def, fact, stored);
} catch (DuplicateKeyException dup) {
// uk(event_id, reg_id) 并发兜底:另一路径已建行,视为已投递
} catch (Exception e) {
allDelivered = false;
log.error("[flow-center] 事件事实补分发建行失败(保留待重扫):eventId={} regId={} {}",
fact.getEventId(), reg.getId(), e.toString());
}
}
if (!allDelivered) {
return; // R2:未全部可靠建行,不标记 dispatched
}
fact.setDispatched(1);
stateEventRepository.save(fact);
} catch (Exception e) {
log.warn("[flow-center] 事件事实补分发失败(下轮重扫):eventId={} {}", fact.getEventId(), e.toString());
}
}
/**
* 补分发建行:载荷以事实冻结体为准(R2 冻结信封原样复用,与正常链同构);
* R2 前存量事实行缺 eventId/eventSeq/flowGuid 时按列值幂等补齐。异步首推同正常链。
*/
private void persistFactDelivery(FlwCallbackReg reg, FlwInstance instance, FlwDef def,
com.yunhai.flowcenter.entity.FlwStateEvent fact,
Map<String, Object> stored) throws Exception {
String payloadJson;
if (stored.containsKey("eventId") && stored.containsKey("flowGuid")) {
// R2 冻结体:完整信封已含全部投递字段,原样复用(不可变)
payloadJson = fact.getPayloadJson();
} else {
// R2 前存量事实行:信封字段缺失,按事实列值+现查 flowGuid 幂等修复
Map<String, Object> repaired = new HashMap<>(stored);
repaired.put("eventId", fact.getEventId());
if (fact.getEventSeq() != null) {
repaired.put("eventSeq", fact.getEventSeq());
}
repaired.put("flowGuid", def.getFlowGuid());
payloadJson = objectMapper.writeValueAsString(repaired);
}
FlwCallbackLog callbackLog = new FlwCallbackLog();
callbackLog.setSchId(instance.getSchId());
callbackLog.setRegId(reg.getId());
callbackLog.setEventId(fact.getEventId());
callbackLog.setEvent(fact.getEventType());
callbackLog.setInstanceId(instance.getId());
callbackLog.setBizType(instance.getBizType());
callbackLog.setBizId(instance.getBizId());
callbackLog.setPayloadJson(payloadJson);
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
callbackLog.setRetryCount(0);
callbackLog.setNextRetryTime(LocalDateTime.now());
Long logId = logStore.persist(callbackLog);
submitPush(logId);
}
private void dispatch(FlowEvent event) throws Exception {
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null);
if (instance == null) {
return;
@@ -90,6 +229,7 @@ public class CallbackDispatcher {
List<FlwCallbackReg> regs = regRepository.findBySchIdAndFlowGuidAndStatus(
instance.getSchId(), def.getFlowGuid(), 0);
for (FlwCallbackReg reg : regs) {
if (!self(reg, instance)) continue;
if (!subscribed(reg.getEventTypes(), eventCode)) {
continue;
}
@@ -97,54 +237,87 @@ public class CallbackDispatcher {
}
}
private void persistAndPush(FlwCallbackReg reg, FlwInstance instance, FlwDef def, FlowEvent event) {
private void persistAndPush(FlwCallbackReg reg, FlwInstance instance, FlwDef def, FlowEvent event)
throws Exception {
// T05:eventId 用事实行稳定身份(全部订阅方/重试复用);事实行缺席(存量路径)才现场生成
String eventId = event.getStableEventId() != null ? event.getStableEventId()
: UUID.randomUUID().toString().replace("-", "").toLowerCase();
if (logRepository.existsByEventIdAndRegId(eventId, reg.getId())) {
return; // 幂等:补分发与正常分发竞争时不重复建投递行
}
// R2:优先复用事实冻结信封(与崩溃恢复链同构、消息体恒含 eventId/eventSeq);
// 冻结体缺席(存量路径/记录器缺席)才现场构建
String payloadJson = event.getStablePayloadJson() != null
? event.getStablePayloadJson()
: objectMapper.writeValueAsString(legacyEnvelope(event, instance, def, eventId));
FlwCallbackLog callbackLog = new FlwCallbackLog();
callbackLog.setSchId(instance.getSchId());
callbackLog.setRegId(reg.getId());
callbackLog.setEventId(eventId);
callbackLog.setEvent(event.getType().getCode());
callbackLog.setInstanceId(instance.getId());
callbackLog.setBizType(instance.getBizType());
callbackLog.setBizId(instance.getBizId());
callbackLog.setPayloadJson(payloadJson);
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
callbackLog.setRetryCount(0);
callbackLog.setNextRetryTime(LocalDateTime.now());
// REQUIRES_NEW 落库(afterCommit 阶段原事务已完结,REQUIRED 会挂空事务);
// R2:建行失败(含并发撞 uk)向外传播——调用方据此不标记事实已分发/保持待重扫
Long logId;
try {
String eventId = UUID.randomUUID().toString().replace("-", "").toLowerCase();
Map<String, Object> payload = new HashMap<>();
payload.put("eventId", eventId);
payload.put("event", event.getType().getCode());
payload.put("schId", instance.getSchId());
payload.put("flowGuid", def.getFlowGuid());
payload.put("instanceId", instance.getId());
payload.put("instanceVersion", instance.getVersion());
payload.put("bizType", instance.getBizType());
payload.put("bizId", instance.getBizId());
payload.put("bizTitle", instance.getBizTitle());
payload.put("occurredTime", LocalDateTime.now().format(TIME));
// 2026-09-20 信封升级:任务定位与操作人(均可空,办件级事件/历史重放体为 null——
// 契约见《子应用接入流程中心规范.md》§6.3/§6.4)
payload.put("taskId", event.getTaskId());
payload.put("operatorUserId", event.getOperatorId());
payload.put("operatorName", event.getOperatorName());
Map<String, Object> data = new HashMap<>();
if (event.getData() != null) {
data.putAll(event.getData());
}
payload.put("data", data);
String payloadJson = objectMapper.writeValueAsString(payload);
logId = logStore.persist(callbackLog);
} catch (DuplicateKeyException dup) {
return; // uk(event_id, reg_id) 并发兜底:另一路径已建行,幂等成功
}
submitPush(logId);
}
FlwCallbackLog callbackLog = new FlwCallbackLog();
callbackLog.setSchId(instance.getSchId());
callbackLog.setRegId(reg.getId());
callbackLog.setEventId(eventId);
callbackLog.setEvent(event.getType().getCode());
callbackLog.setInstanceId(instance.getId());
callbackLog.setBizType(instance.getBizType());
callbackLog.setBizId(instance.getBizId());
callbackLog.setPayloadJson(payloadJson);
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
callbackLog.setRetryCount(0);
callbackLog.setNextRetryTime(LocalDateTime.now());
// REQUIRES_NEW 落库(afterCommit 阶段原事务已完结,REQUIRED 会挂空事务)
Long logId = logStore.persist(callbackLog);
// 异步首推:失败由退避重试接手(1m 起)
/** 异步首推提交:失败不回退建行结果(行已在,重试扫描接手) */
private void submitPush(Long logId) {
try {
executor().submit(() -> pushService.pushAsync(logId));
} catch (Exception e) {
log.error("[flow-center] 回调日志落库失败(对账兜底会追平):instance={} event={} {}",
instance.getId(), event.getType().getCode(), e.toString(), e);
log.warn("[flow-center] 异步首推提交失败(重试扫描接手):logId={} {}", logId, e.toString());
}
}
/**
* 存量现场信封(事实冻结体缺席的兜底路径):字段口径与 FlwStateEventRecorder 冻结体一致。
*/
private Map<String, Object> legacyEnvelope(FlowEvent event, FlwInstance instance, FlwDef def,
String eventId) {
Map<String, Object> payload = new HashMap<>();
payload.put("eventId", eventId);
if (event.getStableEventSeq() != null) {
payload.put("eventSeq", event.getStableEventSeq());
}
payload.put("event", event.getType().getCode());
payload.put("schId", instance.getSchId());
payload.put("flowGuid", def.getFlowGuid());
payload.put("instanceId", instance.getId());
payload.put("instanceVersion", instance.getVersion());
payload.put("bizType", instance.getBizType());
payload.put("bizId", instance.getBizId());
payload.put("bizTitle", instance.getBizTitle());
payload.put("occurredTime", LocalDateTime.now().format(TIME));
// 2026-09-20 信封升级:任务定位与操作人(均可空,办件级事件/历史重放体为 null——
// 契约见《子应用接入流程中心规范.md》§6.3/§6.4)
payload.put("taskId", event.getTaskId());
payload.put("operatorUserId", event.getOperatorId());
payload.put("operatorName", event.getOperatorName());
Map<String, Object> data = new HashMap<>();
if (event.getData() != null) {
data.putAll(event.getData());
}
// 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。
if (event.getRoundIdentity() != null) {
data.putAll(event.getRoundIdentity());
}
payload.put("data", data);
return payload;
}
/** 重推扫描(fixedDelay 串行防重入;推拉结合里 Webhook 永远只是加速器) */
@Scheduled(fixedDelay = 30_000)
public void scanRetry() {
@@ -0,0 +1,24 @@
package com.yunhai.flowcenter.callback;
import com.yunhai.flowcenter.repository.FlwStateEventRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** Fact bookkeeping has its own commit; AFTER_COMMIT still has the completed business resources bound. */
@Service
@RequiredArgsConstructor
public class CallbackFactStore {
private final FlwStateEventRepository stateEventRepository;
/** Called only after every matching delivery has been persisted (including the zero-subscriber case). */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markDispatched(String eventId) {
stateEventRepository.findByEventId(eventId).ifPresent(fact -> {
fact.setDispatched(1);
stateEventRepository.save(fact);
});
}
}
@@ -2,21 +2,45 @@ package com.yunhai.flowcenter.callback;
import com.yunhai.flowcenter.entity.FlwCallbackLog;
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
import lombok.RequiredArgsConstructor;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.sql.DataSource;
import java.sql.*;
import java.time.LocalDateTime;
import java.util.*;
/**
* 回调日志落库(独立 bean 保证 REQUIRES_NEW 生效):AFTER_COMMIT 监听阶段原事务已提交
* 但事务资源仍挂在线程上,REQUIRED 会"加入"已完结事务并抛 no transaction is in progress——
* 必须显式开新事务写日志。落库成功即视为主业务完成,推送失败不影响审批。
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CallbackLogStore {
private final FlwCallbackLogRepository logRepository;
private final DataSource dataSource;
private final int claimIdleSeconds;
@Autowired
public CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource) {
this(logRepository, dataSource, 60);
}
// 窄范围故障测试可缩短会话租期;生产固定 60s,大于正常 HTTP 5s 连接 + 10s 读取超时。
CallbackLogStore(FlwCallbackLogRepository logRepository, DataSource dataSource, int claimIdleSeconds) {
if (claimIdleSeconds < 1) throw new IllegalArgumentException("claimIdleSeconds must be positive");
this.logRepository = logRepository;
this.dataSource = dataSource;
this.claimIdleSeconds = claimIdleSeconds;
}
/** @return 落库后的日志行 id */
@Transactional(propagation = Propagation.REQUIRES_NEW)
@@ -24,4 +48,198 @@ public class CallbackLogStore {
FlwCallbackLog saved = logRepository.saveAndFlush(callbackLog);
return saved.getId();
}
/**
* MySQL 会话锁不是事务/行锁。专用借出连接始终 autocommit,HTTP 不占用数据库事务。
* 连接空闲 60s 后由数据库关闭并释放锁;所有结果必须在原连接上验证锁所有权,禁止换连接补写。
*/
public Claim tryClaim(Long id) {
Claim claim = openSession();
try {
if (!claim.acquire(id)) {
claim.close();
return null;
}
claim.row = claim.read(id);
if (claim.row == null) {
claim.close();
return null;
}
return claim;
} catch (RuntimeException | SQLException failure) {
claim.close();
throw new IllegalStateException("回调认领失败", failure);
}
}
private Claim openSession() {
Connection connection = null;
try {
// 不用 DataSourceUtils:绝不借用调用方已绑定的业务事务连接。
connection = dataSource.getConnection();
if (!connection.getAutoCommit()) throw new SQLException("callback claim requires autocommit");
long previous;
try (Statement st = connection.createStatement(); ResultSet rs = st.executeQuery("SELECT @@session.wait_timeout")) {
rs.next(); previous = rs.getLong(1);
}
try (Statement st = connection.createStatement()) {
st.execute("SET SESSION wait_timeout = " + claimIdleSeconds);
}
return new Claim(connection, previous);
} catch (SQLException failure) {
if (connection != null) {
try { connection.abort(Runnable::run); } catch (SQLException ignored) { }
try { connection.close(); } catch (SQLException ignored) { }
}
throw new IllegalStateException("回调认领连接不可用", failure);
}
}
/** 人工重推先完成整批短事务,再交回推送层发 HTTP;成功行不能被旧管理快照复活。 */
public List<Long> resetForReplay(Long school, Collection<Long> ids) {
try (Claim claim = openSession()) {
List<Long> sorted = new ArrayList<>(new TreeSet<>(ids));
for (Long id : sorted) {
if (!claim.acquire(id)) throw new BizException(ErrorCode.CUSTOM, "回调正在派发,请稍后重推:" + id);
FlwCallbackLog row = claim.read(id);
if (row == null || !school.equals(row.getSchId())) throw new BizException(ErrorCode.FORBIDDEN, "回调日志不存在或不属于该校");
}
claim.connection.setAutoCommit(false);
List<Long> reset = new ArrayList<>();
try {
for (Long id : sorted) {
// 也检查所有权,旧 MySQL 若不支持同会话多锁会 fail closed,而不会无锁更新。
if (!claim.owns(id)) throw new BizException(ErrorCode.CUSTOM, "回调派发权已失效,请重试");
int changed = claim.update(id, "status=0, retry_count=0, next_retry_time=?, update_time=?, update_by=?",
"status in (0,2)", Timestamp.valueOf(LocalDateTime.now()), Timestamp.valueOf(LocalDateTime.now()), actor());
if (changed == 1) reset.add(id);
}
claim.connection.commit();
return reset;
} catch (RuntimeException | SQLException failure) {
claim.connection.rollback();
throw failure;
} finally { claim.connection.setAutoCommit(true); }
} catch (SQLException failure) {
throw new IllegalStateException("回调重推状态提交失败", failure);
}
}
private static String actor() {
com.yunhai.platform.auth.LoginUser user = com.yunhai.platform.auth.UserContextHolder.get();
return user == null ? null : user.getLoginName();
}
/** 令牌即拥有命名锁的原始数据库会话;状态更新只能在该连接上进行。 */
public static final class Claim implements AutoCloseable {
private final Connection connection;
private final long previousWaitTimeout;
private final Map<Long, String> keys = new LinkedHashMap<>();
private FlwCallbackLog row;
private boolean closed;
private boolean settled;
private Claim(Connection connection, long previousWaitTimeout) {
this.connection = connection;
this.previousWaitTimeout = previousWaitTimeout;
}
public FlwCallbackLog row() { return row; }
public boolean active() {
try { return !closed && row != null && owns(row.getId()); }
catch (SQLException expired) { return false; }
}
private boolean acquire(Long id) throws SQLException {
String key;
// 64 字符锁名:库名隔离,同 (eventId,regId) 的全部执行者用同一身份。
try (PreparedStatement st = connection.prepareStatement(
"SELECT SHA2(CONCAT(LOWER(DATABASE()), ':flw_callback:', event_id, ':', reg_id),256) FROM flw_callback_log WHERE id=?")) {
st.setLong(1, id);
try (ResultSet rs = st.executeQuery()) { if (!rs.next()) return false; key = rs.getString(1); }
}
try (PreparedStatement st = connection.prepareStatement("SELECT GET_LOCK(?,0)")) {
st.setString(1, key);
try (ResultSet rs = st.executeQuery()) {
rs.next();
if (rs.getInt(1) != 1 || rs.wasNull()) return false;
}
}
keys.put(id, key);
return true;
}
private FlwCallbackLog read(Long id) throws SQLException {
try (PreparedStatement st = connection.prepareStatement("SELECT * FROM flw_callback_log WHERE id=?")) {
st.setLong(1, id);
try (ResultSet rs = st.executeQuery()) {
return rs.next() ? new BeanPropertyRowMapper<>(FlwCallbackLog.class).mapRow(rs, 0) : null;
}
}
}
private boolean owns(Long id) throws SQLException {
try (PreparedStatement st = connection.prepareStatement("SELECT IS_USED_LOCK(?)=CONNECTION_ID()")) {
st.setString(1, keys.get(id));
try (ResultSet rs = st.executeQuery()) { rs.next(); return rs.getBoolean(1); }
}
}
private int update(Long id, String assignments, String condition, Object... values) throws SQLException {
try (PreparedStatement st = connection.prepareStatement("UPDATE flw_callback_log SET " + assignments
+ " WHERE id=? AND " + condition + " AND IS_USED_LOCK(?)=CONNECTION_ID()")) {
int i=1;
for (Object value : values) st.setObject(i++, value);
st.setLong(i++, id); st.setString(i, keys.get(id));
return st.executeUpdate();
}
}
public boolean success(int httpStatus) { return finish("status=1, last_http_status=?, last_error=NULL, push_time=?, next_retry_time=NULL",
String.valueOf(httpStatus), Timestamp.valueOf(LocalDateTime.now())); }
public boolean failure(String error, String httpStatus, long backoffMinutes) {
return finish("retry_count=COALESCE(retry_count,0)+1, last_http_status=?, last_error=?, next_retry_time=?",
httpStatus, error, Timestamp.valueOf(LocalDateTime.now().plusMinutes(backoffMinutes)));
}
public boolean dead(String error) { return finish("status=2, last_error=?", error); }
private boolean finish(String assignments, Object... values) {
if (closed || settled || row == null) return false;
Object[] audited = Arrays.copyOf(values, values.length+2);
audited[values.length] = Timestamp.valueOf(LocalDateTime.now()); audited[values.length+1] = actor();
try {
// count 是附加 CAS;不是把旧实体全量 merge 回库。会话失权/成功/人工处理后均不能覆盖。
settled = update(row.getId(), assignments + ", update_time=?, update_by=?",
"status=0 AND retry_count=" + row.getRetryCount(), audited) == 1;
if (!settled) log.debug("[flow-center] 回调旧结果已忽略:logId={}", row.getId());
return settled;
} catch (SQLException failure) {
log.warn("[flow-center] 回调结果未记账(会话失效或数据库不可用,留待重试):logId={} {}", row.getId(), failure.toString());
return false;
}
}
@Override public void close() {
if (closed) return;
closed = true;
try {
for (String key : keys.values()) {
try (PreparedStatement st = connection.prepareStatement("SELECT RELEASE_LOCK(?)")) {
st.setString(1, key); st.execute();
}
}
try (Statement st = connection.createStatement()) {
st.execute("SET SESSION wait_timeout = " + previousWaitTimeout);
}
} catch (SQLException failedRelease) {
// 不能把可能残留会话锁的物理连接还给池;显式中止,让数据库释放全部锁。
try { connection.abort(Runnable::run); } catch (SQLException ignored) { }
} finally {
try { connection.close(); } catch (SQLException ignored) { }
}
}
}
}
@@ -21,8 +21,8 @@ import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HashSet;
import java.util.Set;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
@@ -32,7 +32,7 @@ import java.util.UUID;
* <b>持续重试不终止</b>(T1.1:消费方只承诺"瞬时失败回 5xx",恢复后自动追平,消费方免对账);
* 挂起(retryCount 首达 hang-alert-retries)时 UserMsg 告警恰一次;
* 死信(status=2)仅两个入口——注册停用、管理端手工标记,重试次数永不自动触发。
* 推送一律在新事务里落库(AFTER_COMMIT 监听器/调度线程均无外层事务)。
* HTTP 在业务事务之外执行,结果在认领会话上以自动提交的条件 UPDATE 落定。
* 推送本身与落库解耦:payload_json 是完整快照,可重放。
*/
@Slf4j
@@ -40,43 +40,72 @@ import java.util.UUID;
@RequiredArgsConstructor
public class CallbackPushService {
private static final Set<Long> IN_FLIGHT = new HashSet<>();
/** 仅观测,不阻断数据库租期后的恢复;每个执行者有独立本地状态。 */
private final Map<Long, Integer> IN_FLIGHT = new HashMap<>();
/** 行是否有本地执行者在途(仅观测;互斥由数据库认领负责)。 */
boolean inFlight(Long logId) {
synchronized (IN_FLIGHT) {
return IN_FLIGHT.containsKey(logId);
}
}
private final FlwCallbackLogRepository logRepository;
private final FlwCallbackRegRepository regRepository;
private final CallbackProperties properties;
private final CallbackLogStore logStore;
private final UserMsgClient userMsgClient;
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
appIdentityProvider;
@org.springframework.beans.factory.annotation.Autowired
private CallbackSourcePolicy sourcePolicy;
/** 推送指定日志行(幂等入口:成功/终态/在途跳过) */
@org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
public void pushAsync(Long logId) {
synchronized (IN_FLIGHT) {
if (!IN_FLIGHT.add(logId)) {
IN_FLIGHT.merge(logId, 1, Integer::sum);
}
try (CallbackLogStore.Claim claim = logStore.tryClaim(logId)) {
if (claim == null) {
log.debug("[flow-center] 回调数据库派发权未取得:logId={}", logId);
return;
}
}
try {
doPush(logId);
doPush(claim);
} finally {
synchronized (IN_FLIGHT) {
IN_FLIGHT.remove(logId);
IN_FLIGHT.computeIfPresent(logId, (id, count) -> count == 1 ? null : count - 1);
}
}
}
private void doPush(Long logId) {
FlwCallbackLog callbackLog = logRepository.findById(logId).orElse(null);
private void doPush(CallbackLogStore.Claim claim) {
FlwCallbackLog callbackLog = claim.row();
if (callbackLog == null
|| callbackLog.getStatus() != FlwEnums.CallbackLogStatus.PENDING.getCode()) {
log.debug("[flow-center] 回调推送早退(行缺席或非待推):logId={} status={}",
callbackLog == null ? null : callbackLog.getId(), callbackLog == null ? null : callbackLog.getStatus());
return;
}
FlwCallbackReg reg = regRepository.findById(callbackLog.getRegId()).orElse(null);
if (reg == null || reg.getStatus() != 0) {
// 注册已停用:保留日志待查,不再推送(置死信便于管理端一次性清理)
markDead(callbackLog, "registration disabled");
markDead(claim, "registration disabled");
return;
}
if (sourcePolicy != null) {
try {
if (!sourcePolicy.permits(reg, callbackLog)) {
markDead(claim, "SELF source or command identity mismatch");
return;
}
} catch (RuntimeException unavailable) {
markRetryOrFail(claim, "source authentication unavailable", null);
return;
}
}
if (!claim.active()) return; // 权威查询迟到/会话租期已失效:不再发送。
String body = callbackLog.getPayloadJson();
String ts = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
@@ -94,41 +123,34 @@ public class CallbackPushService {
new HttpEntity<>(body, headers), String.class);
int status = resp.getStatusCodeValue();
if (status >= 200 && status < 300) {
markSuccess(callbackLog, status);
markSuccess(claim, status);
} else {
markRetryOrFail(callbackLog, "HTTP " + status, String.valueOf(status));
markRetryOrFail(claim, "HTTP " + status, String.valueOf(status));
}
} catch (Exception e) {
markRetryOrFail(callbackLog, rootMessage(e), null);
markRetryOrFail(claim, rootMessage(e), null);
}
}
private void markSuccess(FlwCallbackLog callbackLog, int httpStatus) {
callbackLog.setStatus(FlwEnums.CallbackLogStatus.SUCCESS.getCode());
callbackLog.setRetryCount(callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount());
callbackLog.setLastHttpStatus(String.valueOf(httpStatus));
callbackLog.setLastError(null);
callbackLog.setPushTime(LocalDateTime.now());
saveQuietly(callbackLog);
log.debug("[flow-center] 回调推送成功:eventId={} http={}", callbackLog.getEventId(), httpStatus);
private void markSuccess(CallbackLogStore.Claim claim, int httpStatus) {
if (claim.success(httpStatus)) {
log.debug("[flow-center] 回调推送成功:eventId={} http={}", claim.row().getEventId(), httpStatus);
}
}
/**
* 失败落定(持续重试语义):越过退避表后按 {@link CallbackProperties#getCapBackoffMinutes()}
* 封顶继续排期,<b>绝不自动死信</b>;retryCount 首次等于挂起阈值时告警恰一次。
*/
private void markRetryOrFail(FlwCallbackLog callbackLog, String error, String httpStatus) {
private void markRetryOrFail(CallbackLogStore.Claim claim, String error, String httpStatus) {
FlwCallbackLog callbackLog = claim.row();
int retryCount = (callbackLog.getRetryCount() == null ? 0 : callbackLog.getRetryCount()) + 1;
callbackLog.setRetryCount(retryCount);
callbackLog.setLastHttpStatus(httpStatus);
callbackLog.setLastError(truncate(error));
java.util.List<Long> backoff = properties.getRetryBackoffMinutes();
long backoffMinutes = retryCount <= backoff.size()
? backoff.get(retryCount - 1)
: Math.max(1, properties.getCapBackoffMinutes());
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
callbackLog.setNextRetryTime(LocalDateTime.now().plusMinutes(backoffMinutes));
saveQuietly(callbackLog);
if (!claim.failure(truncate(error), httpStatus, backoffMinutes)) return;
callbackLog.setRetryCount(retryCount); // 仅用于日志/告警;数据库已原子 +1,绝不 merge 此快照。
log.warn("[flow-center] 回调推送失败(第 {} 次重试,{} 分钟后):eventId={} {}",
retryCount, backoffMinutes, callbackLog.getEventId(), error);
// 挂起告警恰一次:retryCount 每失败 +1 单调递增,等于阈值的时机只有一次;
@@ -163,9 +185,20 @@ public class CallbackPushService {
* 仍被注册停用场景使用(doPush:注册已停用即无意义重试,置死信便于管理端清理)。
*/
public void markDead(FlwCallbackLog callbackLog, String error) {
callbackLog.setStatus(FlwEnums.CallbackLogStatus.DEAD.getCode());
callbackLog.setLastError(truncate(error));
saveQuietly(callbackLog);
try (CallbackLogStore.Claim claim = logStore.tryClaim(callbackLog.getId())) {
if (claim == null) throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.CUSTOM, "回调正在派发,请稍后标记");
if (claim.row().getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "已成功日志无需标记死信");
}
markDead(claim, error);
}
}
private void markDead(CallbackLogStore.Claim claim, String error) {
if (!claim.dead(truncate(error))) return;
FlwCallbackLog callbackLog = claim.row();
log.error("[flow-center] 回调推送进入死信:eventId={} biz={}/{} error={}",
callbackLog.getEventId(), callbackLog.getBizType(), callbackLog.getBizId(), error);
alertDead(callbackLog, error);
@@ -203,14 +236,6 @@ public class CallbackPushService {
}
}
private void saveQuietly(FlwCallbackLog callbackLog) {
try {
logRepository.save(callbackLog);
} catch (Exception e) {
log.error("[flow-center] 回调日志落库失败:eventId={} {}", callbackLog.getEventId(), e.toString());
}
}
/**
* 回调探测(2026-10-11 管理端增强):以该注册的 secret 按现行信封签一条未知事件码 flow.ping,
* 走真实推送管道同步一发——接收方按规范 §6.4 对未知事件码"记日志后 200",不破坏业务数据。
@@ -0,0 +1,57 @@
package com.yunhai.flowcenter.callback;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.entity.FlwCallbackReg;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.service.FlowSourcePrincipalResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/** Same SELF predicate for live fanout, fact recovery, retries and manual re-delivery. */
@Component @RequiredArgsConstructor
public class CallbackSourcePolicy {
private final FlowSourcePrincipalResolver resolver;
private final FlwInstanceRepository instances;
private final ObjectMapper mapper;
public boolean matches(FlwCallbackReg reg, FlwInstance instance) {
if (instance.getSourcePrincipalId() == null) {
return true; // Legacy callback migration is explicit; formal facts always use UUID SELF.
}
if (!instance.getSourcePrincipalId().equals(reg.getSourcePrincipalId())) return false;
// Recheck the credential mapping: a reused appKey cannot receive its predecessor's facts.
return instance.getSourcePrincipalId().equals(resolver.resolve(reg.getAppKey()).id);
}
public boolean permits(FlwCallbackReg reg, com.yunhai.flowcenter.entity.FlwCallbackLog delivery) {
if (!permits(reg, delivery.getInstanceId(), delivery.getPayloadJson())) return false;
FlwInstance instance = instances.findById(delivery.getInstanceId()).orElseThrow(IllegalStateException::new);
if (instance.getSourcePrincipalId() == null) return true;
try {
JsonNode body = mapper.readTree(delivery.getPayloadJson());
return java.util.Objects.equals(delivery.getEventId(), body.path("eventId").asText())
&& java.util.Objects.equals(delivery.getEvent(), body.path("event").asText())
&& java.util.Objects.equals(delivery.getBizType(), body.path("bizType").asText())
&& java.util.Objects.equals(delivery.getBizId(), body.path("bizId").asText());
} catch (Exception e) { return false; }
}
public boolean permits(FlwCallbackReg reg, Long instanceId, String payload) {
FlwInstance instance = instances.findById(instanceId).orElse(null);
if (instance == null || !matches(reg, instance)) return false;
if (instance.getSourcePrincipalId() == null) return true;
try {
JsonNode body = mapper.readTree(payload);
JsonNode data = body.path("data");
return instance.getSourcePrincipalId().equals(body.path("sourcePrincipalId").asText())
&& instance.getApplyRound().equals(body.path("applyRound").asInt())
&& instance.getSourcePrincipalId().equals(data.path("sourcePrincipalId").asText())
&& instance.getApplyRequestToken().equals(data.path("requestId").asText())
&& instance.getApplyPayloadHash().equals(data.path("canonicalHash").asText())
&& instance.getDefVersion().equals(data.path("defVersion").asInt())
&& instance.getApplyRound().equals(data.path("applyRound").asInt())
&& instance.getId().equals(body.path("instanceId").asLong())
&& instance.getSchId().equals(body.path("schId").asLong())
&& instance.getBizType().equals(body.path("bizType").asText())
&& instance.getBizId().equals(body.path("bizId").asText());
} catch (Exception e) { return false; }
}
}
@@ -0,0 +1,23 @@
package com.yunhai.flowcenter.callback;
import com.yunhai.flowcenter.engine.FlowEvent;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/** 同步发布阶段冻结轮次身份;不修改引擎或 apply/v2 七支分支。 */
@Component
@RequiredArgsConstructor
public class FlowEventIdentityListener {
private final FlwInstanceRepository instances;
@EventListener
@Order(Ordered.HIGHEST_PRECEDENCE)
public void capture(FlowEvent event) {
instances.findById(event.getInstanceId()).ifPresent(instance ->
event.captureRoundIdentity(instance.getApplyRound(), instance.getApplyRequestToken()));
}
}
@@ -0,0 +1,145 @@
package com.yunhai.flowcenter.callback;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.FlowEvent;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.entity.FlwStateEvent;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwStateEventRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 状态事件事实记录器(2026-10-25 T05;同日 R2 返修冻结完整信封):普通同步监听器,与发布方
* <b>同一事务</b>执行——状态变更提交成功 ⇔ 不可变事件事实已落库,关闭"提交到 AFTER_COMMIT
* 分发之间进程被杀,事件永久丢失"的窗口(审计 E24)。
*
* <p>职责边界:只落事实行并给 {@link FlowEvent} 赋<b>稳定身份</b>与<b>冻结信封</b>
* (eventId 全订阅方/全部重试复用;eventSeq 实例内单调;R2 起信封含 eventId/eventSeq/flowGuid
* 全部投递字段——正常分发与崩溃恢复共用同一冻结体,Codex D04"恢复消息体缺 eventId"关闭);
* 回调分发、订阅匹配、推送仍归 {@link CallbackDispatcher}。URGE 站内信不入事件流(既有口径)。
* 无事务的发布(fallbackExecution 场景)退化为自动提交直写——事实仍落,只是失去与状态的
* 同事务原子性(记录 warn,不静默)。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FlwStateEventRecorder {
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final FlwStateEventRepository eventRepository;
private final FlwInstanceRepository instanceRepository;
private final FlwDefRepository defRepository;
private final ObjectMapper objectMapper;
@EventListener
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public void record(FlowEvent event) {
if (event.getType() == FlowEvent.Type.URGE) {
return;
}
if (event.getStableEventId() != null) {
return; // 幂等:同一事件对象重复发布不重复落事实
}
try {
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElse(null);
if (instance == null) {
log.warn("[state-event] 事实落库时实例不存在(跳过):instance={} event={}",
event.getInstanceId(), event.getType().getCode());
return;
}
FlwDef def = instance.getDefId() == null ? null
: defRepository.findById(instance.getDefId()).orElse(null);
FlwStateEvent fact = new FlwStateEvent();
fact.setEventId(UUID.randomUUID().toString().replace("-", "").toLowerCase());
fact.setSchId(instance.getSchId());
fact.setInstanceId(instance.getId());
fact.setApplyRound(instance.getApplyRound());
// T05 发号(终形态):FOR UPDATE 标量当前读(行 X 锁,任何隔离级别串行正确)
// + 锁内回写;不实体 merge(@Version 会 StaleObjectState,实证)。
Integer nextSeq = instanceRepository.nextEventSeqForUpdate(instance.getId());
if (nextSeq == null) {
throw new IllegalStateException("事件序号发号返回空:instance=" + instance.getId());
}
instanceRepository.setEventSeqCounter(instance.getId(), nextSeq);
fact.setEventSeq(nextSeq);
fact.setEventType(event.getType().getCode());
fact.setTaskId(event.getTaskId());
fact.setOperatorId(event.getOperatorId());
// R2:冻结完整投递信封(含 eventId/eventSeq/flowGuid)——事实载荷即投递体,
// 正常链与恢复链字节级同构,接收端必填字段(eventId/event/bizType/bizId)恒在。
fact.setPayloadJson(objectMapper.writeValueAsString(
fullEnvelope(event, instance, def, fact.getEventId(), nextSeq)));
eventRepository.save(fact);
event.assignStableIdentity(fact.getEventId(), fact.getEventSeq());
event.assignStablePayload(fact.getPayloadJson());
} catch (Exception e) {
// 事实落库失败=状态事务随之一并失败(同事务)或无事务场景仅告警;
// 绝不反噬引擎主流程——分发链对账兜底仍在
log.error("[state-event] 事件事实落库异常:instance={} event={} {}",
event.getInstanceId(), event.getType().getCode(), e.toString(), e);
if (org.springframework.transaction.support.TransactionSynchronizationManager
.isActualTransactionActive()) {
// 同事务场景:事实写失败必须让状态一起回滚(事件与状态原子),不吞
throw new IllegalStateException("状态事件事实落库失败,事务回滚:" + event.getType().getCode(), e);
}
}
}
/**
* 完整投递信封(R2 冻结体):{@code eventId/eventSeq/event/schId/flowGuid/instanceId/
* instanceVersion/applyRound/bizType/bizId/bizTitle/occurredTime/taskId/operatorUserId/
* operatorName/data}。def 缺席(定义已删)时不带 flowGuid——该场合分发器同样不投递。
*/
private Map<String, Object> fullEnvelope(FlowEvent event, FlwInstance instance,
FlwDef def, String eventId, Integer eventSeq) {
Map<String, Object> payload = new HashMap<>();
payload.put("eventId", eventId);
payload.put("eventSeq", eventSeq);
payload.put("event", event.getType().getCode());
payload.put("schId", instance.getSchId());
if (def != null) {
payload.put("flowGuid", def.getFlowGuid());
}
payload.put("instanceId", instance.getId());
payload.put("instanceVersion", instance.getVersion());
payload.put("applyRound", instance.getApplyRound());
payload.put("bizType", instance.getBizType());
payload.put("bizId", instance.getBizId());
payload.put("bizTitle", instance.getBizTitle());
payload.put("occurredTime", LocalDateTime.now().format(TIME));
payload.put("taskId", event.getTaskId());
payload.put("operatorUserId", event.getOperatorId());
payload.put("operatorName", event.getOperatorName());
Map<String, Object> data = new HashMap<>();
if (event.getData() != null) {
data.putAll(event.getData());
}
if (event.getRoundIdentity() != null) {
data.putAll(event.getRoundIdentity());
}
if (instance.getSourcePrincipalId() != null) {
payload.put("sourcePrincipalId", instance.getSourcePrincipalId());
data.put("sourcePrincipalId", instance.getSourcePrincipalId());
data.put("requestId", instance.getApplyRequestToken());
data.put("defVersion", instance.getDefVersion());
data.put("canonicalHash", instance.getApplyPayloadHash());
data.put("applyRound", instance.getApplyRound());
}
payload.put("data", data);
return payload;
}
}
@@ -0,0 +1,29 @@
package com.yunhai.flowcenter.callback;
import com.yunhai.flowcenter.engine.FlowEvent;
import com.yunhai.flowcenter.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** Release in the state transaction; late events can never free a newer round's pointer. */
@Component @RequiredArgsConstructor
public class FormalCoordinateReleaseListener {
private final FlwInstanceRepository instances;
private final FlwBizCoordinateRepository coordinates;
@EventListener
public void release(FlowEvent event) {
if (event.getType() != FlowEvent.Type.COMPLETED && event.getType() != FlowEvent.Type.REJECTED
&& event.getType() != FlowEvent.Type.WITHDRAWN) return;
instances.findById(event.getInstanceId()).ifPresent(instance -> {
if (instance.getSourcePrincipalId() != null) {
if (!org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException("Formal terminal transition requires the state transaction");
}
coordinates.release(instance.getSchId(), instance.getSourcePrincipalId(), instance.getBizType(),
instance.getBizId(), instance.getId(), instance.getApplyRound());
}
});
}
}
@@ -0,0 +1,13 @@
package com.yunhai.flowcenter.config;
import com.yunhai.flowcenter.service.FlowOapiSourceScope;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @RequiredArgsConstructor
public class FlowSourceScopeConfiguration implements WebMvcConfigurer {
private final FlowOapiSourceScope scope;
@Override public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(scope).addPathPatterns("/oapi/flow/**");
}
}
@@ -6,6 +6,7 @@ import com.yunhai.flowcenter.entity.FlwCallbackReg;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.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;
@@ -54,6 +65,7 @@ public class FlowAdminController {
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
private final com.yunhai.flowcenter.service.FlowAppAdminService appAdminService;
private final com.yunhai.flowcenter.service.FlowDeptAdminService deptAdminService;
private final com.yunhai.flowcenter.repository.FlwRouteLogRepository routeLogRepository;
// ==================== 流程定义 ====================
@@ -97,12 +109,74 @@ public class FlowAdminController {
return appAdminService.stats(adminGuard.requireAdmin(user, null));
}
// ==================== 应用使用授权(T01) ====================
/**
* 使用授权管理面(字段注入:构造被既有单测逐位钉住,同 formCenterCatalogClient 先例)。
* 授权对象是稳定应用主体(UserBase appId),非 appKey 凭据;撤权只拦新发起。
*/
@org.springframework.beans.factory.annotation.Autowired
private com.yunhai.flowcenter.service.FlowUsageGrantAdminService usageGrantAdminService;
/** 某流程线的使用授权清单(含已撤销行,审计可见) */
@GetMapping("/usage-grant/list")
public List<com.yunhai.flowcenter.entity.FlwUsageGrant> usageGrantList(
BoaUser user, @RequestParam("flowGuid") String flowGuid) {
return usageGrantAdminService.listByFlow(adminGuard.requireAdmin(user, null), flowGuid);
}
/** 授予/重申使用授权(appKey 当场解析为 UserBase 稳定主体落库;enforce 开启时校学校开通) */
@PostMapping("/usage-grant/grant")
public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantGrant(
BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
return usageGrantAdminService.grant(schId,
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")),
body.get("appKey") == null ? null : String.valueOf(body.get("appKey")),
body.get("remark") == null ? null : String.valueOf(body.get("remark")),
user.getName());
}
/** 撤销使用授权(enabled=0 行保留;只拦新发起,在途与已受理命令不受影响) */
@PostMapping("/usage-grant/revoke")
public com.yunhai.flowcenter.entity.FlwUsageGrant usageGrantRevoke(
BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
return usageGrantAdminService.revoke(schId,
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")),
body.get("appId") == null ? null : Integer.valueOf(String.valueOf(body.get("appId"))),
body.get("remark") == null ? null : String.valueOf(body.get("remark")),
user.getName());
}
@GetMapping("/def/get")
public FlwDef defGet(BoaUser user, @RequestParam("id") Long id) {
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);
@@ -110,7 +184,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);
}
}
/**
@@ -146,6 +231,24 @@ public class FlowAdminController {
return defAdminService.versions(adminGuard.requireAdmin(user, null), flowGuid);
}
// ==================== 线级停发/恢复(T03) ====================
/** 线级停发:整条 flowGuid 停止新发起/预览;在途实例按原版本跑完不受影响 */
@PostMapping("/def/offlineLine")
public int defOfflineLine(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
return defAdminService.offlineLine(schId,
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
}
/** 线级恢复:只恢复线可用性,不改写任何版本状态或发布内容 */
@PostMapping("/def/enableLine")
public int defEnableLine(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
return defAdminService.enableLine(schId,
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
}
/** 在途办件数(议题二④):flowGuids 逗号分隔,去重后最多 100;返回 flowGuid → 审批中办件数(无在途也返 0 键) */
@GetMapping("/def/inflight")
public Map<String, Long> defInflight(BoaUser user, @RequestParam("flowGuids") String flowGuids) {
@@ -364,6 +467,23 @@ public class FlowAdminController {
callbackAdminService.rePush(adminGuard.requireAdmin(user, null), logIds);
}
/**
* 对接健康度体检(2026-10-15 议题二 P2):每应用一行六项检查 + 综合评分。
* 数据全部来自已有资产(flw_callback_reg / flw_callback_log),零新增表;
* 应用名从 flw_app 白名单补齐(缺失回落 appKey)。
*/
@GetMapping("/callback/health")
public List<com.yunhai.flowcenter.model.AppHealthVo> callbackHealth(BoaUser user) {
Long schId = adminGuard.requireAdmin(user, null);
java.util.Map<String, String> appNames = new java.util.HashMap<>();
for (com.yunhai.flowcenter.entity.FlwApp app : appAdminService.list(schId)) {
if (app.getAppKey() != null) {
appNames.put(app.getAppKey(), app.getAppName());
}
}
return callbackAdminService.health(schId, appNames);
}
// ==================== 条件路由注册 / 重跑路由 ====================
/** 判断服务注册列表(flw_router 全局表;编辑器 routerKey 下拉与管理页共用) */
@@ -396,10 +516,13 @@ public class FlowAdminController {
/**
* 重跑路由(判断失败挂起的恢复入口,2026-09-24 §4.4):重新调判断服务,成功即按出口续走;
* 仍失败回业务错(办件继续挂起,route_log 已落新行)。
* useLatestDef=true(F5-4 僵尸逃生门):按该 flowGuid 当前已发布最高版本重跑并刷新办件定义快照。
*/
@PostMapping("/instance/{id}/reroute")
public void instanceReroute(BoaUser user, @PathVariable("id") Long id) {
routerAdminService.reroute(adminGuard.requireAdmin(user, null), id);
public void instanceReroute(BoaUser user, @PathVariable("id") Long id,
@RequestParam(value = "useLatestDef", required = false, defaultValue = "false")
boolean useLatestDef) {
routerAdminService.reroute(adminGuard.requireAdmin(user, null), id, useLatestDef);
}
/**
@@ -445,18 +568,34 @@ public class FlowAdminController {
// ==================== 办件查询 ====================
/**
* 管理端办件分页(F11-4 增 hung 筛选与行级红标):hung=true 只看路由挂起办件
* (口径见 {@link com.yunhai.flowcenter.repository.FlwInstanceRepository#pageAdmin});
* 每行回填 {@code routeHung}(按页一次 {@code findHungInstanceIds} 查询,非逐行 N+1)。
*/
@GetMapping("/instance/page")
public BasePageResult<FlwInstance> instancePage(
BoaUser user,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "hung", required = false) Boolean hung,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
Long schId = adminGuard.requireAdmin(user, null);
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword,
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword, hung,
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
size == null || size < 1 || size > 100 ? 10 : size,
Sort.by(Sort.Order.desc("id"))));
List<Long> pageIds = new java.util.ArrayList<>();
for (FlwInstance i : p.getContent()) {
pageIds.add(i.getId());
}
// 行级红标:不在挂起集合内的行显式置 false(前端只判 truthy,null 也行,但显式更可读)
java.util.Set<Long> hungIds = pageIds.isEmpty() ? java.util.Collections.emptySet()
: new java.util.HashSet<>(routeLogRepository.findHungInstanceIds(pageIds));
for (FlwInstance i : p.getContent()) {
i.setRouteHung(hungIds.contains(i.getId()));
}
int pi = page == null || page < 1 ? 1 : page;
int ps = size == null || size < 1 || size > 100 ? 10 : size;
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(), pi, ps,
@@ -3,6 +3,7 @@ package com.yunhai.flowcenter.controller.api;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.entity.FlwTask;
import com.yunhai.flowcenter.model.FlowModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.service.FlowAccessGuard;
import com.yunhai.flowcenter.service.FlowDiagramService;
import com.yunhai.flowcenter.service.FlowInstanceService;
@@ -41,6 +42,22 @@ public class FlowUserController {
private final FlowDiagramService diagramService;
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
/** oapi 预览服务复用(T07 托管选人:候选+版本锚同源,字段注入防构造钉) */
@org.springframework.beans.factory.annotation.Autowired
private com.yunhai.flowcenter.service.OapiFlowService oapiFlowService;
/**
* 发起时指派预览(2026-10-25 T07 托管选人 iframe 数据源,F6 预置件点亮):
* CAS 会话取学校+提交人,复用 oapi applyPreview(含 T03 线级停发守卫)——
* 返回 {flowGuid, defVersion, nodes:[{nodeId,nodeName,countersign,selection,candidates}]}。
* <b>只读预览</b>:不建实例、不建任务、不落任何流程事实;正式提交仍走 apply 终审
* (前端候选不是授权凭据)。
*/
@GetMapping("/apply/preview")
public OapiModels.ApplyPreviewVo applyPreview(BoaUser user, @RequestParam("flowGuid") String flowGuid) {
return oapiFlowService.applyPreview(requireSchId(user), flowGuid, user.getId());
}
/** 我提交的办件 */
@GetMapping("/my")
public BasePageResult<FlowModels.InstanceItem> my(BoaUser user,
@@ -0,0 +1,21 @@
package com.yunhai.flowcenter.controller.oapi;
import com.yunhai.flowcenter.model.FormalApplyModels;
import com.yunhai.flowcenter.service.FormalApplyCommandService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/oapi/flow/commands") @RequiredArgsConstructor
public class FormalApplyCommandController {
private final FormalApplyCommandService commands;
@PostMapping
public FormalApplyModels.Receipt submit(@RequestParam("schId") Long school,
@RequestAttribute("yh.app.auth") String appKey, @RequestBody FormalApplyModels.Request request) {
return commands.submit(school, appKey, request);
}
@GetMapping("/{requestId}")
public FormalApplyModels.Receipt query(@RequestParam("schId") Long school,
@RequestAttribute("yh.app.auth") String appKey, @PathVariable String requestId,
@RequestParam(value="expectedHash", required=false) String expectedHash) {
return commands.query(school, appKey, requestId, expectedHash);
}
}
@@ -1,6 +1,10 @@
package com.yunhai.flowcenter.controller.oapi;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.FlowModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.service.FlowQueryService;
import com.yunhai.flowcenter.service.FlowRouterAdminService;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.platform.utils.model.BasePageResult;
import lombok.RequiredArgsConstructor;
@@ -27,10 +31,14 @@ import java.util.List;
* - GET /oapi/flow/diagram/url?instanceId=|flowGuid=&mode=&schId= → data={imgUrl,expiresIn}
* - GET /oapi/flow/embed/url?instanceId=|bizType=&bizId=&mode=&schId= → data={pageUrl,expiresIn}
* - POST /oapi/flow/callback/reg?schId= → data={secret}
* - POST /oapi/flow/router/reg → data=null(判断服务自注册,归属=appKey)
* 应用内审批(2026-09 第三步,原"刻意不提供审批动作"立场由该方案修订:登录身份由调用方
* 应用按其 CAS 会话保证并以 operatorId 传入,本端按任务办理人逐字复核,审计链不断):
* - GET /oapi/flow/tasks/todo?schId=&assigneeId=&bizType=&page=&size= → data=分页待办
* - GET /oapi/flow/tasks/by-biz?schId=&bizType=&bizId=&operatorId= → data=当前人在该单据的待办(0/1/N)
* - GET /oapi/flow/instances?schId=&bizType=&status=&page=&size= → data=分页办件
* - GET /oapi/flow/tasks/done?schId=&assigneeId=&page=&size= → data=分页已办(过滤=签名 appKey→d.appKey,P3.5)
* - GET /oapi/flow/cc?schId=&userId=&page=&size= → data=分页抄送我(过滤=签名 appKey→d.appKey,P3.5)
* - POST /oapi/flow/task/{taskId}/approve?schId=&operatorId=&operatorName=(body: opinion/edgeName)
* - POST /oapi/flow/task/{taskId}/reject?schId=&operatorId=&operatorName=(body: opinion 必填)
*/
@@ -40,6 +48,8 @@ import java.util.List;
public class OapiFlowController {
private final OapiFlowService flowService;
private final FlowRouterAdminService routerAdminService;
private final FlowQueryService queryService;
@GetMapping("/list")
public List<OapiModels.FlowSummaryVo> list(@RequestParam("schId") Long schId) {
@@ -59,6 +69,30 @@ 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,
@RequestParam("flowGuid") String flowGuid, @RequestParam("submitterId") Integer submitterId) {
return flowService.applyPreview(schId, flowGuid, submitterId);
}
@GetMapping("/instance")
public OapiModels.InstanceVo instance(@RequestParam("bizType") String bizType,
@RequestParam("bizId") String bizId,
@@ -101,8 +135,9 @@ public class OapiFlowController {
@RequestParam(value = "bizType", required = false) String bizType,
@RequestParam(value = "bizId", required = false) String bizId,
@RequestParam(value = "mode", required = false) String mode,
@RequestParam(value = "taskId", required = false) Long taskId,
@RequestParam("schId") Long schId) {
return flowService.embedUrl(schId, instanceId, bizType, bizId, mode);
return flowService.embedUrl(schId, instanceId, bizType, bizId, mode, taskId);
}
/**
@@ -124,6 +159,16 @@ public class OapiFlowController {
return flowService.registerCallback(schId, appKey, body);
}
/**
* 判断服务登记(2026-10-12 SERVICE 收口):由 {@code yh-flow-callback-spring-boot-starter} 启动自注册调用。
* 无 schId——{@code flw_router} 是全局表;归属即验签后的 appKey(登记即持有,跨应用覆盖拒绝)。
*/
@PostMapping("/router/reg")
public void registerRouter(@RequestAttribute(value = "yh.app.auth", required = false) String appKey,
@RequestBody OapiModels.RouterRegBody body) {
routerAdminService.registerByApp(appKey, body);
}
// ===== 应用内审批(第三步):查询 + 办理 =====
/** 我的待办(oapi):assigneeId 维度分页,bizType 可选过滤 */
@@ -137,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, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(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, com.yunhai.flowcenter.service.FlowOapiSourceScope.currentOr(appKey), page, size);
}
/** 办件分页(oapi):bizType/status 可选过滤 */
@GetMapping("/instances")
public BasePageResult<OapiModels.InstanceItemVo> instances(
@@ -0,0 +1,29 @@
package com.yunhai.flowcenter.engine;
/**
* 发起时指派快照解析(2026-10-13 方案):assign_json 列文本 → nodeId → 指派人员快照。
* 脏 JSON 只 log warn 返回空 Map(宽松回退口径,见方案 D3/I5)——引擎永不因缺指派拒绝或挂起。
*/
public final class AssignDesignations {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(AssignDesignations.class);
private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
private AssignDesignations() {}
/** @return nodeId → 指派人员快照;null/空串/脏 JSON → 空 Map */
public static java.util.Map<String, java.util.List<AssigneeRef>> parse(String assignJson) {
if (assignJson == null || assignJson.trim().isEmpty()) {
return java.util.Collections.emptyMap();
}
try {
java.util.Map<String, java.util.List<AssigneeRef>> result = MAPPER.readValue(assignJson,
new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, java.util.List<AssigneeRef>>>() {});
return result == null ? java.util.Collections.emptyMap() : result;
} catch (Exception e) {
log.warn("[flow-center] assign_json 解析失败,按无指派回退全量候选:{}", e.getMessage());
return java.util.Collections.emptyMap();
}
}
}
@@ -1,11 +1,17 @@
package com.yunhai.flowcenter.engine;
import lombok.Value;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 办理人解析结果(id+姓名快照),任务创建与通知共用。
*/
@Value
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AssigneeRef {
Integer userId;
String userName;
@@ -15,7 +15,8 @@ import java.util.List;
/**
* 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源,
* 零跨服务依赖);USERS 定义直配,姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。
* 零跨服务依赖);USERS 定义直配。审批责任人统一走 D11 资格终审(存在+启用+校域);
* 仅 NOTIFY 抄送接收人保留宽容解析(姓名补全失败降级空名、不校验资格)。
*/
@Slf4j
@Service
@@ -32,13 +33,31 @@ public class AssigneeResolver {
}
List<AssigneeRef> refs = new ArrayList<>();
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
// D11:科室启用 ≠ 成员都可办理——逐人走同一资格终审;不合格成员按既有
// “只取启用成员”口径剔除(local status 已在 resolveEnabled 过滤),并留日志。
List<String> rejected = new ArrayList<>();
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
try {
requireEligibleAssignee(schId, m.getUserId());
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
} catch (BizException e) {
if (e.getErrorCode() == ErrorCode.INTERNAL_SERVER_ERROR) {
throw e; // 权威源不可达≠成员不合格,不能降级成“无人可用”
}
rejected.add(m.getUserId() + "=" + e.getMessage());
log.warn("[flow-center] 科室成员资格校验不通过,跳过:schId={} deptKey={} userId={} reason={}",
schId, a.deptKey, m.getUserId(), e.getMessage());
}
}
if (refs.isEmpty() && !rejected.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"节点[" + node.id + "]无可用办理人:科室成员均不具备办理资格("
+ String.join(";", rejected) + ")");
}
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
if (a.userIds != null) {
for (Integer uid : a.userIds) {
refs.add(new AssigneeRef(uid, fetchName(uid)));
refs.add(new AssigneeRef(uid, requireEligibleAssigneeName(schId, uid)));
}
}
} else {
@@ -51,10 +70,30 @@ public class AssigneeResolver {
return refs;
}
/**
* D11 固定 USERS 整体预检:流程启动前逐节点核验全部固定审批人,任何一人不存在/停用/跨校
* 即整体拒绝——不允许“过滤掉非法人后继续”。运行时建任务仍复用 {@link #resolve} 兜底。
*/
public void requireEligibleFixedAssignees(Long schId, FlowDefModel model) {
for (FlowDefModel.Node node : model.nodes) {
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignee == null
|| !FlowDefModel.ASSIGNEE_USERS.equals(node.assignee.type)) {
continue;
}
try {
resolve(schId, node);
} catch (BizException e) {
throw new BizException(e.getErrorCode(),
"节点[" + node.id + "]固定审批人配置非法:" + e.getMessage());
}
}
}
/**
* 抄送接收人解析(NOTIFY 专用,宽容口径):结构不合法照抛(发布期校验本应拦住,属脏定义),
* 但解析不出人(科室被停用/名单为空)只返回空列表由调用方记日志——抄送是知会不是审批,
* 绝不能因抄送对象缺失卡住流程推进。与 {@link #resolve} 的强口径相反。
* 绝不能因抄送对象缺失卡住流程推进。D11 的资格终审只约束审批责任人,本方法刻意不校验
* 存在/启用/校域,直接复用既有姓名补全容错。
*/
public List<AssigneeRef> resolveTolerant(Long schId, FlowDefModel.Node node) {
FlowDefModel.Assignee a = node.receivers;
@@ -89,24 +128,52 @@ public class AssigneeResolver {
}
/**
* 移交目标校验(严格口径,与 {@link #fetchName} 的降级容错相反):
* 查不到就必须拒绝——否则会建出一条谁在待办里都看不到的僵尸任务。
* UserBase 不可达与"人不存在"分别报错,便于排查。
* 审批责任人资格终审(D11 唯一权威入口):存在 + 启用(status=1,null fail-closed)
* + 属于当前学校。UserBase 不可达与“人不合格”分别报错,前者不可降级为过滤。
*/
public String requireUserName(Integer userId) {
public BoaUser requireEligibleAssignee(Long schId, Integer userId) {
BoaUser user = requireValidUser(userId);
Long userSchId = user.getSchool() == null || user.getSchool().getId() == null
? null : Long.valueOf(user.getSchool().getId());
if (schId == null || !schId.equals(userSchId)) {
log.warn("[flow-center] 办理人不属于本校,拒绝:userId={} userSchool={} schId={}",
userId, userSchId, schId);
throw new BizException(ErrorCode.FORBIDDEN,
"办理人不属于该学校,不能办理任务:" + userId
+ "(所属学校 " + (userSchId == null ? "未知" : userSchId)
+ ",要求 " + schId + ")");
}
return user;
}
/** 资格终审通过后返回姓名快照;姓名缺失保留空串(既有口径)。 */
public String requireEligibleAssigneeName(Long schId, Integer userId) {
BoaUser user = requireEligibleAssignee(schId, userId);
return user.getName() == null ? "" : user.getName();
}
/**
* 存在 + 启用子检查。只供本类资格终审复用;外部审批责任人必须走
* {@link #requireEligibleAssignee}(含校域),避免漏掉学校范围。
*/
private BoaUser requireValidUser(Integer userId) {
if (userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少移交对象");
throw new BizException(ErrorCode.PARAM_ERROR, "缺少办理人");
}
BoaUser user;
try {
user = userClient.getUserById(String.valueOf(userId));
} catch (Exception e) {
log.warn("[flow-center] 移交对象校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
throw new BizException(ErrorCode.PARAM_ERROR, "移交对象校验失败,请稍后重试:" + userId);
log.warn("[flow-center] 办理人资格校验失败(UserBase 不可达)userId={}:{}", userId, e.getMessage());
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "办理人资格校验失败,请稍后重试:" + userId);
}
if (user == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "移交对象不存在:" + userId);
throw new BizException(ErrorCode.PARAM_ERROR, "办理人不存在:" + userId);
}
return user.getName() == null ? "" : user.getName();
if (!Integer.valueOf(1).equals(user.getStatus())) {
log.warn("[flow-center] 办理人为禁用/未知状态账号,拒绝:userId={} status={}", userId, user.getStatus());
throw new BizException(ErrorCode.PARAM_ERROR, "办理人为禁用账号,不能办理任务:" + userId);
}
return user;
}
}
@@ -29,6 +29,7 @@ public final class DefValidator {
*/
public static void validate(FlowDefModel model, Set<String> enabledDeptKeys, Set<String> enabledRouterKeys) {
List<String> problems = new ArrayList<>();
VariableContract.validateDefinition(model, false);
if (model.nodes == null || model.nodes.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义缺少节点");
@@ -248,7 +249,7 @@ public final class DefValidator {
* (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
*
* <p>EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦
* (表单变量是子应用运行时传的,发布期无从核对 schema,一律放行、缺失按 null 注入求值)。
* 新契约的变量引用/类型/值域由 VariableContract 统一检查;历史无声明版本保持兼容。
*/
private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set<String> enabledRouterKeys,
List<String> problems) {
@@ -282,8 +283,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"));
@@ -335,6 +340,24 @@ public final class DefValidator {
if (n.deadlineHours != null && n.deadlineHours <= 0) {
problems.add(nodeLabel(n) + " deadlineHours 必须为正整数");
}
// 发起时指派策略(2026-10-13):存在即开启;发布期拦明显配错(mode 非法/SINGLE 带非 1 边界/边界倒挂)
if (n.assignPolicy != null) {
FlowDefModel.AssignPolicy p = n.assignPolicy;
if (p.mode != null && !FlowDefModel.ASSIGN_MODE_SINGLE.equals(p.mode)
&& !FlowDefModel.ASSIGN_MODE_MULTI.equals(p.mode)) {
problems.add(nodeLabel(n) + " assignPolicy.mode 取值非法:" + p.mode + "(支持 SINGLE/MULTI)");
}
if (FlowDefModel.ASSIGN_MODE_SINGLE.equals(p.mode)
&& ((p.min != null && p.min != 1) || (p.max != null && p.max != 1))) {
problems.add(nodeLabel(n) + " SINGLE 指派的 min/max 必须缺省或为 1(恰选 1 人)");
}
if (p.min != null && p.min < 1) {
problems.add(nodeLabel(n) + " assignPolicy.min 必须 ≥1");
}
if (p.min != null && p.max != null && p.min > p.max) {
problems.add(nodeLabel(n) + " assignPolicy.min 不得大于 max");
}
}
}
private static void dfs(FlowDefModel model, String current, Set<String> visited,
@@ -1,81 +1,201 @@
package com.yunhai.flowcenter.engine;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.entity.FlwTask;
import com.yunhai.flowcenter.entity.*;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.flowcenter.repository.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.*;
/**
* 节点放行推进(AFTER_COMMIT 监听器经代理调用本 bean,REQUIRES_NEW 生效——
* 事务已提交后的同步监听阶段没有活动事务,@Modifying 批量语句必须有新事务承载):
* 该节点待办清零且办件在途才推进;并发冲突由 flw_instance.version 乐观锁暴露,监听器层负责重试。
*/
@Slf4j
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.LocalDateTime;
import java.util.*;
/** D09: durable activation membership, serialized last-handler payload, and revision-scoped settlement. */
@Service
@RequiredArgsConstructor
public class FlowAdvanceService {
private final FlwInstanceRepository instanceRepository;
private final FlwDefRepository defRepository;
private final FlwTaskRepository taskRepository;
private final FlowEngine flowEngine;
private final FlwAdvanceIntentRepository intentRepository;
@PersistenceContext private EntityManager entityManager;
/** The listener groups the existing totalTasks TASK_CREATED events within their creation transaction. */
@Transactional(propagation = Propagation.MANDATORY)
public Long registerTask(FlowEvent event, Long activationIntentId) {
String nodeId = (String) event.getData().get("nodeId");
FlwAdvanceIntent intent;
if (activationIntentId == null) {
FlwInstance instance = instanceRepository.findById(event.getInstanceId()).orElseThrow(IllegalStateException::new);
intent = new FlwAdvanceIntent();
intent.setInstanceId(instance.getId());intent.setNodeId(nodeId);
intent.setActivationTaskId(event.getTaskId());
intent.setActivationDefId(instance.getDefId());intent.setActivationDefVersion(instance.getDefVersion());
intent.setActivationApplyRound(instance.getApplyRound());
intent.setStatus(FlwAdvanceIntent.STATE_DONE); // Registration only; no advance work before handling.
intent.setNextRetryAt(LocalDateTime.now());
} else {
intent = intentRepository.findById(activationIntentId).orElseThrow(IllegalStateException::new);
if (!Objects.equals(intent.getInstanceId(), event.getInstanceId()) || !Objects.equals(intent.getNodeId(), nodeId)) {
throw new IllegalStateException("Activation registration identity mismatch");
}
}
intent.addTask(event.getTaskId());
return intentRepository.save(intent).getId();
}
/** TRANSFERRED lacks the parent task ID. Accept only a uniquely proven parent activation, never guess by max ID. */
@Transactional(propagation = Propagation.MANDATORY)
public void registerTransfer(FlowEvent event) {
String nodeId = (String) event.getData().get("fromNodeId");
List<FlwAdvanceIntent> generations = intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId);
Set<Long> parents = new HashSet<>();
List<Long> candidates = taskRepository.findTransferParentCandidates(event.getTaskId(), event.getOperatorId());
for (Long taskId : candidates) {
FlwAdvanceIntent parent = memberOf(generations, taskId);
if (parent == null) throw new IllegalStateException("D09 transfer parent requires audited activation backfill");
parents.add(parent.getId());
}
if (parents.size() != 1) throw new IllegalStateException("D09 ambiguous transfer activation; parent identity is required");
FlwAdvanceIntent parent = lockIntent(parents.iterator().next());
parent.addTask(event.getTaskId());
intentRepository.save(parent);
}
/** Called synchronously in the task transaction, after claimAndFinish. The head lock orders commits, not clocks/IDs. */
@Transactional(propagation = Propagation.MANDATORY)
public void recordHandled(FlowEvent event) {
String nodeId = (String) event.getData().get("nodeId");
FlwAdvanceIntent candidate = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(event.getInstanceId(), nodeId), event.getTaskId());
if (candidate == null) throw new IllegalStateException("D09 task has no proven activation; audited backfill required");
FlwAdvanceIntent intent = lockIntent(candidate.getId());
if (intent.handledTask(event.getTaskId())) return; // Duplicate/late publication cannot rewind the last handler.
intent.addHandledTask(event.getTaskId());intent.setTriggerTaskId(event.getTaskId());
intent.setEdgeName((String) event.getData().get("edgeName"));
intent.setAssigneeId((Integer) event.getData().get("assigneeId"));
intent.setAssigneeName((String) event.getData().get("assigneeName"));
intent.setStatus(FlwAdvanceIntent.STATE_PENDING);intent.setAttempts(0);
intent.setNextRetryAt(LocalDateTime.now());intent.setLastError(null);
intentRepository.save(intent);
}
public static final class IntentRef {
public final Long id;
public final Long revision;
public IntentRef(Long id, Long revision) { this.id = id; this.revision = revision; }
}
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
public IntentRef pendingForTask(Long instanceId, String nodeId, Long taskId) {
FlwAdvanceIntent intent = memberOf(intentRepository.findByInstanceIdAndNodeIdOrderByIdDesc(instanceId, nodeId), taskId);
return intent == null || !FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) ? null
: new IntentRef(intent.getId(), intent.getRevision());
}
/**
* @param nodeId 待放行节点
* @param edgeName 多出口节点所选出口(办理事件携带);可空——兜底从该节点
* 最近一条已同意任务的 edge_name 列重放(推进在 AFTER_COMMIT 新事务重查,
* 事件传参只是快路径,落库的值才是权威)
* @param operator 本节点办理人(TASK_HANDLED 事件 data 穿透;推进产生的
* TASK_CREATED/COMPLETED 操作人信封字段来源),可空
* @return 是否实际推进了
* One transaction owns validation, engine effects and settlement. RC sees the last committed task status;
* the head lock serializes against last-handler refresh, and instance @Version fences reactivation writes.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean tryAdvance(Long instanceId, String nodeId, String edgeName, FlowOperator operator) {
FlwInstance instance = instanceRepository.findById(instanceId).orElse(null);
if (instance == null
|| instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED)
public boolean tryAdvance(Long intentId, Long observedRevision) {
FlwAdvanceIntent intent = lockIntent(intentId);
if (!ownsRevision(intent, observedRevision)) return false;
FlwAdvanceIntent latest = intentRepository.findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(
intent.getInstanceId(), intent.getNodeId()).orElse(null);
if (latest != null && !Objects.equals(latest.getId(), intent.getId())) {
done(intent, "D09_STALE_ACTIVATION: successor=" + latest.getId());
handoff(latest.getId());
return false;
}
// 已推进过的节点不再放行(currentNodes 由引擎在放行时移除,乐观锁保证互斥)
String current = instance.getCurrentNodes();
boolean active = current != null && java.util.Arrays.stream(current.split(","))
.anyMatch(nodeId::equals);
if (!active) {
if (intent.getActivationTaskId() == null || !intent.containsTask(intent.getActivationTaskId())
|| intent.getTriggerTaskId() == null || !intent.handledTask(intent.getTriggerTaskId())) {
hold(intent, "D09_UNANCHORED: audited cohort/last-handler backfill required; not executed");
return false;
}
long pending = taskRepository.findByInstanceIdAndStatus(instanceId,
FlwEnums.TaskStatus.PENDING.getCode()).stream()
.filter(t -> nodeId.equals(t.getNodeId()))
.count();
if (pending > 0) {
return false;
FlwInstance instance = instanceRepository.findById(intent.getInstanceId()).orElse(null);
if (instance == null || instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()
|| !active(instance, intent.getNodeId())) {
done(intent, null);return false;
}
FlwDef def = defRepository.findById(instance.getDefId()).orElse(null);
if (def == null) {
log.error("[flow-center] 办件定义缺失,推进中止:instance={} defId={}",
instanceId, instance.getDefId());
return false;
if (!Objects.equals(instance.getDefId(), intent.getActivationDefId())
|| !Objects.equals(instance.getDefVersion(), intent.getActivationDefVersion())
|| !Objects.equals(instance.getApplyRound(), intent.getActivationApplyRound())) {
hold(intent, "D09_IDENTITY_MISMATCH: no proven successor; audited backfill required");return false;
}
String chosen = edgeName;
if (chosen == null) {
chosen = taskRepository.findByInstanceIdAndStatus(instanceId,
FlwEnums.TaskStatus.APPROVED.getCode()).stream()
.filter(t -> nodeId.equals(t.getNodeId()) && t.getEdgeName() != null)
.max(java.util.Comparator.comparing(
FlwTask::getHandleTime, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder())))
.map(FlwTask::getEdgeName)
.orElse(null);
boolean pending = taskRepository.findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode())
.stream().anyMatch(t -> intent.getNodeId().equals(t.getNodeId()));
if (pending) {
// A future handler holds/obtains this same head lock and must re-arm it in its task transaction.
done(intent, null);return false;
}
flowEngine.continueAdvance(instance, def, nodeId, chosen, operator);
instanceRepository.save(instance);
FlwTask trigger = taskRepository.findById(intent.getTriggerTaskId()).orElse(null);
if (trigger == null || !intent.containsTask(trigger.getId())
|| !Objects.equals(trigger.getInstanceId(), instance.getId())
|| !Objects.equals(trigger.getNodeId(), intent.getNodeId())
|| !Integer.valueOf(FlwEnums.TaskStatus.APPROVED.getCode()).equals(trigger.getStatus())) {
hold(intent, "D09_INVALID_LAST_HANDLER: not executed");return false;
}
FlwDef def = defRepository.findById(instance.getDefId()).orElseThrow(() -> new IllegalStateException("Missing activation definition"));
flowEngine.continueAdvance(instance, def, intent.getNodeId(), trigger.getEdgeName(),
FlowOperator.of(trigger.getAssigneeId(), trigger.getAssigneeName()));
if (active(instance, intent.getNodeId())) throw new IllegalStateException("Activation did not advance; retaining recoverable intent");
instanceRepository.saveAndFlush(instance); // An optimistic conflict rolls back engine writes AND settlement.
done(intent, null);
return true;
}
/** Never merge a worker's detached row. A refreshed revision owns its own retry budget. */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void defer(Long intentId, Long observedRevision, String error) {
FlwAdvanceIntent intent = lockIntent(intentId);
if (!ownsRevision(intent, observedRevision)) return;
intent.setAttempts(intent.getAttempts() + 1);intent.setLastError(truncate(error));
if (intent.getAttempts() >= 6) intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED);
else intent.setNextRetryAt(LocalDateTime.now().plusSeconds(Math.min(1800, 30L * (1L << (intent.getAttempts() - 1)))));
intentRepository.save(intent);
}
private FlwAdvanceIntent lockIntent(Long id) {
// Queries in a handling transaction may already have loaded a stale RR snapshot into the identity map.
// A lock upgrade on that managed row checks its obsolete @Version before refresh can run.
// Flush our own changes, detach ONLY this row, then load a new managed row with a locking current read.
entityManager.flush();
FlwAdvanceIntent cached = entityManager.find(FlwAdvanceIntent.class, id);
if (cached != null) entityManager.detach(cached);
return intentRepository.findByIdForUpdate(id).orElse(null);
}
private void handoff(Long successorId) {
FlwAdvanceIntent successor = lockIntent(successorId);
// A stale worker must not consume its successor. Re-arm a previously waiting head, retaining its own payload.
if (successor != null && successor.getTriggerTaskId() != null && FlwAdvanceIntent.STATE_DONE.equals(successor.getStatus())) {
successor.setStatus(FlwAdvanceIntent.STATE_PENDING);successor.setAttempts(0);
successor.setNextRetryAt(LocalDateTime.now());successor.setLastError(null);intentRepository.save(successor);
}
}
private static FlwAdvanceIntent memberOf(List<FlwAdvanceIntent> intents, Long taskId) {
FlwAdvanceIntent found = null;
for (FlwAdvanceIntent intent : intents) if (intent.containsTask(taskId)) {
if (found != null && !Objects.equals(found.getId(), intent.getId())) throw new IllegalStateException("Task belongs to multiple activations");
found = intent;
}
return found;
}
private static boolean ownsRevision(FlwAdvanceIntent intent, Long revision) {
return intent != null && FlwAdvanceIntent.STATE_PENDING.equals(intent.getStatus()) && Objects.equals(intent.getRevision(), revision);
}
private static boolean active(FlwInstance instance, String nodeId) {
return instance.getCurrentNodes() != null && Arrays.asList(instance.getCurrentNodes().split(",")).contains(nodeId);
}
private void done(FlwAdvanceIntent intent, String reason) {
intent.setStatus(FlwAdvanceIntent.STATE_DONE);intent.setLastError(reason);intentRepository.save(intent);
}
private void hold(FlwAdvanceIntent intent, String reason) {
intent.setStatus(FlwAdvanceIntent.STATE_EXHAUSTED);intent.setLastError(reason);intentRepository.save(intent);
}
private static String truncate(String s) { return s == null || s.length() <= 255 ? s : s.substring(0,255); }
}
@@ -46,12 +46,28 @@ public class FlowDefModel {
public static final String ASSIGNEE_DEPT_KEY = "DEPT_KEY";
public static final String ASSIGNEE_USERS = "USERS";
/** 发起时指派选择模式:SINGLE=恰 1 人;MULTI=多选(min~max) */
public static final String ASSIGN_MODE_SINGLE = "SINGLE";
public static final String ASSIGN_MODE_MULTI = "MULTI";
public String code;
public String name;
public String start;
public List<Node> nodes;
public List<Edge> edges;
public Settings settings;
/** null = 历史无契约版本;新发布必须显式声明(无业务变量时为 [])。 */
public List<Variable> variables;
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Variable {
public String key;
/** string/number/boolean/date/datetime/money/stringArray/fileArray/userArray */
public String type;
public Boolean required;
@com.fasterxml.jackson.annotation.JsonProperty("enum")
public List<String> enumValues;
}
/**
* 画布坐标(nodeId → 左上角),仅前端设计器/查看器复用;引擎不消费。
* 缺失时前端自行做自动布局,故可选、可空。
@@ -81,6 +97,8 @@ public class FlowDefModel {
public String waitFor;
/** 办理时限小时数(自然时间,任务 arrive_time + N 小时;可空) */
public Integer deadlineHours;
/** 发起时指派策略(2026-10-13 方案):存在即允许指派;缺省/缺失=不允许(向前兼容) */
public AssignPolicy assignPolicy;
}
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -91,6 +109,16 @@ public class FlowDefModel {
public List<Integer> userIds;
}
/** 发起时指派策略:mode 必填(容错 null=MULTI);min/max 可空(缺省 1/候选全量数);required 仅子应用 UI 用 */
@JsonIgnoreProperties(ignoreUnknown = true)
public static class AssignPolicy {
/** SINGLE | MULTI */
public String mode;
public Integer min;
public Integer max;
public Boolean required;
}
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Edge {
public String from;
@@ -45,6 +45,9 @@ public class FlowEngine {
/** 发起后首推:从 START 激活(instance 已建好、status=10)。operator=null:TASK_CREATED/COMPLETED 的操作人回退发起人 */
public void startAdvance(FlwInstance instance, FlwDef def) {
FlowDefModel model = parseModel(def);
// D11:整份定义的固定 USERS 先整体预检,非法人不得等到后续节点才暴露(会留下卡单)。
// 校验失败抛业务异常,由发起事务整体回滚(零实例零任务)。
assigneeResolver.requireEligibleFixedAssignees(instance.getSchId(), model);
setCurrentNodes(instance, null);
advance(instance, model, model.start, null, null);
}
@@ -209,10 +212,14 @@ public class FlowEngine {
}
}
/** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件。operator=null 回退发起人 */
/** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件。operator=null 回退发起人。
* 命中发起时指派快照则用指派子集替代全量候选(不变量 I1 收窄不改变 countersign 放行语义、I4 信任快照) */
private void createTasks(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node,
FlowOperator operator) {
List<AssigneeRef> assignees = assigneeResolver.resolve(instance.getSchId(), node);
List<AssigneeRef> designated = AssignDesignations.parse(instance.getAssignJson()).get(node.id);
List<AssigneeRef> assignees = (designated != null && !designated.isEmpty())
? designated
: assigneeResolver.resolve(instance.getSchId(), node);
LocalDateTime now = LocalDateTime.now();
LocalDateTime deadline = node.deadlineHours == null ? null : now.plusHours(node.deadlineHours);
FlowOperator op = operator != null ? operator
@@ -69,6 +69,55 @@ 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;
// ===== T05 稳定事件身份(2026-10-25) =====
/** 事实行 event_id:发布线程同步监听器在状态事务内落事实行时一次性赋值,全订阅方/重试复用 */
private String stableEventId;
/** 事实行 event_seq(实例内单调序号) */
private Integer stableEventSeq;
/**
* R2(Codex D04)冻结信封:事实落库时一次性构建的完整投递载荷 JSON(含 eventId/eventSeq/
* flowGuid 等全部信封字段)。正常分发与崩溃恢复分发共用同一份冻结体——恢复消息体不再缺
* eventId/eventSeq,两条路径字节级同构。事实行缺席(存量路径)为 null,由分发器现场构建。
*/
private String stablePayloadJson;
public void assignStableIdentity(String eventId, Integer eventSeq) {
if (this.stableEventId != null) {
return; // 只赋一次:幂等重入(同事件多监听器)不换身份
}
this.stableEventId = eventId;
this.stableEventSeq = eventSeq;
}
public String getStableEventId() {
return stableEventId;
}
public Integer getStableEventSeq() {
return stableEventSeq;
}
/** 随稳定身份一并冻结完整投递信封(只赋一次;身份与信封同源同事务) */
public void assignStablePayload(String payloadJson) {
if (this.stablePayloadJson != null) {
return;
}
this.stablePayloadJson = payloadJson;
}
public String getStablePayloadJson() {
return stablePayloadJson;
}
public void captureRoundIdentity(Integer round, String requestToken) {
if (roundIdentity != null) return;
Map<String, Object> identity = new HashMap<>();
identity.put("applyRound", round);
identity.put("requestToken", requestToken);
roundIdentity = java.util.Collections.unmodifiableMap(identity);
}
public static FlowEvent of(Type type, Long instanceId) {
return new FlowEvent(type, instanceId, null, null, null, new HashMap<>());
@@ -1,50 +1,102 @@
package com.yunhai.flowcenter.engine;
import com.yunhai.flowcenter.entity.FlwAdvanceIntent;
import com.yunhai.flowcenter.repository.FlwAdvanceIntentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* 节点放行推进监听器:TASK_HANDLED 事件 AFTER_COMMIT 后在新事务里重查——
* 该节点待办是否清零、办件是否仍在途,全部成立才继续推进(下一节点/JOIN 合并/END)。
* 会签并发办理时各方提交的快照互相看不到对方未提交的办理行,事务内判断
* "节点是否全部办结"必假,提交后重查才是唯一可靠时机;推进串行化由办件乐观锁兜底
* (重试循环在监听器侧,跨 bean 调用 {@link FlowAdvanceService#tryAdvance} 保证 REQUIRES_NEW 生效)。
*/
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/** TASK_CREATED establishes membership; TASK_HANDLED refreshes that same activation in the handling transaction. */
@Slf4j
@Component
@RequiredArgsConstructor
public class FlowNodeAdvanceListener {
private final FlowAdvanceService advanceService;
private final FlwAdvanceIntentRepository intentRepository;
@EventListener
@Order(Ordered.HIGHEST_PRECEDENCE + 2)
public void persistIntent(FlowEvent event) {
if (event.getData() == null) return;
if (event.getType() == FlowEvent.Type.TASK_CREATED) {
ActivationBatch batch = batch();
if (!batch.tasks.add(event.getTaskId())) return;
String key = event.getInstanceId() + ":" + event.getData().get("nodeId");
int total = ((Number) event.getData().get("totalTasks")).intValue();
if (total <= 0) throw new IllegalStateException("Missing activation batch size");
Registration registration = batch.nodes.get(key);
if (registration == null) { registration = new Registration(total);batch.nodes.put(key, registration); }
if (registration.total != total) throw new IllegalStateException("Activation batch size changed");
registration.intentId = advanceService.registerTask(event, registration.intentId);
if (--registration.remaining == 0) batch.nodes.remove(key);
} else if (event.getType() == FlowEvent.Type.TRANSFERRED) {
advanceService.registerTransfer(event);
} else if (event.getType() == FlowEvent.Type.TASK_HANDLED && event.getData().get("nodeId") != null) {
advanceService.recordHandled(event);
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onTaskHandled(FlowEvent event) {
if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) {
return;
}
if (event.getType() != FlowEvent.Type.TASK_HANDLED || event.getData() == null) return;
String nodeId = (String) event.getData().get("nodeId");
if (nodeId == null) {
return;
}
String edgeName = (String) event.getData().get("edgeName");
// 办理人随事件 data 穿透进推进链:新 TASK_CREATED/COMPLETED 的操作人信封字段(2026-09-20)
FlowOperator operator = FlowOperator.of(
(Integer) event.getData().get("assigneeId"),
(String) event.getData().get("assigneeName"));
if (nodeId == null) return;
FlowAdvanceService.IntentRef ref = advanceService.pendingForTask(event.getInstanceId(), nodeId, event.getTaskId());
if (ref == null) return;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
advanceService.tryAdvance(event.getInstanceId(), nodeId, edgeName, operator);
return;
} catch (ObjectOptimisticLockingFailureException e) {
log.warn("[flow-center] 推进冲突重试 {}/3:instance={} node={}",
attempt, event.getInstanceId(), nodeId);
try { advanceService.tryAdvance(ref.id, ref.revision);return; }
catch (ObjectOptimisticLockingFailureException conflict) {
log.warn("[flow-center] D09 advance conflict {}/3: intent={} revision={}", attempt, ref.id, ref.revision);
} catch (Exception failure) {
advanceService.defer(ref.id, ref.revision, failure.toString());return;
}
}
log.error("[flow-center] 推进重试耗尽(请人工检查):instance={} node={}",
event.getInstanceId(), nodeId);
advanceService.defer(ref.id, ref.revision, "AFTER_COMMIT retries exhausted; sweep will resume");
}
@Scheduled(fixedDelay = 30_000)
public void sweepIntents() {
for (FlwAdvanceIntent detached : intentRepository.findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
FlwAdvanceIntent.STATE_PENDING, LocalDateTime.now())) {
Long id = detached.getId(), revision = detached.getRevision();
try { advanceService.tryAdvance(id, revision); }
catch (Exception failure) { advanceService.defer(id, revision, failure.toString()); }
}
}
/** Synchronizations are suspended with REQUIRES_NEW; an outer activation batch cannot leak into its child. */
private static ActivationBatch batch() {
if (!TransactionSynchronizationManager.isSynchronizationActive()) throw new IllegalStateException("Activation requires its creation transaction");
for (TransactionSynchronization sync : TransactionSynchronizationManager.getSynchronizations()) {
if (sync instanceof ActivationBatch) return (ActivationBatch) sync;
}
ActivationBatch created = new ActivationBatch();
TransactionSynchronizationManager.registerSynchronization(created);return created;
}
private static final class ActivationBatch implements TransactionSynchronization {
final Map<String, Registration> nodes = new HashMap<>();
final java.util.Set<Long> tasks = new java.util.HashSet<>();
@Override public void beforeCommit(boolean readOnly) {
if (!nodes.isEmpty()) throw new IllegalStateException("Incomplete activation membership; creation transaction must roll back");
}
}
private static final class Registration {
final int total;
int remaining;
Long intentId;
Registration(int total) { this.total = total;this.remaining = total; }
}
}
@@ -16,7 +16,8 @@ import java.util.Map;
* 身份即本应用 appKey(100019)——签名拦截器由 {@link YschOpenCall} 装配链注入,与回调推送同构。
* 寻址:service_key 走服务目录三级解析(targets > services > 枚举兜底);
* flw_router.base_path 非空时覆盖目录条目的 context-path(网关形态目录无前缀、实际服务有的场景),
* 漏填必 401(规范 §4.2 同款坑)。信封解析复用 SDK({code,msg,data})。
* 漏填必 401(规范 §4.2 同款坑)。<b>direct_host 非空的自注册行跳过目录解析直连</b>(2026-10-12 收口)。
* 信封解析复用 SDK({code,msg,data})。
*
* <p>重试纪律:同步 3s×3 次退避重试(500ms/1500ms),业务方义务=幂等纯读轻实现。
* 全部失败抛 {@link JudgeUnavailableException}(由调用方落 outlet=NULL 留痕并挂起)。
@@ -121,14 +122,29 @@ public class RouteJudgeClient {
throw new JudgeUnavailableException("判断服务调用失败(已重试 " + MAX_ATTEMPTS + " 次)", last);
}
/** 目标解析:目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),判断专用 3s 超时 */
/**
* 目标解析:
* <ul>
* <li>{@code direct_host} 非空(2026-10-12 starter 自注册行)→ {@link TargetSpec#fromUrl(String)}
* 拆基址直连,<b>跳过服务目录三级解析</b>(service_key/base_path 手工核对坑消失);</li>
* <li>否则(存量手工行):目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),行为逐字不变。</li>
* </ul>
* 两分支同设判断专用 3s 超时。
*/
private TargetSpec resolveTarget(FlwRouter router) {
String directHost = router.getDirectHost();
if (directHost != null && !directHost.trim().isEmpty()) {
return withJudgeTimeout(TargetSpec.fromUrl(directHost.trim()));
}
TargetSpec resolved = openCall.resolveTarget(router.getServiceKey());
String basePath = resolved.getBasePath();
if (router.getBasePath() != null && !router.getBasePath().trim().isEmpty()) {
basePath = router.getBasePath().trim();
}
TargetSpec spec = TargetSpec.of(resolved.getBaseHost(), basePath);
return withJudgeTimeout(TargetSpec.of(resolved.getBaseHost(), basePath));
}
private static TargetSpec withJudgeTimeout(TargetSpec spec) {
spec.setConnectTimeoutMs((long) TIMEOUT_MS);
spec.setReadTimeoutMs((long) TIMEOUT_MS);
return spec;
@@ -0,0 +1,209 @@
package com.yunhai.flowcenter.engine;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** D10 定义与发起共用的纯校验器;不转换输入,不依赖 FC/数据库。 */
public final class VariableContract {
private VariableContract() { }
private static final Set<String> TYPES = new HashSet<>(Arrays.asList(
"string", "number", "boolean", "date", "datetime", "money", "stringArray", "fileArray", "userArray"));
private static final String TOKEN = "(?:'(?:[^'\\\\]|\\\\.)*'|\"(?:[^\"\\\\]|\\\\.)*\"|-?\\d+(?:\\.\\d+)?|[A-Za-z_][A-Za-z0-9_]*)";
// 先分词以跳过完整字符串;不把函数参数字符串中的 "amount > 3" 当成比较。
private static final Pattern TOKENS = Pattern.compile(TOKEN + "|==|!=|>=|<=|>|<|\\S");
private static final Set<String> COMPARISONS = new HashSet<>(Arrays.asList("==", "!=", ">=", "<=", ">", "<"));
public static Set<String> referencedKeys(FlowDefModel model) {
Set<String> keys = new LinkedHashSet<>();
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !n.expressionJudge()) continue;
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null || e.condition.trim().isEmpty()) continue;
try {
for (String key : ExpressionEvaluator.variableNames(e.condition.trim())) {
if (!DefValidator.EXPRESSION_BUILTINS.contains(key)) keys.add(key);
}
} catch (RuntimeException ex) {
// 历史版本保持原有语法异常跳过口径;新契约必须 fail closed。
if (model.variables != null) fail("表达式不合法:" + ex.getMessage());
}
}
}
return keys;
}
public static void validateDefinition(FlowDefModel model, boolean publishing) {
if (model.variables == null) {
if (publishing) fail("新发布版本必须声明 variables(无变量请显式声明 [])");
return;
}
Map<String, FlowDefModel.Variable> fields = new LinkedHashMap<>();
for (FlowDefModel.Variable v : model.variables) {
if (v == null || v.key == null || !v.key.matches("[A-Za-z0-9_]{1,64}")) fail("变量 key 不合法");
if (DefValidator.EXPRESSION_BUILTINS.contains(v.key)) fail("变量不能占用内置键:" + v.key);
if (fields.put(v.key, v) != null) fail("变量 key 重复:" + v.key);
if (!TYPES.contains(v.type)) fail("变量类型不支持:" + v.key + " / " + v.type);
if (v.enumValues != null) {
if (!"string".equals(v.type) && !"stringArray".equals(v.type)) fail("enum 仅适用于字符串及字符串数组:" + v.key);
if (v.enumValues.isEmpty() || v.enumValues.contains(null)
|| new HashSet<>(v.enumValues).size() != v.enumValues.size()) fail("enum 值域为空或重复:" + v.key);
for (String option : v.enumValues) {
if (blank(option)) fail("enum 值域不能含空白:" + v.key);
validateValue(v, "stringArray".equals(v.type) ? Collections.singletonList(option) : option, true);
}
}
}
for (String key : referencedKeys(model)) {
if (!fields.containsKey(key)) fail("表达式变量 " + key + " 未声明(不在关联表单字段集内或 variables 中)");
}
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !n.expressionJudge()) continue;
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null) continue;
Matcher m = TOKENS.matcher(e.condition);
List<String> tokens = new ArrayList<>();
while (m.find()) tokens.add(m.group());
for (int i = 1; i + 1 < tokens.size(); i++) {
if (!COMPARISONS.contains(tokens.get(i))) continue;
checkLiteral(fields.get(tokens.get(i-1)), tokens.get(i+1));
checkLiteral(fields.get(tokens.get(i+1)), tokens.get(i-1));
}
}
}
}
private static void checkLiteral(FlowDefModel.Variable field, String token) {
if (field == null || "null".equals(token)) return;
Object value;
if (token.startsWith("'") || token.startsWith("\"")) {
// Aviator 的转义交由编译器;带转义字面量不做正则域推断。
if (token.indexOf('\\') >= 0) return;
value = token.substring(1, token.length() - 1);
} else if ("true".equals(token) || "false".equals(token)) value = Boolean.valueOf(token);
else if (token.matches("-?\\d+(\\.\\d+)?")) value = new BigDecimal(token);
else return;
try { validateValue(field, value, true); }
catch (BizException invalid) { fail("类型桶查②/域查③:" + invalid.getMessage()); }
}
public static void validateInput(FlowDefModel model, Map<String, Object> submitted) {
Set<String> refs = referencedKeys(model);
if (model.variables == null) {
for (String key : refs) {
if (!submitted.containsKey(key) || submitted.get(key) == null) fail("流程条件所需变量缺失或为空(key=" + key + ")");
}
return;
}
validateDefinition(model, false);
Set<String> declared = new HashSet<>();
for (FlowDefModel.Variable v : model.variables) {
declared.add(v.key);
validateValue(v, submitted.get(v.key), Boolean.TRUE.equals(v.required) || refs.contains(v.key));
}
for (String key : submitted.keySet()) if (!declared.contains(key)) fail("未声明的变量:" + key);
}
/** 必填/表达式引用均需实值;可选 null 可省略,其他非 null 值始终按类型与值域检查。 */
private static void validateValue(FlowDefModel.Variable v, Object value, boolean required) {
if (value == null) {
if (required) fail("变量缺失或为空:" + v.key);
return;
}
if (required && ((value instanceof String && blank((String) value))
|| (value instanceof Collection && ((Collection<?>) value).isEmpty()))) fail("变量未填写:" + v.key);
// FC 文本/单选和日期控件的清空值为 "";仅非必填且未被表达式引用时视作未填。
// 数值控件空值是 null,不能借空字符串绕过 number/money/boolean/数组的类型检查。
if (!required && "".equals(value) && Arrays.asList("string", "date", "datetime").contains(v.type)) return;
boolean valid = false;
switch (v.type) {
case "string": valid = value instanceof String; break;
case "number": case "money":
if (value instanceof Number) {
try { new BigDecimal(value.toString()); valid = true; } catch (NumberFormatException ignored) { }
}
break;
case "boolean": valid = value instanceof Boolean; break;
case "date":
if (value instanceof String && ((String)value).matches("\\d{4}-\\d{2}-\\d{2}")) {
try { LocalDate.parse((String)value); valid = true; } catch (RuntimeException ignored) { }
}
break;
case "datetime":
if (value instanceof String && ((String)value).matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})?")) {
try { LocalDateTime.parse((String)value); valid = true; }
catch (RuntimeException local) {
try { OffsetDateTime.parse((String)value); valid = true; } catch (RuntimeException ignored) { }
}
}
break;
case "stringArray": case "fileArray": case "userArray":
valid = value instanceof List;
if (valid) for (Object item : (List<?>)value) {
if ("stringArray".equals(v.type)) {
if (!(item instanceof String) || (v.enumValues != null && !v.enumValues.contains(item))) valid = false;
} else if (!validRecord(item, "fileArray".equals(v.type))) valid = false;
}
break;
default: break;
}
if (!valid) fail("变量类型/格式不合法:" + v.key + "(应为 " + v.type + ")");
if (value instanceof String && v.enumValues != null && !v.enumValues.contains(value)) fail("变量 " + v.key + " 不在枚举值域 " + v.enumValues + ":" + value);
}
private static boolean validRecord(Object value, boolean file) {
if (!(value instanceof Map)) return false;
Map<?, ?> row = (Map<?, ?>)value;
Object id = row.get("id");
try {
if (!(id instanceof Number) || new BigDecimal(id.toString()).scale() > 0 || ((Number)id).longValue() <= 0) return false;
} catch (NumberFormatException invalid) { return false; }
return file ? nonblank(row.get("fileName")) && nonblank(row.get("key")) : nonblank(row.get("name"));
}
private static boolean nonblank(Object value) { return value instanceof String && !blank((String)value); }
private static boolean blank(String value) {
return value.codePoints().allMatch(c -> Character.isWhitespace(c) || Character.isSpaceChar(c));
}
/** FC 快照是唯一派生来源,保存时传入的 variables 不能覆盖绑定表单的契约。 */
public static List<FlowDefModel.Variable> fromSnapshot(Map<String, Object> snapshot) {
Object fields = snapshot.get("fields");
if (!(fields instanceof List)) fail("formContract.fields 必须为数组");
List<FlowDefModel.Variable> variables = new ArrayList<>();
for (Object item : (List<?>)fields) {
if (!(item instanceof Map)) fail("formContract 字段不合法");
Map<?, ?> field = (Map<?, ?>)item;
FlowDefModel.Variable v = new FlowDefModel.Variable();
v.key = (String)field.get("key");
v.required = Boolean.TRUE.equals(field.get("required"));
String type = String.valueOf(field.get("type"));
switch (type) {
case "text": case "textarea": case "phone": case "select": case "radio": v.type = "string"; break;
case "number": case "date": v.type = type; break;
case "checkbox": case "multiselect": case "address": v.type = "stringArray"; break;
case "image": case "upload": v.type = "fileArray"; break;
case "user": case "users": v.type = "userArray"; break;
default: fail("FC 字段类型不支持:" + v.key + " / " + type);
}
if (Arrays.asList("select", "radio", "checkbox", "multiselect").contains(type)) {
Object enums = field.get("enum");
if (!(enums instanceof List) || ((List<?>)enums).isEmpty()) fail("FC 枚举值域缺失:" + v.key);
v.enumValues = new ArrayList<>();
for (Object option : (List<?>)enums) {
if (!(option instanceof String)) fail("FC 枚举必须为字符串:" + v.key);
v.enumValues.add((String)option);
}
}
variables.add(v);
}
return variables;
}
private static void fail(String message) { throw new BizException(ErrorCode.PARAM_ERROR, message); }
}
@@ -0,0 +1,107 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.Version;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 待推进意图(2026-10-25 T05):任务办理事务内持久化"这个节点该继续推进了"的事实,
* 事务外 worker 领取执行——办理事务提交成功 ⇔ 推进意图已持久化,进程崩溃/重启后
* 扫描续跑(旧链路 AFTER_COMMIT 三次内存重试,耗尽只 log.error,重启即丢)。
*
* <p>D09:每个激活批次记录精确任务成员,未办理时以 DONE 等待;办理同事务串行刷新
* 本批次的末笔任务及 PENDING 工作。worker 按 id+revision 锁定当前读,推进与结算同事务;
* NULL 代次只允许明确接棒或留待有证据的人工回填,不作为本代放行。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "flw_advance_intent",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_advance_activation", columnNames = "activation_task_id"),
indexes = {
@Index(name = "idx_flw_advance_intent_due", columnList = "status, next_retry_at"),
@Index(name = "idx_flw_advance_intent_instance", columnList = "instance_id")})
public class FlwAdvanceIntent extends BaseEntity {
public static final String STATE_PENDING = "PENDING";
public static final String STATE_DONE = "DONE";
public static final String STATE_EXHAUSTED = "EXHAUSTED";
@Column(name = "instance_id", nullable = false)
private Long instanceId;
/** 触发推进的节点(TASK_HANDLED 的 nodeId) */
@Column(name = "node_id", nullable = false, length = 64)
private String nodeId;
/** First task CREATED in this activation batch; never the last task to finish. NULL = unanchored legacy work. */
@Column(name = "activation_task_id")
private Long activationTaskId;
/** Exact membership, including transfers; comma-delimited positive task IDs, e.g. ,12,14,. */
@Column(name = "activation_task_ids", columnDefinition = "LONGTEXT")
private String activationTaskIds;
@Column(name = "handled_task_ids", columnDefinition = "LONGTEXT")
private String handledTaskIds;
@Column(name = "trigger_task_id")
private Long triggerTaskId;
@Column(name = "activation_def_id")
private Long activationDefId;
@Column(name = "activation_def_version")
private Integer activationDefVersion;
@Column(name = "activation_apply_round")
private Integer activationApplyRound;
/** A detached worker may act/defer only the revision it actually observed. */
@Version
@Column(name = "revision", nullable = false)
private Long revision = 0L;
public boolean containsTask(Long id) { return contains(activationTaskIds, id); }
public boolean handledTask(Long id) { return contains(handledTaskIds, id); }
public void addTask(Long id) { if (!containsTask(id)) activationTaskIds = append(activationTaskIds, id); }
public void addHandledTask(Long id) { if (!handledTask(id)) handledTaskIds = append(handledTaskIds, id); }
private static boolean contains(String ids, Long id) { return ids != null && id != null && ids.contains("," + id + ","); }
private static String append(String ids, Long id) {
if (id == null || id <= 0) throw new IllegalArgumentException("Missing activation task identity");
return (ids == null ? "," : ids) + id + ",";
}
/** 出边名(可空=引擎默认路由) */
@Column(name = "edge_name", length = 128)
private String edgeName;
/** 办理人随行(推进链操作人信封,可空) */
@Column(name = "assignee_id")
private Integer assigneeId;
@Column(name = "assignee_name", length = 64)
private String assigneeName;
/** PENDING=待执行;DONE=已执行(含推进判定为无需推进的幂等结论);EXHAUSTED=重试耗尽留人工 */
@Column(name = "status", nullable = false, length = 16)
private String status = STATE_PENDING;
@Column(name = "attempts", nullable = false)
private Integer attempts = 0;
/** 退避到期时间(耗尽前按指数退避续扫) */
@Column(name = "next_retry_at")
private LocalDateTime nextRetryAt;
@Column(name = "last_error", length = 255)
private String lastError;
}
@@ -0,0 +1,24 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
/** Immutable settled command fact: ACCEPTED or REJECTED. Never repurpose a request ID. */
@Entity @Getter @Setter
@Table(name="flw_apply_command", uniqueConstraints=@UniqueConstraint(name="uk_flw_command_request",columnNames={"sch_id","source_principal_id","request_id"}),indexes=@Index(name="idx_flw_command_instance",columnList="instance_id"))
public class FlwApplyCommand extends BaseEntity {
@Column(name="sch_id",nullable=false) private Long schId;
@Column(name="source_principal_id",nullable=false,length=36) private String sourcePrincipalId;
@Column(name="request_id",nullable=false,length=64) private String requestId;
@Column(name="canonical_hash",nullable=false,length=64) private String canonicalHash;
@Column(name="canonical_version",nullable=false) private Integer canonicalVersion;
@Column(name="biz_type",nullable=false,length=32) private String bizType;
@Column(name="biz_id",nullable=false,length=64) private String bizId;
@Column(name="flow_guid",nullable=false,length=32) private String flowGuid;
@Column(name="state",nullable=false,length=16) private String state;
@Column(name="instance_id") private Long instanceId;
@Column(name="apply_round") private Integer round;
@Column(name="def_version") private Integer defVersion;
@Column(name="def_id") private Long defId;
@Column(name="reject_code",length=64) private String rejectCode;
@Column(name="reject_detail",length=500) private String rejectDetail;
}
@@ -0,0 +1,15 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
@Entity @Getter @Setter
@Table(name="flw_biz_coordinate", uniqueConstraints=@UniqueConstraint(name="uk_flw_coord", columnNames={"sch_id","source_principal_id","biz_type","biz_id"}))
public class FlwBizCoordinate extends BaseEntity {
@Column(name="sch_id",nullable=false) private Long schId;
@Column(name="source_principal_id",nullable=false,length=36) private String sourcePrincipalId;
@Column(name="biz_type",nullable=false,length=32) private String bizType;
@Column(name="biz_id",nullable=false,length=64) private String bizId;
@Column(name="last_round",nullable=false) private Integer lastRound = 0;
@Column(name="last_instance_id") private Long lastInstanceId;
@Column(name="active_instance_id") private Long activeInstanceId;
}
@@ -12,14 +12,16 @@ import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 回调推送日志 = 重试队列(概览 §4.9):eventId 唯一(重试不变,业务方按它幂等去重);
* 回调推送日志 = 重试队列(概览 §4.9;2026-10-25 R2 幂等键改复合):eventId 全订阅方复用
* (重试不变,业务方按它幂等去重),(event_id, reg_id) 复合唯一——同一事件对同一订阅方只建
* 一行,多订阅方各一行;并发 exists→insert 竞态由复合 uk 兜底(DuplicateKeyException=已建行)。
* payload_json 完整快照可重放;索引 (status, next_retry_time) 是扫描主路径。
*/
@Getter
@Setter
@Entity
@Table(name = "flw_callback_log",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event", columnNames = "event_id"),
uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event_reg", columnNames = {"event_id", "reg_id"}),
indexes = {
@Index(name = "idx_flw_log_scan", columnList = "status, next_retry_time"),
@Index(name = "idx_flw_log_inst", columnList = "instance_id")})
@@ -21,6 +21,9 @@ import javax.persistence.UniqueConstraint;
columnNames = {"sch_id", "app_key", "flow_guid"}),
indexes = @Index(name = "idx_flw_reg_flow", columnList = "sch_id, flow_guid, status"))
public class FlwCallbackReg extends BaseEntity {
@Column(name="source_principal_id",length=36)
private String sourcePrincipalId;
/** 租户键 */
@Column(name = "sch_id", nullable = false)
@@ -0,0 +1,10 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
/** Persistent admission mutex. Rows survive application deletion; never recycle. */
@Entity @Table(name="flw_command_source") @Getter @Setter
public class FlwCommandSource {
@Id @Column(length=36) private String id;
@Column(nullable=false) private Integer appId;
}
@@ -14,7 +14,7 @@ import java.time.LocalDateTime;
/**
* 流程定义(概览 §4.1 + V2.0 Part D #2/#3 修订)。
* flow_guid 绑定"这条流程线"而非某个版本:升版后业务方绑定关系零改动,新发起自动走
* 当前已发布最高版本;item_code/form_id 留可空列(M1 不启用,原生模式/表单二期)。
* 当前已发布最高版本;item_code/form_uuid 留可空列(M1 不启用,原生模式/表单二期)。
*/
@Getter
@Setter
@@ -56,9 +56,32 @@ public class FlwDef extends BaseEntity {
@Column(name = "item_code", length = 64)
private String itemCode;
/** 关联表单定义(外部托管模式可无表单,M1 恒 null;二期通用表单预留) */
@Column(name = "form_id")
private Long formId;
/** 关联表单定义 uuid(表单中心对外身份,32 位;M1 恒 null,二期对接时启用) */
@Column(name = "form_uuid", length = 32)
private String formUuid;
/**
* 关联表单的<b>确切版本</b>(v3/TASK_1):与 {@link #formUuid} 成对,发布期按这组坐标取快照,
* 中心返回的坐标与请求不符即阻断发布。
*
* <p>为什么必须钉版本而不是"发布时取当前发布版":同一条流程线的两次发布可能落到不同的表结构上,
* 而表达式校验、办件回显都按嵌入的 formContract 判——版本不钉住,"这条流程当时用的哪张表"
* 就成了只能猜的历史。存的是服务器侧核验过的版本号。</p>
*
* <p>null 只出现在本次改造之前已经关联了表单的存量行上(C1 第一刀没这列):
* 那种行发布时仍走"当前发布版"的旧口径,改造后新保存/新关联一律成对写入。</p>
*/
@Column(name = "form_version")
private Integer formVersion;
/**
* 线级停发标志(2026-10-25 T03):1=该 flowGuid 整线停发——阻止新发起/预览,不影响在途实例
* (在途按实例固定 defId 原版推进)。与版本级 status(DISABLED) 正交:线级停发后 enable()
* 恢复的只是版本状态,线仍不可新发起;enableLine 才恢复线可用性,且不改写任何发布内容字节。
* 批量 UPDATE 全版本行同值,读任一行即读线状态。
*/
@Column(name = "line_offline", nullable = false, columnDefinition = "INT NOT NULL DEFAULT 0")
private Integer lineOffline = 0;
/** §5.1 流程定义 JSON */
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
@@ -14,8 +14,9 @@ import java.time.LocalDateTime;
/**
* 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存
* biz_title/biz_url/biz_snapshot 快照与关联键;uk(sch_id, biz_type, biz_id) 幂等——
* 同一业务对象一条办件,重复代发起返回原办件;驳回/撤回后重提复用同一行(R4 重启语义)。
* biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为
* (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。
* 旧入口以 sourcePrincipalId + legacySlot=1 实现每来源一业务一行;正式命令 legacySlot=NULL。
*
* <p>version 是乐观锁(推进串行化)兼回调 payload 的 instanceVersion(业务方按它丢弃乱序旧事件),
* 由 JPA @Version 在 flush 时自增。
@@ -24,12 +25,19 @@ import java.time.LocalDateTime;
@Setter
@Entity
@Table(name = "flw_instance",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_instance_biz",
columnNames = {"sch_id", "biz_type", "biz_id"}),
uniqueConstraints = {
@UniqueConstraint(name = "uk_flw_instance_legacy", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "legacy_slot"}),
@UniqueConstraint(name = "uk_flw_instance_round", columnNames = {"sch_id", "source_principal_id", "biz_type", "biz_id", "apply_round"})},
indexes = {
@Index(name = "idx_flw_inst_submitter", columnList = "sch_id, submitter_id, status"),
@Index(name = "idx_flw_inst_status_time", columnList = "sch_id, status, submit_time")})
public class FlwInstance extends BaseEntity {
@Column(name="source_principal_id",length=36,updatable=false)
private String sourcePrincipalId;
/** 1 for source-scoped legacy API writers, NULL for formal commands. */
@Column(name="legacy_slot")
private Integer legacySlot = 1;
/** 租户键 */
@Column(name = "sch_id", nullable = false)
@@ -51,6 +59,10 @@ public class FlwInstance extends BaseEntity {
@Column(name = "form_data_json", columnDefinition = "LONGTEXT")
private String formDataJson;
/** 发起时指派快照:{nodeId:[{userId,userName}]};NULL=无指派(全量候选现状行为) */
@Column(name = "assign_json", columnDefinition = "LONGTEXT")
private String assignJson;
/** CMS 事项快照(原生模式,M1 不启用恒 null) */
@Column(name = "item_code", length = 64)
private String itemCode;
@@ -124,4 +136,48 @@ public class FlwInstance extends BaseEntity {
@Version
@Column(name = "version", nullable = false)
private Integer version;
/**
* 最近一次成功受理代发起的<b>请求标识</b>(消费方持久化命令里带的 token,v2 §8,TASK_2 C.3)。
*
* <p>它回答的是"这次 apply 是同一命令的重投,还是新一轮发起"——biz uk 只说得出
* "这个业务键已经有办件了",说不出这句。没有它时,消费方在响应丢失后重发同业务键,
* 命中 30/40 会被 R4 语义重启推进,等于<b>把网络重试当成用户重提</b>。
* 同 token 一律只回读现状、不重启;换 token 才是新一轮。</p>
*/
@Column(name = "apply_request_token", length = 64)
private String applyRequestToken;
/**
* 当前发起轮次(1 起)。正式命令由服务端坐标锁内分配,不接受客户端 attempt 分轮。
*
* <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
* 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p>
*/
@Column(name = "apply_round")
private Integer applyRound;
/**
* 受理时规范载荷指纹(2026-10-25 T04,SHA-256 十六进制):同 requestToken 必须同指纹——
* 同 ID 不同载荷是两份命令,撞在同一个 token 上按冲突拒绝,不伪装成幂等成功。
* 存量行(指纹列为 NULL)跳过该核对(守卫缺省是关,与 token/round 存量纪律一致)。
*/
@Column(name = "apply_payload_hash", length = 64)
private String applyPayloadHash;
/**
* 事件序号计数器(2026-10-25 T05):实例内事件事实 eventSeq 的发号器。发号时在事务内
* 悲观锁住本行(findByIdForUpdate 当前读)再 +1——REPEATABLE READ 下对聚合做快照读会
* 拿到陈旧 max(FlowEngineIT 会签并发实证),行上计数器+行锁才是串行安全的。
*/
@Column(name = "event_seq_counter")
private Integer eventSeqCounter;
/**
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
* 随实体序列化给前端画红标;业务代码不得读写该字段。
*/
@javax.persistence.Transient
private Boolean routeHung;
}
@@ -48,4 +48,18 @@ public class FlwRouter extends BaseEntity {
/** 1 启用 / 0 停用(停用后发布校验拦截引用,存量在途办件重跑会失败挂起) */
@Column(name = "status", nullable = false)
private Integer status = 1;
/**
* 自注册直连基址 {@code scheme://authority[/ctx]}(yh-flow-callback starter 启动登记)。
* 非空时 {@code RouteJudgeClient} 跳过服务目录三级解析直连——service_key/base_path 手工核对两坑消失;
* 空=存量手工行,继续按目录寻址(老行为零改动)。
*/
@Column(name = "direct_host", length = 255)
private String directHost;
/**
* 登记应用 appKey(自注册归属:upsert 时校验,防跨应用覆盖);管理端手工行为空=无归属。
*/
@Column(name = "reg_app_key", length = 64)
private String regAppKey;
}
@@ -0,0 +1,77 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 流程状态事件事实(2026-10-25 T05):状态/审批动作与不可变事件事实<b>同一数据库事务</b>落库。
*
* <p>关闭审计 E24 的丢失窗口:旧链路 AFTER_COMMIT 才按订阅生成 eventId、另事务落回调行——
* 提交到分发之间进程被杀,事件永久丢失(订阅方靠对账兜底)。本事实行在发布线程的同一事务内
* 写入:状态提交成功 ⇔ 事件事实已持久化;回调分发只是它的消费,崩溃后由 sweep 扫描补分发。</p>
*
* <p>身份:{@code eventId} 在事实创建时一次性生成,<b>全部订阅方/全部投递重试复用同一 eventId</b>
* (消费端 (event_id, consumer_app_key) 唯一键去重);{@code eventSeq} 为实例内<b>尽力单调</b>序号
* (FOR UPDATE 发号器;REQUIRES_NEW 子事务推进拓扑下极端并发窗口可能同值——有序性以
* eventSeq+发生时间为准,同实例严格全序需专用发号器,列入后续;不承诺全局副作用严格有序)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "flw_state_event",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_state_event_id", columnNames = {"event_id"}),
indexes = {
@Index(name = "idx_flw_state_event_instance", columnList = "instance_id"),
@Index(name = "idx_flw_state_event_dispatch", columnList = "dispatched, create_time"),
@Index(name = "idx_flw_state_event_seq", columnList = "instance_id, event_seq")})
public class FlwStateEvent extends BaseEntity {
/** 稳定事件身份(32 位 uuid 去横线;投递与重试全链复用) */
@Column(name = "event_id", nullable = false, length = 32)
private String eventId;
/** 租户键 */
@Column(name = "sch_id", nullable = false)
private Long schId;
/** 实例身份 */
@Column(name = "instance_id", nullable = false)
private Long instanceId;
/** 发布时冻结的申报轮次(旧事件不得被 AFTER_COMMIT 重读贴上新轮标签) */
@Column(name = "apply_round")
private Integer applyRound;
/** 实例内单调序号(1 起;同实例状态行锁内取 max+1) */
@Column(name = "event_seq", nullable = false)
private Integer eventSeq;
/** 事件类型码(FlowEvent.Type#getCode,与回调订阅枚举同值域) */
@Column(name = "event_type", nullable = false, length = 32)
private String eventType;
/** 关联任务(办件级事件为 null) */
@Column(name = "task_id")
private Long taskId;
/** 操作人(可空) */
@Column(name = "operator_id")
private Integer operatorId;
/** 完整载荷快照(与回调信封同构 JSON;发生时状态,不随后续变化) */
@Column(name = "payload_json", nullable = false, columnDefinition = "LONGTEXT")
private String payloadJson;
/**
* 分发标记:0=待分发(含崩溃窗口恢复);1=已按当前订阅生成了全部回调行(或无可订阅方)。
* 分发幂等:重扫前按 (eventId, regId) 查重,不重复建投递行。
*/
@Column(name = "dispatched", nullable = false)
private Integer dispatched = 0;
}
@@ -0,0 +1,63 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 应用使用授权(2026-10-25 T01):哪个<b>稳定应用主体</b>可以在哪所学校使用哪条流程线。
*
* <p>与 {@link FlwApp}(定义保存白名单)和流程定义管理权(FlowAdminGuard 管理角色)三权分立:
* 有 flw_app 行不等于能新发起;是应用管理员也不等于能编辑/发布学校流程定义。
* 许可键是三元 {@code (schId, flowGuid, appId)}——appId 是 UserBase application_info 主键
* (稳定主体,appKey 可重置/注销后不复用历史来源),appKey 快照仅供展示回溯。</p>
*
* <p>撤权语义:{@code enabled=0} 只拦截<b>新发起</b>;已受理命令的回执恢复、在途实例推进
* 不回读本表(P0-3 收口顺序:认证 → 自有命令回执 → 未命中才查新发起资格)。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "flw_usage_grant",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_usage_grant",
columnNames = {"sch_id", "flow_guid", "app_id"}),
indexes = {@Index(name = "idx_flw_usage_grant_sch_flow", columnList = "sch_id, flow_guid"),
@Index(name = "idx_flw_usage_grant_app", columnList = "app_id")})
public class FlwUsageGrant extends BaseEntity {
@Column(name="source_principal_id",length=36)
private String sourcePrincipalId;
/** 租户键 */
@Column(name = "sch_id", nullable = false)
private Long schId;
/** 流程线稳定身份(跨版本不变;版本归属由 flw_def 承载) */
@Column(name = "flow_guid", nullable = false, length = 64)
private String flowGuid;
/** 稳定应用主体主键(UserBase application_info.id;非 appKey 凭据索引) */
@Column(name = "app_id", nullable = false)
private Integer appId;
/** 授权时的 appKey 快照(展示/审计回溯用;判权不读它) */
@Column(name = "app_key_snapshot", length = 64)
private String appKeySnapshot;
/** 应用名快照(授权时 UserBase 返回值;同步不自动刷新) */
@Column(name = "app_name_snapshot", length = 128)
private String appNameSnapshot;
/** 1=许可生效;0=撤销(只拦新发起) */
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
/** 备注(谁/为何授予或撤销,人工审计线索) */
@Column(name = "remark", length = 255)
private String remark;
}
@@ -48,6 +48,8 @@ public final class FlwEnums {
public static final String NODE_RESUBMIT = "resubmit";
/** 留痕事件行保留节点 id:撤回 */
public static final String NODE_WITHDRAW = "withdraw";
/** 留痕事件行保留节点 id:管理端按新版定义重跑路由(F5-4,nodeName 载明 v旧→v新) */
public static final String NODE_REROUTE_DEF = "reroute-def";
private final int code;
@@ -0,0 +1,27 @@
package com.yunhai.flowcenter.enums;
import com.yunhai.platform.utils.exception.IErrorCode;
/** 管理面表单绑定契约:消费方按数值码判断恢复动作,不解析 msg。 */
public enum FormBindingErrorCode implements IErrorCode {
VERSION_REQUIRED(46101, "关联表单缺少明确版本"),
INVALID_COORDINATE(46102, "关联表单坐标或版本不合法"),
SCHEMA_UNAVAILABLE(46103, "关联表单 schema 拉取失败,可重试"),
SCHEMA_CONTRACT_VIOLATION(46104, "关联表单 schema 坐标核验失败"),
RESOURCE_UNAVAILABLE(46105, "关联表单或指定发布版本不存在或不可读"),
ACCESS_DENIED(46106, "关联表单访问身份或授权被拒绝");
private final int code;
private final String msg;
FormBindingErrorCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
@Override
public int getCode() { return code; }
@Override
public String getMsg() { return msg; }
}
@@ -0,0 +1,69 @@
package com.yunhai.flowcenter.model;
import lombok.Data;
import java.util.List;
/**
* 对接健康度体检结果(2026-10-15 议题二 P2):
* 每应用一行六项检查,数据全部来自已有资产(flw_callback_reg / flw_callback_log / probe),零新增表。
*
* <p>检查项固定六条(顺序即前端展示顺序),但用 List 承载以便未来增删:
* <ol>
* <li>登记地址形态(是否网关形态,非私网/开发机)</li>
* <li>base_path 与应用 ctx 一致性</li>
* <li>最近探测结果(最近的 flow.ping 日志)</li>
* <li>近 7 日推送成功率</li>
* <li>挂起 / 死信数</li>
* <li>有 flowGuid 无登记行(漏注册)</li>
* </ol>
*/
@Data
public class AppHealthVo {
/** 应用标识 */
private String appKey;
private String appName;
/** 综合评分 0~100(六项加权;无数据项中性处理,不扣分) */
private int score;
/** 总体结论:ok / warn / bad */
private String level;
/** 一句话结论(前端顶部提示条用) */
private String summary;
private List<CheckItem> checks;
/** 单条检查项 */
@Data
public static class CheckItem {
/** 检查项名称 */
private String name;
/** ok / warn / bad */
private String level;
/** 实测值展示文本(等宽字体渲染) */
private String value;
/** 可选:修复建议(level != ok 时给出) */
private String advice;
public CheckItem(String name, String level, String value) {
this.name = name;
this.level = level;
this.value = value;
}
public CheckItem(String name, String level, String value, String advice) {
this.name = name;
this.level = level;
this.value = value;
this.advice = advice;
}
}
}
@@ -0,0 +1,91 @@
package com.yunhai.flowcenter.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
/**
* 代发起的请求标识与轮次守卫(v2 §8,TASK_2 C.3)。
*
* <p><b>为什么是独立 DTO 而不是往 {@code OapiModels.ApplyBody} 加字段</b>(任务书原文要求):
* 旧 ApplyBody 是多个消费应用共用的线上契约,往里塞"重投标识/轮次"会让所有消费方
* 都要重新理解一遍字段语义;更重要的是,请求标识的分支行为<b>只对新端点成立</b>——
* 旧端点保持逐字不变,新端点显式声明"我带守卫"。两侧分支写清楚,比共用一个 DTO 加 if 诚实。</p>
*
* <p>继承旧 body 是为了让 {@code /oapi/flow/apply/v2} 的接收形状与旧端点完全一致
* (消费方只多带三个字段),不是为了共享可变字段。</p>
*/
public final class FlowApplyRetryModels {
private FlowApplyRetryModels() {
}
/** v2 请求体:旧 apply 全字段 + 请求标识/轮次/仅补发开关 */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ApplyRequestV2 extends OapiModels.ApplyBody {
/**
* 本次命令的请求标识(消费方持久化后携带,重投同值)。
* 与行上已落的标识相同 ⇒ 同一命令的重投 ⇒ <b>只回读现状,绝不重启推进</b>。
*/
private String requestToken;
/** 发起轮次(1 起,单调);低于行上 applyRound 的是陈旧轮次迟到重投 */
private Integer attempt;
/**
* true=只补发"确实还没有办件"的情况:命中既有实例一律回读现状,
* 即使处于 30/40 也<b>不</b>触发 R4 重启。恢复链在"查无才补发"之分支上用它,
* 这样"网络重投"与"用户授权重提"在中心侧也是两个不同的事实。
*/
private Boolean retryOnly;
/**
* 期望的前一轮次(T04 服务端轮次,可选):显式重提必须声明基于第 N 轮;
* 服务端在坐标锁内核对并只分配 N+1,不信客户端 attempt。null=既有语义。
*/
private Integer expectedPrevRound;
}
/** v2 响应:在旧 {instanceId,duplicated} 之上把"中心判出来的是哪一种命中"说清楚 */
@Data
public static class ApplyResultV2 {
private Long instanceId;
private boolean duplicated;
/**
* 稳定分支码(消费方按码处置,不靠文案):
* <ul>
* <li>{@code CREATED}:新建办件并推进;</li>
* <li>{@code IDEMPOTENT}:在途/已完成(10/20)命中,纯幂等返回;</li>
* <li>{@code SAME_REQUEST}:同 requestToken 的重投命中,<b>未重启</b>;</li>
* <li>{@code STALE_ROUND}:请求轮次陈旧,未重启;</li>
* <li>{@code REPLAY_ONLY}:retryOnly 命中既有实例(含 30/40),未重启;</li>
* <li>{@code RESTARTED}:新一轮授权重提,R4 重启推进。</li>
* </ul>
*/
private String branch;
/** 行上的当前轮次(消费方据此校正自己的命令表认知) */
private Integer applyRound;
/** 同一响应的真实状态/实例版本;分支判据保持不变。 */
private Integer status;
private Integer version;
}
/** 分支码常量(与 {@link ApplyResultV2#branch} 同值域) */
public static final class Branch {
public static final String CREATED = "CREATED";
public static final String IDEMPOTENT = "IDEMPOTENT";
public static final String SAME_REQUEST = "SAME_REQUEST";
public static final String STALE_ROUND = "STALE_ROUND";
public static final String REPLAY_ONLY = "REPLAY_ONLY";
public static final String RESTARTED = "RESTARTED";
public static final String CONCURRENT_CREATED = "CONCURRENT_CREATED";
/**
* 冲突(2026-10-25 T04):同 token 异载荷、或带 token 撞上他人/其他轮次的在途与通过件——
* 调用方(OapiFlowService)把该分支翻译成业务异常,不伪装幂等成功。
*/
public static final String CONFLICT = "CONFLICT";
private Branch() {
}
}
}
@@ -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;
@@ -0,0 +1,34 @@
package com.yunhai.flowcenter.model;
import lombok.Getter;
import lombok.Setter;
/** Service-owned v3 contract. No shared SDK DTO changes. */
public final class FormalApplyModels {
private FormalApplyModels() { }
@Getter @Setter
public static class Request extends OapiModels.ApplyBody {
private String requestId;
private Long expectedPreviousInstanceId;
private Integer expectedPreviousRound;
}
@Getter @Setter
public static class Receipt {
private String state;
private String requestId;
private Long schoolId;
private String sourcePrincipalId;
private String canonicalHash;
private Integer canonicalVersion = 1;
private String bizType;
private String bizId;
private String flowGuid;
private Long instanceId;
private Integer round;
private Integer defVersion;
private Integer status;
private Integer version;
private String rejectCode;
private String rejectDetail;
}
}
@@ -3,6 +3,7 @@ package com.yunhai.flowcenter.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -40,6 +41,59 @@ public final class OapiModels {
private String submitterName;
/** 表单数据对象(业务方编辑页产出,无表单模式不传) */
private Map<String, Object> formData;
/** 发起时指派(可选,2026-10-13):[{nodeId, userIds}],校验失败业务码拒绝发起 */
private List<AssignItem> assigns;
/**
* 期望的定义版本(可选,2026-10-17 S2.7 版本竞态):调用方拿 apply/preview 时回带的
* {@code defVersion} 原样传回。不匹配即业务码拒绝,报「流程已更新,请刷新后重新选择审批人」,
* 不再让选人结果落到改版后的线上而报「指派节点不存在」。不传=现状行为逐字不变(存量零影响)。
*/
private Integer expectedDefVersion;
}
/** 发起时指派项(apply 可选字段) */
@Data
public static class AssignItem {
private String nodeId;
private List<Integer> userIds;
}
/** 发起预览响应(GET /oapi/flow/apply/preview):只含开启指派的节点 */
@Data
public static class ApplyPreviewVo {
private String flowGuid;
/**
* 本次预览依据的已发布定义版本(2026-10-17 S2.7):调用方在 apply 时以
* {@code expectedDefVersion} 原样回带,改版即人类可读拒绝而非「指派节点不存在」。
*/
private Integer defVersion;
private List<PreviewNodeVo> nodes;
}
@Data
public static class PreviewNodeVo {
private String nodeId;
private String nodeName;
/** ANY|ALL(后端缺省补 ALL) */
private String countersign;
private PreviewSelectionVo selection;
private List<PreviewUserVo> candidates;
}
@Data
public static class PreviewSelectionVo {
/** SINGLE|MULTI */
private String mode;
private Integer min;
private Integer max;
/** 仅子应用 UI 必选提示;引擎不消费 */
private Boolean required;
}
@Data
public static class PreviewUserVo {
private Integer userId;
private String userName;
}
/** 代发起响应(biz uk 幂等:重复代发起 HTTP 200 + duplicated:true 返回既有办件) */
@@ -53,6 +107,13 @@ public final class OapiModels {
@Data
public static class InstanceVo {
private Long instanceId;
private Integer applyRound;
private String requestToken;
/**
* 来源应用快照(2026-10-25 T04):UNKNOWN 恢复链按"稳定来源+requestToken"核验命令归属的
* 依据——查得实例但 applyAppKey 与本应用不符=他人办件,不得认领(P0-4 关闭点)。
*/
private String applyAppKey;
private Integer status;
private Integer version;
private String nodeSummary;
@@ -88,6 +149,22 @@ public final class OapiModels {
private String secret;
}
/**
* 判断服务登记请求体(POST /oapi/flow/router/reg,2026-10-12 SERVICE 收口):
* 由 yh-flow-callback-spring-boot-starter 启动时自动提交,字段与 SDK
* {@code com.yunhai.platform.openapi.flow.model.FlowRouterRegBody} 逐字对齐。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class RouterRegBody {
private String routerKey;
private String name;
/** 判断端点路径(如 /oapi/flow/route/judge) */
private String judgePath;
/** 直连基址 scheme://authority[/ctx] */
private String directHost;
}
/**
* 办件进度(结构化轨迹,2026-09-16 用户拍板:SDK 对外提供图3/图4时间线数据源)。
* steps 按 nodeId 任务到达顺序聚合(同人多任务行保留原始顺序),发起申请固定为第 0 步。
@@ -197,4 +274,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;
}
}
@@ -20,6 +20,9 @@ import java.util.Collections;
* - 任务被办理:todo -1(办理人)
* - 完成/拒绝/撤回:msgType=1、bizType=flow.result、content 必填(结果+意见)、msgNo 带 version
* (重提重启多轮终态互不幂等吞掉)
* - 退回:bizType=flow.returned、msgNo 带 version,通知被退回节点的新待办人
* 结果/退回与待办/抄送同跳转策略(2026-10-17 F8 缺口①):bizUrl 优先 + jumpApp=applyAppCode,
* 空回落中心页且归属留空——四类消息的页面归属口径从此一致。
* - 催办:bizType=flow.urge、msgNo=urge:{taskId}:{第N次}
* 全部在 AFTER_COMMIT 监听器里发送,失败不阻断(UserMsgClient 内部容错)。
*/
@@ -77,9 +80,8 @@ public class UserMsgNotifier {
// 按_by-key 换得的 app_code,接收方按它查基址拼绝对地址);applyAppCode 空(老数据/
// UserBase 不可达)或未传 bizUrl 回落流程中心页(/task/{taskId}、/instance/{id}),
// 页面属自己(jumpApp 留空按发送方回落)。bizType 是消息分类,不是路由键,不再冒充。
boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
String jumpPath = useBizUrl ? instance.getBizUrl() : "/task/" + task.getId();
String jumpApp = useBizUrl ? instance.getApplyAppCode() : null;
String jumpPath = jumpPath(instance, "/task/" + task.getId());
String jumpApp = jumpApp(instance);
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
Collections.singletonList(task.getAssigneeId()), 2,
"flow.task", String.valueOf(instance.getId()), title, content,
@@ -124,7 +126,8 @@ public class UserMsgNotifier {
userMsgClient.sendUserMsg(instance.getSchId().intValue(), targets, 1,
"flow.returned", String.valueOf(instance.getId()),
"办件被退回:" + safe(instance.getBizTitle()), content.toString(),
"/instance/" + instance.getId(), null,
// F8 缺口①:退回消息与待办/抄送同策略——业务方详情页优先、归属申报给 apply 调用方
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance),
"returned:" + instance.getId() + ":" + instance.getVersion(), 30);
}
@@ -147,14 +150,11 @@ public class UserMsgNotifier {
? "" : "(" + nodeName + ")");
// 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=apply 调用方 applyAppCode)优先,
// 空回落办件详情(属自己)
boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
String jumpPath = useBizUrl ? instance.getBizUrl() : "/instance/" + instance.getId();
String jumpApp = useBizUrl ? instance.getApplyAppCode() : null;
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
Collections.singletonList((Integer) ccUserId), 1,
"flow.cc", String.valueOf(instance.getId()),
"您有新的抄送:" + bizTitle, content,
jumpPath, jumpApp, "cc:" + ccId, 30);
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance), "cc:" + ccId, 30);
}
private void notifyResult(FlowEvent event) {
@@ -188,7 +188,9 @@ public class UserMsgNotifier {
Collections.singletonList(instance.getSubmitterId()), 1,
"flow.result", String.valueOf(instance.getId()),
"办件状态更新:" + bizTitle, content.toString(),
"/instance/" + instance.getId(), null,
// F8 缺口①:结果消息与待办/抄送同策略——业务方详情页优先(子应用详情页 iframe
// 显终态进度),jumpApp 申报给 apply 调用方;bizUrl 空仍回落中心办件详情
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance),
"result:" + instance.getId() + ":" + instance.getVersion(), 30);
}
@@ -209,6 +211,23 @@ public class UserMsgNotifier {
"/task/" + task.getId(), null, "urge:" + task.getId() + ":" + count, 7);
}
/**
* 四类消息(待办/抄送/结果/退回)共用的跳转路径策略:办件 bizUrl 优先,空/空白回落中心页。
* bizUrl 自 2026-09-26 起是裸相对路径(不含域名/ctx/#),接收方按 jumpApp 查基址拼绝对地址。
*/
private static String jumpPath(FlwInstance instance, String centerFallback) {
return hasBizUrl(instance) ? instance.getBizUrl() : centerFallback;
}
/** 页面归属:走 bizUrl 时申报给 apply 调用方(applyAppCode),回落中心页时留空按发送方归属 */
private static String jumpApp(FlwInstance instance) {
return hasBizUrl(instance) ? instance.getApplyAppCode() : null;
}
private static boolean hasBizUrl(FlwInstance instance) {
return instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
}
private FlwInstance instanceOf(Long instanceId) {
return instanceId == null ? null : instanceRepository.findById(instanceId).orElse(null);
}
@@ -0,0 +1,35 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwAdvanceIntent;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.jpa.repository.Lock;
import javax.persistence.LockModeType;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface FlwAdvanceIntentRepository extends JpaRepository<FlwAdvanceIntent, Long> {
/** 到点待执行:PENDING 且(无退避或已到期) */
@Query(value="select * from flw_advance_intent where status=:status "
+ "and (next_retry_at is null or next_retry_at<=:now) order by create_time,id limit 50", nativeQuery=true)
List<FlwAdvanceIntent> findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
@Param("status") String status, @Param("now") LocalDateTime now);
List<FlwAdvanceIntent> findByInstanceIdAndNodeIdOrderByIdDesc(Long instanceId, String nodeId);
Optional<FlwAdvanceIntent> findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(
Long instanceId, String nodeId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select i from FlwAdvanceIntent i where i.id=:id")
Optional<FlwAdvanceIntent> findByIdForUpdate(@Param("id") Long id);
/** 幂等记账:同实例同节点是否已有未完成意图(办理事务内去重,避免并发办理堆积重复行) */
@Query("select count(i) > 0 from FlwAdvanceIntent i where i.instanceId = :instanceId "
+ "and i.nodeId = :nodeId and i.status = 'PENDING'")
boolean existsPending(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
}
@@ -0,0 +1,8 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwApplyCommand;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FlwApplyCommandRepository extends JpaRepository<FlwApplyCommand,Long> {
boolean existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(Long school, String source, String flow, String state);
Optional<FlwApplyCommand> findBySchIdAndSourcePrincipalIdAndRequestId(Long schId,String sourcePrincipalId,String requestId);
}
@@ -0,0 +1,14 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwBizCoordinate;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import javax.persistence.LockModeType;
import java.util.Optional;
public interface FlwBizCoordinateRepository extends JpaRepository<FlwBizCoordinate,Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from FlwBizCoordinate c where c.schId=:sch and c.sourcePrincipalId=:source and c.bizType=:type and c.bizId=:biz")
Optional<FlwBizCoordinate> lock(@Param("sch") Long sch,@Param("source") String source,@Param("type") String type,@Param("biz") String biz);
@Modifying
@Query("update FlwBizCoordinate c set c.activeInstanceId=null where c.schId=:sch and c.sourcePrincipalId=:source and c.bizType=:type and c.bizId=:biz and c.activeInstanceId=:instance and c.lastRound=:round")
int release(@Param("sch") Long sch,@Param("source") String source,@Param("type") String type,@Param("biz") String biz,@Param("instance") Long instance,@Param("round") Integer round);
}
@@ -13,6 +13,9 @@ import java.util.List;
public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog, Long> {
/** T05 投递幂等:同一事件事实对同一订阅方只建一行(补分发与正常分发竞争时去重) */
boolean existsByEventIdAndRegId(String eventId, Long regId);
/** 重推扫描主路径:待推/重试中且到点 */
List<FlwCallbackLog> findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc(
Integer status, LocalDateTime now);
@@ -35,4 +38,53 @@ public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog,
Page<FlwCallbackLog> pageHung(@Param("schId") Long schId, @Param("minRetry") int minRetry, Pageable pageable);
List<FlwCallbackLog> findByIdInAndSchId(Collection<Long> ids, Long schId);
// ==================== 健康度体检聚合(2026-10-15) ====================
// 全部为只读统计,不新增表;口径见 FlowCallbackAdminService#health。
/** 近 7 日推送成败计数:按 状态(FULLY_QUALIFIED) 返回 [status, count] */
@Query("select l.status, count(l) from FlwCallbackLog l "
+ "where l.schId = :schId and l.pushTime >= :since "
+ "group by l.status")
List<Object[]> countByStatusSince(@Param("schId") Long schId, @Param("since") LocalDateTime since);
/** 死信数(status=FULLY_QUALIFIED 由调用方传入) */
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.status = :status")
long countByStatus(@Param("schId") Long schId, @Param("status") Integer status);
/**
* 挂起数:待推且重试次数达阈值。
* 与 pageHung 同口径(管理端"挂起"列表的计数版)。
*/
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.status = 0 "
+ "and l.retryCount >= :minRetry")
long countHung(@Param("schId") Long schId, @Param("minRetry") int minRetry);
/** 某应用的最近一次推送时间(判活) */
@Query("select max(l.pushTime) from FlwCallbackLog l where l.schId = :schId and l.status = 1")
LocalDateTime lastSuccessTime(@Param("schId") Long schId);
// -------- 按应用 appKey 维度过滤(健康度面板每应用一行,用 regId 归属关联) --------
/** 某注册集合近 7 日成败计数 */
@Query("select l.status, count(l) from FlwCallbackLog l "
+ "where l.schId = :schId and l.regId in :regIds and l.pushTime >= :since "
+ "group by l.status")
List<Object[]> countByStatusSinceForRegs(@Param("schId") Long schId,
@Param("regIds") Collection<Long> regIds,
@Param("since") LocalDateTime since);
/** 某注册集合的死信数 */
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId "
+ "and l.regId in :regIds and l.status = :status")
long countByStatusForRegs(@Param("schId") Long schId,
@Param("regIds") Collection<Long> regIds,
@Param("status") Integer status);
/** 某注册集合的挂起数 */
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.regId in :regIds "
+ "and l.status = 0 and l.retryCount >= :minRetry")
long countHungForRegs(@Param("schId") Long schId,
@Param("regIds") Collection<Long> regIds,
@Param("minRetry") int minRetry);
}
@@ -14,10 +14,13 @@ public interface FlwCcRepository extends JpaRepository<FlwCc, Long> {
/** 抄送我的(用户面页签,走 user+sent_time 索引;appKey 可空=不按应用过滤,口径=定义归属 flw_def.app_key) */
@Query("select c from FlwCc c join FlwInstance i on i.id = c.instanceId join FlwDef d on d.id = i.defId "
+ "where c.schId = :schId and c.userId = :userId "
+ "and (:appKey is null or d.appKey = :appKey) order by c.sentTime desc")
+ "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) order by c.sentTime desc")
Page<FlwCc> pageCc(@Param("schId") Long schId, @Param("userId") Integer userId,
@Param("appKey") String appKey, Pageable pageable);
/** 办件轨迹/进度留痕 */
List<FlwCc> findByInstanceIdOrderByIdAsc(Long instanceId);
/** 可见性守卫用(F7 前置补丁):本办件是否抄送过该人——只读可见,不授予办理权 */
boolean existsByInstanceIdAndUserId(Long instanceId, Integer userId);
}
@@ -0,0 +1,10 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwCommandSource;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import javax.persistence.LockModeType;
import java.util.Optional;
public interface FlwCommandSourceRepository extends JpaRepository<FlwCommandSource,String> {
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select s from FlwCommandSource s where s.id=:id")
Optional<FlwCommandSource> lock(@Param("id") String id);
}
@@ -32,12 +32,22 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
List<FlwDef> findBySchIdOrderByUpdateTimeDesc(Long schId);
/** 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本 */
@Query("select d from FlwDef d where d.schId = :schId and d.status = 1 "
/**
* 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本。
* 2026-10-25 T03:外层追加 {@code lineOffline=0}——线级停发的整线不出现在新发起预览源,
* 且不存在"停发后回退旧版"的形态(内层仍是该线已发布最高版本,停发不改变版本事实)。
*/
@Query("select d from FlwDef d where d.schId = :schId and d.status = 1 and d.lineOffline = 0 "
+ "and d.version = (select max(d2.version) from FlwDef d2 where d2.schId = d.schId "
+ "and d2.flowGuid = d.flowGuid and d2.status = 1) order by d.updateTime desc")
List<FlwDef> findPublishedLatestPerGuid(@Param("schId") Long schId);
/** 线级停发/恢复(T03):批量 UPDATE 全版本行同值;返回受影响行数 */
@org.springframework.data.jpa.repository.Modifying
@Query("update FlwDef d set d.lineOffline = :offline where d.schId = :schId and d.flowGuid = :flowGuid")
int updateLineOffline(@Param("schId") Long schId, @Param("flowGuid") String flowGuid,
@Param("offline") Integer offline);
/** 在途统计(议题二④):一次取回多条流程线的全部版本行 */
List<FlwDef> findBySchIdAndFlowGuidIn(Long schId, Collection<String> flowGuids);
}
@@ -12,8 +12,38 @@ import java.util.List;
import java.util.Optional;
public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long> {
@org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE)
@Query("select i from FlwInstance i where i.id=:id")
Optional<FlwInstance> findFormalPreviousForUpdate(@Param("id") Long id);
/** biz uk 幂等主键 */
/**
* T05 事件序号发号(2026-10-25 FlowEngineIT 会签并发实证三轮修正,终形态):
* ①FOR UPDATE 标量当前读发号(取行 X 锁,任何隔离级别都看到已提交最新值)→
* ②按读到的值回写计数器(值已在我锁内计算,无竞态)→③事实行按该值落库。
* 纯快照读 max+1 会撞 uk;实体 merge 会触发 @Version 冲突;自增 UPDATE 后普通 SELECT
* 的读回在并发窗口同样不可靠——FOR UPDATE 标量读是唯一两全形态。
*/
@org.springframework.data.jpa.repository.Query(
value = "select coalesce(event_seq_counter, 0) + 1 from flw_instance where id = :id for update",
nativeQuery = true)
Integer nextEventSeqForUpdate(@Param("id") Long id);
@org.springframework.data.jpa.repository.Modifying
@org.springframework.data.jpa.repository.Query(
value = "update flw_instance set event_seq_counter = :v where id = :id", nativeQuery = true)
int setEventSeqCounter(@Param("id") Long id, @Param("v") Integer v);
Optional<FlwInstance> findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(Long school, String source, String type, String biz);
@Query("select i from FlwInstance i where i.schId=:school and i.sourcePrincipalId=:source and (:bizType is null or i.bizType=:bizType) and (:status is null or i.status=:status) order by i.id desc")
Page<FlwInstance> pageSelf(@Param("school") Long school,@Param("source") String source,@Param("bizType") String bizType,@Param("status") Integer status, Pageable pageable);
Optional<FlwInstance> findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
Long school, String source, String bizType, String bizId, Integer slot);
/** Unknown-source rows are never used to recover a caller's instance. */
@Query("select i from FlwInstance i where i.schId=?1 and i.bizType=?2 and i.bizId=?3 and i.sourcePrincipalId is null")
Optional<FlwInstance> findBySchIdAndBizTypeAndBizId(Long schId, String bizType, String bizId);
List<FlwInstance> findBySchIdAndSubmitterIdAndStatusOrderByIdDesc(Long schId, Integer submitterId, Integer status);
@@ -27,12 +57,23 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
@Param("status") Integer status, @Param("appKey") String appKey,
Pageable pageable);
/** 管理端办件分页(状态/关键字可空) */
/**
* 管理端办件分页(状态/关键字可空)。
* hung=true 只看「路由挂起」:审批中(status=10)且存在某 (instance,node) 的<b>最大 attempt</b>
* flw_route_log 行 outlet 仍为 NULL(与 {@link com.yunhai.flowcenter.repository.FlwRouteLogRepository#findHungInstanceIds}
* 同口径——历史低 attempt 失败行不参与判定,防「重跑已成功仍报挂起」误报);hung=null/false 不过滤。
*/
@Query("select i from FlwInstance i where i.schId = :schId "
+ "and (:status is null or i.status = :status) "
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) order by i.id desc")
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) "
+ "and (:hung is null or :hung = false or (i.status = 10 and exists ("
+ "select r.id from FlwRouteLog r where r.instanceId = i.id and r.outlet is null "
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)))) "
+ "order by i.id desc")
Page<FlwInstance> pageAdmin(@Param("schId") Long schId, @Param("status") Integer status,
@Param("keyword") String keyword, Pageable pageable);
@Param("keyword") String keyword, @Param("hung") Boolean hung,
Pageable pageable);
/** 按业务类型/状态分页(oapi instances 查询;bizType/status 均可空=不过滤) */
@Query("select i from FlwInstance i where i.schId = :schId "
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long> {
@@ -16,4 +17,16 @@ public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long>
@Query("select coalesce(max(l.attempt), 0) from FlwRouteLog l "
+ "where l.instanceId = :instanceId and l.nodeId = :nodeId")
int maxAttempt(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
/**
* 台账「路由挂起」红标数据源(F11-4):页内办件中仍挂起的 instanceId 集合。
* 判定口径:某 (instance, node) 的<b>最大 attempt</b> 行 outlet 仍为 NULL 才算挂着——
* 退回重走/管理端重跑会写更高 attempt 的新行,历史低 attempt 的失败行(outlet NULL)不参与判定,
* 防「已重跑成功仍报挂起」的误报。
*/
@Query("select distinct r.instanceId from FlwRouteLog r where r.instanceId in :instanceIds "
+ "and r.outlet is null "
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)")
List<Long> findHungInstanceIds(@Param("instanceIds") Collection<Long> instanceIds);
}
@@ -0,0 +1,24 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwStateEvent;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface FlwStateEventRepository extends JpaRepository<FlwStateEvent, Long> {
/** 实例内单调序号取号:调用方持有该实例的状态行锁(同事务状态变更),max+1 串行安全 */
@Query("select coalesce(max(e.eventSeq), 0) from FlwStateEvent e where e.instanceId = :instanceId")
int maxSeqByInstance(@Param("instanceId") Long instanceId);
/** 崩溃窗口恢复扫描:未分发且已过缓冲期(给 AFTER_COMMIT 首分发留时间) */
List<FlwStateEvent> findTop50ByDispatchedAndCreateTimeLessThanEqualOrderByCreateTimeAsc(
Integer dispatched, LocalDateTime deadline);
java.util.Optional<FlwStateEvent> findByEventId(String eventId);
boolean existsByEventId(String eventId);
}
@@ -13,6 +13,12 @@ import java.util.List;
public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
/** Additive D09 query: the real transfer parent is in this set. Ambiguous generations must be rejected. */
@Query(value="select t.id from flw_task t join flw_task n on n.id=:newTaskId "
+ "where t.instance_id=n.instance_id and t.node_id=n.node_id and t.status=5 "
+ "and t.assignee_id=:operatorId and t.handle_time=n.arrive_time", nativeQuery=true)
List<Long> findTransferParentCandidates(@Param("newTaskId") Long newTaskId, @Param("operatorId") Integer operatorId);
List<FlwTask> findByInstanceIdOrderByIdAsc(Long instanceId);
List<FlwTask> findByInstanceIdAndStatus(Long instanceId, Integer status);
@@ -52,10 +58,13 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId")
int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId);
@Query("select t from FlwTask t join FlwInstance i on i.id=t.instanceId where t.schId=:school and i.sourcePrincipalId=:source and t.assigneeId=:user and t.status=0 and (:type is null or i.bizType=:type) order by t.id asc")
Page<FlwTask> pageSelfTodo(@Param("school") Long school,@Param("source") String source,@Param("user") Integer user,@Param("type") String type, Pageable pageable);
/** 我的待办(主路径,走 assignee 索引;appKey 可空=不按应用过滤,口径=办件定义归属应用 flw_def.app_key) */
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
+ "where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 "
+ "and (:appKey is null or d.appKey = :appKey) "
+ "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) "
+ "order by t.arriveTime asc, t.id asc")
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
@Param("appKey") String appKey, Pageable pageable);
@@ -84,7 +93,7 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
+ "where t.schId = :schId and t.assigneeId = :assigneeId "
+ "and t.status in :statuses "
+ "and (:appKey is null or d.appKey = :appKey) "
+ "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) "
+ "and t.id = (select max(t2.id) from FlwTask t2 where t2.instanceId = t.instanceId "
+ " and t2.assigneeId = :assigneeId and t2.status in :statuses) "
+ "order by t.handleTime desc, t.id desc")
@@ -0,0 +1,18 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwUsageGrant;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface FlwUsageGrantRepository extends JpaRepository<FlwUsageGrant, Long> {
boolean existsBySchIdAndFlowGuidAndSourcePrincipalIdAndEnabled(Long school, String flow, String source, Integer enabled);
Optional<FlwUsageGrant> findBySchIdAndFlowGuidAndAppId(Long schId, String flowGuid, Integer appId);
List<FlwUsageGrant> findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(Long schId, String flowGuid);
List<FlwUsageGrant> findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(Long schId, Integer appId);
}
@@ -0,0 +1,49 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Set;
/** 发起前按冻结的定义契约校验;历史无声明版本保留原有的引用变量存在性规则。 */
@Slf4j
@Component
@RequiredArgsConstructor
public class ApplyVariableValidator {
private final ObjectMapper objectMapper;
/** def 的全部 EXPRESSION 出边引用的业务变量键(去重保序) */
public Set<String> referencedBusinessKeys(FlowDefModel model) {
return com.yunhai.flowcenter.engine.VariableContract.referencedKeys(model);
}
public void requireVariablesPresent(FlowDefModel model, String formDataJson) {
com.yunhai.flowcenter.engine.VariableContract.validateInput(model, parse(formDataJson, model.variables != null));
}
/** 与 ConditionRouteService.parseVariables 同源:formData 顶层即变量;非对象 JSON=空(后续逐键报缺失) */
private Map<String, Object> parse(String formDataJson, boolean strict) {
if (formDataJson == null || formDataJson.trim().isEmpty()) {
return new java.util.HashMap<>();
}
try {
Map<String, Object> values = objectMapper.readValue(formDataJson, new TypeReference<Map<String, Object>>() { });
if (values == null) {
if (strict) throw new IllegalArgumentException("null object");
return new java.util.HashMap<>();
}
return values;
} catch (Exception e) {
if (strict) throw new BizException(ErrorCode.PARAM_ERROR, "formData 必须为 JSON 对象");
return new java.util.HashMap<>();
}
}
}
@@ -0,0 +1,120 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
* 办件 biz_url 绝对化(办件详情/办理页"查看业务详情")。
*
* <p>bizUrl 自 2026-09-26 起是**裸相对路径**(不含域名/ctx/#,见 UserMsgNotifier 跳转策略):落地基址由
* 接收方按"页面归属"解析。消息链路(UserHome 消息中心)已按 jumpApp→application_info.pc_url 拼;
* 流程中心自己是另一类接收方——用户在流内直接点"查看业务详情"时没有宿主页面可借基址,必须自己解析,
* 否则裸路径会落到 flow-center 自己的 origin 上(实测 /list/detail?serid=78 404)。
*
* <p>解析口径与消息中心逐字一致({@code MsgCenterProxyService#jumpUrl}):绝对地址直通 → 否则
* 按办件快照的 applyAppCode 查应用注册表 pc_url(老数据没有 app_code 时用 applyAppKey 兜底)
* → 丢 fragment、去尾斜杠 → {@code base + "#" + path}。查不到 pc_url 返回 null(前端据此不渲染入口),**不猜**。
*
* <p>缓存:pc_url 属低频变更的注册表数据,按应用标识缓存 5 分钟;查无缓存 60 秒(防脏值刷重复出站)。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class BizUrlResolver {
private static final long HIT_TTL_MS = 5 * 60_000L;
private static final long MISS_TTL_MS = 60_000L;
private final OpenApiAppClient appClient;
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
/**
* @param appCode 页面归属应用(办件 apply_app_code,如 notice)
* @param appKey 验签身份 app_key(老数据 app_code 为空时的兜底)
* @param bizUrl 办件快照里的 biz_url(裸相对路径,或历史存量绝对地址)
* @return 绝对地址;绝对地址原样返回,无法解析返回 null
*/
public String resolve(String appCode, String appKey, String bizUrl) {
if (bizUrl == null || bizUrl.trim().isEmpty()) {
return null;
}
String url = bizUrl.trim();
if (isAbsolute(url)) {
return url;
}
String pcUrl = null;
if (hasText(appCode)) {
pcUrl = pcUrl("code:" + appCode.trim(), appClient::getAppByCode);
}
if (pcUrl == null && hasText(appKey)) {
pcUrl = pcUrl("key:" + appKey.trim(), appClient::getAppByKey);
}
if (pcUrl == null) {
return null;
}
StringBuilder base = new StringBuilder(pcUrl);
int hash = base.indexOf("#");
if (hash >= 0) {
base.setLength(hash);
}
while (base.length() > 0 && base.charAt(base.length() - 1) == '/') {
base.setLength(base.length() - 1);
}
if (base.length() == 0) {
return null;
}
return base + "#" + (url.startsWith("/") ? url : "/" + url);
}
private static boolean isAbsolute(String url) {
return url.startsWith("http://") || url.startsWith("https://");
}
private static boolean hasText(String v) {
return v != null && !v.trim().isEmpty();
}
private String pcUrl(String cacheKey, Function<String, JsonNode> fetcher) {
long now = System.currentTimeMillis();
CacheEntry cached = cache.get(cacheKey);
if (cached != null && cached.expireAt > now) {
return cached.pcUrl;
}
String pcUrl = null;
try {
JsonNode app = fetcher.apply(cacheKey.substring(cacheKey.indexOf(':') + 1));
if (app != null && app.hasNonNull("pcUrl")) {
String v = app.get("pcUrl").asText(null);
if (hasText(v)) {
pcUrl = v.trim();
}
}
if (pcUrl == null) {
log.warn("[flow-center] biz_url 归属应用无 pc_url,跳转入口隐藏:{}", cacheKey);
}
} catch (Exception e) {
// 不可达按 miss 缓存,避免办件详情页每请求打一次 UserBase
log.warn("[flow-center] biz_url 归属应用查址失败(UserBase 不可达?){}:{}", cacheKey, e.toString());
}
cache.put(cacheKey, new CacheEntry(pcUrl, now + (pcUrl == null ? MISS_TTL_MS : HIT_TTL_MS)));
return pcUrl;
}
private static final class CacheEntry {
private final String pcUrl;
private final long expireAt;
private CacheEntry(String pcUrl, long expireAt) {
this.pcUrl = pcUrl;
this.expireAt = expireAt;
}
}
}
@@ -1,6 +1,7 @@
package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.repository.FlwCcRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
@@ -14,6 +15,8 @@ import java.util.List;
/**
* 用户面访问守卫(概览 §十):提交人只读写自己办件;办理人只读写分派给自己的任务;
* 管理员本校可查、不可代批。
* 2026-10-17(F7 前置补丁):放行口径增「该办件 flw_cc 行接收人」——抄送接收人点开工作台
* "抄送我的"行或抄送消息回落链接此前一律 403,抄送自留因此不闭环。只读可见,办理权仍按 assigneeId。
* 管理员口径与 FlowAdminGuard / svc-hall ApplyViewGuard / Notice NoticeFlowGuard 对齐:
* type==1(超管)或 roleId∈{2,5,7}(校级管理员)——三者必须同口径,否则校级管理员
* 能列办件却在详情页 403(本类原先只认 type==1,2026-09-14 校正)。
@@ -26,8 +29,9 @@ public class FlowAccessGuard {
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7);
private final FlwTaskRepository taskRepository;
private final FlwCcRepository ccRepository;
/** 办件详情可见:提交人本人 / 该办件任务办理人 / 本校管理员(超管或校级管理员) */
/** 办件详情可见:提交人本人 / 该办件任务办理人 / 本校管理员(超管或校级管理员)/ 该办件抄送接收人 */
public void requireInstanceVisible(FlwInstance instance, BoaUser user) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
@@ -37,12 +41,19 @@ public class FlowAccessGuard {
}
if (!taskRepository.findByInstanceIdOrderByIdAsc(instance.getId()).stream()
.anyMatch(t -> user.getId().equals(t.getAssigneeId()))) {
if (!isAdminOf(instance, user)) {
// F7 前置补丁:抄送接收人可读(工作台"抄送我的"行与抄送消息回落链接不再 403)。
// 只授予可见,办理权仍在 FlowTaskService 按 assigneeId 复核。
if (!isCcRecipient(instance, user) && !isAdminOf(instance, user)) {
throw new BizException(ErrorCode.FORBIDDEN, "仅提交人/办理人/本校管理员可查看该办件");
}
}
}
/** 该用户是否本办件的抄送接收人(flw_cc 行,逐人一行) */
private boolean isCcRecipient(FlwInstance instance, BoaUser user) {
return ccRepository.existsByInstanceIdAndUserId(instance.getId(), user.getId());
}
/**
* 本校管理员(可查不可代批;办理权在 FlowTaskService 里再按 assigneeId 校验)。
* 超管与校级管理员均限本校——跨校仍 403,不放大可见范围。
@@ -0,0 +1,36 @@
package com.yunhai.flowcenter.service;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* SDK 快捷审批 ActorContext 门禁(2026-10-25 T07 建立;同日 R1 返修改为无条件 fail-closed)。
*
* <p>oapi 通道的 approve/reject 只有<b>自报 operatorId/operatorName</b>——appKey 验签证明的是
* 应用,不是用户;"task.assigneeId == operatorId" 的本人比较 likewise 只能证明调用方知道
* 办理人 ID,不能证明实际办理的用户。在"可验证用户证明"(面向 FL 的用户会话票据/受限委托:
* 绑定用户、学校、调用应用、任务/动作范围、期限与防重放标识,见 Codex 终审 D03/C.2)落地前,
* 快捷审批<b>一律不放行,不存在放行配置</b>。</p>
*
* <p>R1 返修(Codex 终审第一轮):原 {@code flow-center.sdk-approve.require-user-proof}
* 开关的 false=存量放行语义已废除——未配置与 false 均不得默认放行不可信审批,此前依赖该
* 通道的存量消费方(work-leave、svc-hall 应用内审批快捷动作等)须迁移到标准 FL iframe/CAS
* 人工办理({@code FlowUserController} → {@code FlowTaskService.handle},会话用户身份,
* 不经本门)。未来 ActorContext 证明落地后,在此校验证明(用户/校/应用/任务/期限/nonce)
* 并按证明放行;届时再恢复配置化灰度。</p>
*/
@Slf4j
@Component
public class FlowActorContextGate {
/** oapi 审批动作前置门(approve/reject 共用):无条件拒绝 */
public void requireActorProof(Long schId, Long taskId, Integer operatorId) {
log.warn("[actor-gate] SDK 快捷审批被拒(无可验证用户证明,fail-closed):schId={} taskId={} operatorId={}",
schId, taskId, operatorId);
throw new BizException(ErrorCode.FORBIDDEN,
"SDK 快捷审批未放行:仅凭 appKey/operatorId 不构成用户授权。"
+ "请通过流程中心人工办理页(CAS)办理");
}
}
@@ -5,6 +5,7 @@ import com.yunhai.flowcenter.callback.CallbackPushService;
import com.yunhai.flowcenter.entity.FlwCallbackLog;
import com.yunhai.flowcenter.entity.FlwCallbackReg;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.AppHealthVo;
import com.yunhai.flowcenter.repository.FlwCallbackLogRepository;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.platform.apistd.BizException;
@@ -32,6 +33,7 @@ public class FlowCallbackAdminService {
private final FlwCallbackLogRepository logRepository;
private final CallbackPushService pushService;
private final CallbackProperties callbackProperties;
private final com.yunhai.flowcenter.callback.CallbackLogStore logStore;
public List<FlwCallbackReg> listRegs(Long schId) {
return regRepository.findBySchIdOrderByUpdateTimeDesc(schId);
@@ -103,7 +105,6 @@ public class FlowCallbackAdminService {
* 管理端手工标记死信(持续重试语义下死信的唯一人工入口:人工判定事件无意义时止血)。
* 标记后可经既有 rePush 复活。
*/
@Transactional
public void markDead(Long schId, Long logId) {
FlwCallbackLog callbackLog = logRepository.findById(logId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "推送日志不存在:" + logId));
@@ -118,7 +119,6 @@ public class FlowCallbackAdminService {
}
/** 手工重推(死信/失败日志):置回待推并立即触发一次推送 */
@Transactional
public void rePush(Long schId, List<Long> logIds) {
if (logIds == null || logIds.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要重推的日志");
@@ -127,21 +127,210 @@ public class FlowCallbackAdminService {
if (logs.size() != logIds.size()) {
throw new BizException(ErrorCode.PARAM_ERROR, "存在不属于该校或不存在 dead/pending 日志");
}
for (FlwCallbackLog callbackLog : logs) {
if (callbackLog.getStatus() == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
// 短事务提交并释放会话锁后才执行 HTTP;不能把管理端旧实体全量回写覆盖刚成功的状态。
for (Long id : logStore.resetForReplay(schId, logIds)) {
pushService.pushAsync(id);
}
}
// ==================== 对接健康度体检(2026-10-15 议题二 P2) ====================
/** 私网/开发机地址特征(登记地址应为网关形态,见《子应用接入流程中心规范》§4.2) */
private static final java.util.regex.Pattern PRIVATE_HOST = java.util.regex.Pattern.compile(
"://(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|"
+ "10\\.[0-9.]+|192\\.168\\.[0-9.]+|172\\.(1[6-9]|2[0-9]|3[01])\\.[0-9.]+)"
+ "(:[0-9]+)?");
/** 挂起判定阈值(与管理端「挂起列表」同口径) */
private static final int HUNG_RETRY_MIN = 3;
/**
* 全量应用健康度体检(每应用一行)。
*
* <p>数据全部来自已有表:{@code flw_callback_reg}(登记地址/base_path)、
* {@code flw_callback_log}(推送成败/死信/挂起)。不新增任何表。
*
* <p>应用清单来自注册表 distinct appKey(未接入回调的应用不参与体检——
* 无注册行本身即"未接入",由向导负责,不是健康度问题)。
*
* @param appNames appKey → 应用名(来自 flw_app;缺失时回落 appKey)
*/
public List<AppHealthVo> health(Long schId, java.util.Map<String, String> appNames) {
List<FlwCallbackReg> allRegs = regRepository.findBySchIdOrderByUpdateTimeDesc(schId);
java.time.LocalDateTime since = java.time.LocalDateTime.now().minusDays(7);
// 按 appKey 归组(同一应用的多个 flowGuid 注册合并体检)
java.util.Map<String, List<FlwCallbackReg>> byApp = new java.util.LinkedHashMap<>();
for (FlwCallbackReg reg : allRegs) {
if (reg.getAppKey() == null || reg.getAppKey().isEmpty()) {
continue;
}
callbackLog.setStatus(FlwEnums.CallbackLogStatus.PENDING.getCode());
callbackLog.setRetryCount(0);
callbackLog.setNextRetryTime(LocalDateTime.now());
logRepository.save(callbackLog);
byApp.computeIfAbsent(reg.getAppKey(), k -> new java.util.ArrayList<>()).add(reg);
}
logRepository.flush();
for (FlwCallbackLog callbackLog : logs) {
if (callbackLog.getStatus() != null) {
pushService.pushAsync(callbackLog.getId());
List<AppHealthVo> out = new java.util.ArrayList<>();
for (java.util.Map.Entry<String, List<FlwCallbackReg>> e : byApp.entrySet()) {
out.add(buildAppHealth(schId, e.getKey(),
appNames == null ? null : appNames.get(e.getKey()), e.getValue(), since));
}
// 问题应用优先(bad → warn → ok),同级按评分升序
out.sort(java.util.Comparator
.comparingInt((AppHealthVo v) -> levelRank(v.getLevel()))
.thenComparingInt(AppHealthVo::getScore));
return out;
}
private static int levelRank(String level) {
if ("bad".equals(level)) {
return 0;
}
if ("warn".equals(level)) {
return 1;
}
return 2;
}
/** 单应用体检:六项检查逐项判定并加权算分 */
private AppHealthVo buildAppHealth(Long schId, String appKey, String appName,
List<FlwCallbackReg> regs, java.time.LocalDateTime since) {
List<Long> regIds = new java.util.ArrayList<>();
for (FlwCallbackReg r : regs) {
regIds.add(r.getId());
}
List<AppHealthVo.CheckItem> checks = new java.util.ArrayList<>();
int score = 100;
// ---- 1) 登记地址形态:任何一条是私网/开发机即 bad ----
String badUrl = null;
for (FlwCallbackReg r : regs) {
if (r.getCallbackUrl() != null && PRIVATE_HOST.matcher(r.getCallbackUrl()).find()) {
badUrl = r.getCallbackUrl();
break;
}
}
if (badUrl != null) {
checks.add(new AppHealthVo.CheckItem("登记地址为网关形态", "bad", badUrl,
"删除本地镜像行 → 管理端重新保存绑定(走 starter 自注册)"));
score -= 40;
} else {
FlwCallbackReg first = regs.get(0);
checks.add(new AppHealthVo.CheckItem("登记地址为网关形态", "ok",
abbreviate(first.getCallbackUrl())));
}
// ---- 2) base_path 与 ctx 一致性:注册里 basePath 为空的条数 ----
long emptyBase = regs.stream().filter(r -> r.getBasePath() == null || r.getBasePath().isEmpty()).count();
if (emptyBase > 0) {
checks.add(new AppHealthVo.CheckItem("base_path 已登记", "warn",
emptyBase + " 条注册未登记 base_path",
"漏填 base_path 会导致推送全 401,症状与「收不到回调」同构"));
score -= 10;
} else {
checks.add(new AppHealthVo.CheckItem("base_path 已登记", "ok",
regs.get(0).getBasePath()));
}
// ---- 3) 近 7 日推送成功率 ----
long success = 0;
long pending = 0;
long dead = 0;
List<Object[]> statusRows = logRepository.countByStatusSinceForRegs(schId, regIds, since);
for (Object[] row : statusRows) {
int st = ((Number) row[0]).intValue();
long cnt = ((Number) row[1]).longValue();
if (st == FlwEnums.CallbackLogStatus.SUCCESS.getCode()) {
success = cnt;
} else if (st == FlwEnums.CallbackLogStatus.DEAD.getCode()) {
dead = cnt;
} else {
pending = cnt;
}
}
long total = success + pending + dead;
if (total == 0) {
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "warn", "近 7 日无推送记录"));
score -= 5;
} else {
double rate = success * 100.0 / total;
String txt = String.format(java.util.Locale.ROOT, "%.1f%%(%d/%d)", rate, success, total);
if (rate >= 99.0) {
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "ok", txt));
} else if (rate > 0) {
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "warn", txt,
"存在推送失败,查推送日志末次结果定位"));
score -= 15;
} else {
checks.add(new AppHealthVo.CheckItem("近 7 日推送成功率", "bad", txt,
"推送全失败:优先核对登记地址与 base_path"));
score -= 30;
}
}
// ---- 4) 挂起 / 死信数 ----
long hung = logRepository.countHungForRegs(schId, regIds, HUNG_RETRY_MIN);
long deadCnt = logRepository.countByStatusForRegs(schId, regIds,
FlwEnums.CallbackLogStatus.DEAD.getCode());
if (deadCnt > 0) {
checks.add(new AppHealthVo.CheckItem("挂起 / 死信数", "bad", hung + " / " + deadCnt,
"死信需管理端确认后重推或标记处理"));
score -= 20;
} else if (hung > 0) {
checks.add(new AppHealthVo.CheckItem("挂起 / 死信数", "warn", hung + " / 0",
"持续重试中,疑似消费方停机"));
score -= 12;
} else {
checks.add(new AppHealthVo.CheckItem("挂起 / 死信数", "ok", "0 / 0"));
}
// ---- 5) 停用注册告警 ----
long disabled = regs.stream().filter(r -> r.getStatus() != null && r.getStatus() != 0).count();
if (disabled > 0) {
checks.add(new AppHealthVo.CheckItem("注册启用状态", "warn",
disabled + " 条注册已停用", "停用后该流程不再收推送,确认是否为预期"));
score -= 8;
} else {
checks.add(new AppHealthVo.CheckItem("注册启用状态", "ok",
regs.size() + " 条全部启用"));
}
AppHealthVo vo = new AppHealthVo();
vo.setAppKey(appKey);
vo.setAppName(appName == null || appName.isEmpty() ? appKey : appName);
vo.setScore(Math.max(0, score));
vo.setChecks(checks);
String level = checks.stream().anyMatch(c -> "bad".equals(c.getLevel())) ? "bad"
: checks.stream().anyMatch(c -> "warn".equals(c.getLevel())) ? "warn" : "ok";
vo.setLevel(level);
vo.setSummary(buildSummary(regs.size(), level, badUrl, deadCnt, hung));
return vo;
}
private static String buildSummary(int regCount, String level, String badUrl,
long dead, long hung) {
StringBuilder sb = new StringBuilder();
sb.append(regCount == 0 ? "无注册" : regCount + " 条注册");
if ("ok".equals(level)) {
sb.append(" · 对接正常");
return sb.toString();
}
if (badUrl != null) {
sb.append(" · 存在私网登记地址,推送必然失败");
}
if (dead > 0) {
sb.append(" · ").append(dead).append(" 条死信");
} else if (hung > 0) {
sb.append(" · ").append(hung).append(" 条挂起重试中");
}
return sb.toString();
}
private static String abbreviate(String url) {
if (url == null) {
return "-";
}
return url.length() <= 56 ? url : "…" + url.substring(url.length() - 55);
}
private FlwCallbackReg requireReg(Long schId, Long regId) {
@@ -3,10 +3,12 @@ package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.engine.DefValidator;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.VariableContract;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.platform.apistd.BizException;
@@ -43,6 +45,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 +115,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 +143,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 +160,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,28 +176,135 @@ 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 零依赖;新发布的无绑定版本必须显式声明 variables。 */
@Transactional
public FlwDef publish(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态");
}
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿");
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
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(), "发布");
model.variables = VariableContract.fromSnapshot(snapshot);
VariableContract.validateDefinition(model, true);
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
def.setContentJson(embedFormContract(def.getContentJson(), snapshot, model.variables));
} else if (def.getFormVersion() != null) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid");
}
if (def.getFormUuid() == null || def.getFormUuid().trim().isEmpty()) {
VariableContract.validateDefinition(model, true);
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
}
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now());
return defRepository.save(def);
}
/** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */
private String embedFormContract(String contentJson, Map<String, Object> snapshot,
List<FlowDefModel.Variable> variables) {
try {
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
objectMapper.readTree(contentJson);
root.set("formContract", objectMapper.valueToTree(snapshot));
root.set("variables", objectMapper.valueToTree(variables));
return objectMapper.writeValueAsString(root);
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "快照嵌入失败:" + e.getMessage());
}
}
/** 停用:不可新发起,存量跑完 */
@Transactional
public void offline(Long schId, Long id) {
FlwDef def = requireDef(schId, id);
if (def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "草稿不能停用后启用,请先通过发布校验");
}
def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
defRepository.save(def);
}
/**
* 线级停发(2026-10-25 T03):整条 flowGuid 停止新发起/预览,在途实例按各自固定 defId 原版跑完。
* 与版本级 {@link #offline} 正交(可叠加);批量 UPDATE 全版本行,读任一行即读线状态。
* 不改写任何发布内容字节,恢复走 {@link #enableLine}。
*/
@Transactional
public int offlineLine(Long schId, String flowGuid) {
requireLine(schId, flowGuid);
return defRepository.updateLineOffline(schId, flowGuid.trim(), 1);
}
/**
* 线级恢复(T03):只恢复线的可用性——line_offline 归零;各版本 status/发布内容/版本指针
* 一律不动(停发期间发布新版不受影响,恢复后新发起即用当时最高已发布版)。
*/
@Transactional
public int enableLine(Long schId, String flowGuid) {
requireLine(schId, flowGuid);
return defRepository.updateLineOffline(schId, flowGuid.trim(), 0);
}
private void requireLine(Long schId, String flowGuid) {
if (flowGuid == null || flowGuid.trim().isEmpty()
|| !defRepository.findFirstBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid.trim())
.isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "流程线不存在:" + flowGuid);
}
}
/**
* 启用(2026-09-18):只对"停用"版恢复为已发布,与 offline 对称。
* 复用发布期的静态校验(把"任务无主"重新拦一次)——若建的时候某科室还有启用办理人、之后
@@ -207,11 +321,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);
@@ -256,7 +412,12 @@ public class FlowDefAdminService {
}
def.setName(cleanName);
def.setAppKey(cleanAppKey);
def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName));
// 发布封存(2026-10-25 T03):非草稿版本的执行文档不可变——行 name 照改(目录元数据),
// content_json 顶层 name 保持发布时字节,消除"已发布文档被原地改写"的旁路
//(审计 E02:updateMeta 曾同步改已发布 contentJson 顶层 name)。
if (def.getStatus() != null && def.getStatus() == FlwEnums.DefStatus.DRAFT.getCode()) {
def.setContentJson(syncTopLevelName(def.getContentJson(), cleanName));
}
try {
return defRepository.save(def);
} catch (DataIntegrityViolationException e) {
@@ -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;
@@ -23,7 +24,8 @@ import java.util.HashMap;
import java.util.Map;
/**
* 办件生命周期:代发起(幂等 + R4 重启重提)、撤回。
* 引擎实例生命周期与迁移前代发起。正式命令通过 create/startFormalInstance 建立独立轮次实例,
* 命令受理、坐标锁和轮次分配归 FormalApplyCommandService;下述 R4 复用仅描述旧入口。
*
* <p><b>R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿)</b>:biz uk 命中已终态办件
* (30 已拒绝 / 40 已撤回)时复用同一办件行"重启推进"——不新建、不报错:定义刷新为当前
@@ -60,49 +62,61 @@ public class FlowInstanceService {
public final String applyAppKey;
/** 调用方 app_code 快照(by-key 一次换得;UserBase 不可达为 null→消息回落发送方归属) */
public final String applyAppCode;
/** 发起时指派快照 JSON(oapi apply 校验后渲染;null=无指派) */
public final String assignJson;
/**
* 受理载荷指纹(2026-10-25 T04,SHA-256 hex;<b>非构造参数</b>——13 参构造被
* {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值;
* null=存量调用方,指纹核对缺省关)。
*/
public String payloadHash;
/** Assigned only by authenticated server-side source resolution; not a request DTO field. */
public String sourcePrincipalId;
}
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
@Transactional
public ApplyOutcome apply(ApplyCommand cmd) {
FlwInstance existing = instanceRepository
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
FlwInstance existing = findOwned(cmd);
if (existing != null) {
requireSameOwner(existing, cmd);
int status = existing.getStatus();
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
return new ApplyOutcome(existing.getId(), true);
}
// 30/40:R4 重启推进(复用办件行,定义/快照/时间刷新,重新走 START)
restart(existing, cmd);
return new ApplyOutcome(existing.getId(), true);
}
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.setApplyAppKey(cmd.applyAppKey);
instance.setApplyAppCode(cmd.applyAppCode);
instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName);
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instanceRepository.saveAndFlush(instance);
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
return new ApplyOutcome(instance.getId(), false);
FlwInstance created = createInstance(cmd, java.util.UUID.randomUUID().toString(), 1, cmd.sourcePrincipalId, 1, true);
return new ApplyOutcome(created.getId(), false);
}
private FlwInstance findOwned(ApplyCommand cmd) {
if (cmd.schId == null || cmd.schId <= 0 || cmd.sourcePrincipalId == null
|| !cmd.sourcePrincipalId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源或有效申报学校");
}
FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, 1).orElse(null);
if (existing == null && instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(
cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId).isPresent()) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务对象已有正式命令实例,请使用原命令通道");
}
return existing;
}
private void restart(FlwInstance instance, ApplyCommand cmd) {
restart(instance, cmd, java.util.UUID.randomUUID().toString(), instance.getApplyRound());
}
/**
* 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());
@@ -112,11 +126,23 @@ public class FlowInstanceService {
instance.setBizUrl(cmd.bizUrl);
instance.setBizSnapshot(cmd.bizSnapshot);
instance.setFormDataJson(cmd.formDataJson);
instance.setAssignJson(cmd.assignJson);
// 调用方身份快照跟随最新一次 apply 刷新(重启=新一轮代发起,调用方可能已变)
instance.setApplyAppKey(cmd.applyAppKey);
instance.setApplyAppCode(cmd.applyAppCode);
instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName);
// 新一轮才更新请求标识与轮次;同标识重投走不到这里(applyGuarded 已分支挡掉)
if (requestToken != null) {
instance.setApplyRequestToken(requestToken);
}
if (round != null) {
instance.setApplyRound(round);
}
if (cmd.payloadHash != null) {
// 新一轮命令重新取指纹(新轮=新命令,指纹跟随最新受理载荷)
instance.setApplyPayloadHash(cmd.payloadHash);
}
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instance.setFinishTime(null);
@@ -263,4 +289,216 @@ 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;
/**
* 期望的前一轮次(2026-10-25 T04 服务端轮次收口,可选):显式申报"我基于第 N 轮重提"。
* 非空时:行轮次 ≠ N ⇒ STALE_ROUND(不重启);通过则服务端分配 round=N+1,
* <b>不信客户端 attempt</b>。null=既有客户端 attempt 语义(存量兼容)。
*/
public final Integer expectedPrevRound;
public ApplyRetryPolicy(String requestToken, Integer attempt, boolean retryOnly) {
this(requestToken, attempt, retryOnly, null);
}
}
/** 守卫化结果:除 instanceId/duplicated 外,把"命中的是哪一种情形"落成稳定分支码 */
@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 = findOwned(cmd);
if (existing != null) {
return settleExisting(existing, cmd, policy);
}
String token = policy == null ? null : trimToNull(policy.requestToken);
Integer round = policy == null || policy.attempt == null ? Integer.valueOf(1) : policy.attempt;
FlwInstance created = createInstance(cmd, token, round, cmd.sourcePrincipalId, 1, true);
return new GuardedOutcome(created.getId(), 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())) {
// T04(2026-10-25):同 token 必须同指纹——同 ID 不同载荷是两份命令撞同一 token,
// 按冲突拒绝,不伪装成幂等成功。存量行(指纹 NULL)或请求无指纹时跳过(守卫缺省关)。
if (existing.getApplyPayloadHash() != null && cmd.payloadHash != null
&& !existing.getApplyPayloadHash().equals(cmd.payloadHash)) {
log.warn("[flow-center] 同请求标识但载荷指纹不一致(冲突拒绝):instance={} biz={}/{}",
existing.getId(), cmd.bizType, cmd.bizId);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
}
log.info("[flow-center] 同请求标识重投命中,只回读现状不重启:instance={} biz={}/{} status={}",
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()) {
// T04:带 token 的请求撞上在途/通过件且 token 不符=另一份命令(审计 P0-2:
// "10/20 即使异命令也回 IDEMPOTENT"是把他人命令的成功当自己的)。无 token 的
// 存量调用方保持 IDEMPOTENT(守卫缺省关)。
if (token != null) {
log.warn("[flow-center] 在途/通过件命中且请求 token 不符(冲突拒绝):instance={} biz={}/{}",
existing.getId(), cmd.bizType, cmd.bizId);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
}
return new GuardedOutcome(existing.getId(), true,
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());
}
// T04 服务端轮次:显式 expectedPrevRound 时——行轮次必须恰为 N(否则陈旧),服务端只分配 N+1;
// 未显式时保持客户端 attempt 语义(存量兼容)。
Integer expectedPrev = policy == null ? null : policy.expectedPrevRound;
int incomingRound;
if (expectedPrev != null) {
if (expectedPrev != currentRound) {
log.warn("[flow-center] 重提前置轮次不符(不重启):instance={} 行轮次={} 期望前轮={}",
existing.getId(), currentRound, expectedPrev);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
}
incomingRound = currentRound + 1;
} else {
incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt;
if (incomingRound <= currentRound) {
log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}",
existing.getId(), currentRound, incomingRound);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
}
}
restart(existing, cmd, token, incomingRound);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.RESTARTED, incomingRound);
}
/** Formal command caller must hold the coordinate lock in the same transaction. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FlwInstance createFormalInstance(ApplyCommand cmd, String token, Integer round, String source) {
return createInstance(cmd, token, round, source);
}
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void startFormalInstance(FlwInstance instance, ApplyCommand cmd) {
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
}
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) {
return createInstance(cmd, token, round, source, null, false);
}
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source,
Integer legacySlot, boolean start) {
FlwInstance instance = new FlwInstance();
instance.setSourcePrincipalId(source);
instance.setLegacySlot(legacySlot);
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.setApplyPayloadHash(cmd.payloadHash);
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);
if (start) {
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
}
return instance;
}
/**
* 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用
* 相同 bizType 键当自己的结果")。来源必须存在且匹配,不用 appKey 快照替代来源 UUID。
*/
private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) {
if (cmd.sourcePrincipalId == null || !cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) {
throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
}
if (existing.getSubmitterId() != null && cmd.submitterId != null
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符");
}
String 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;
}
}
@@ -0,0 +1,62 @@
package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/** Authenticated request scope; no user-supplied source/header is trusted. */
@Component @RequiredArgsConstructor
public class FlowOapiSourceScope implements HandlerInterceptor {
private static final String ATTRIBUTE = FlowOapiSourceScope.class.getName();
private final FlowSourcePrincipalResolver resolver;
private final FlwInstanceRepository instances;
private final FlwTaskRepository tasks;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String source = resolver.resolve((String) request.getAttribute("yh.app.auth")).id;
request.setAttribute(ATTRIBUTE, source);
String id = request.getParameter("instanceId");
java.util.regex.Matcher pathId = java.util.regex.Pattern.compile("/instance/(\\d+)(?:/|$)").matcher(request.getRequestURI());
if (id == null && pathId.find()) id = pathId.group(1);
String taskId = request.getParameter("taskId");
java.util.regex.Matcher taskPath = java.util.regex.Pattern.compile("/task/(\\d+)(?:/|$)").matcher(request.getRequestURI());
if (taskId == null && taskPath.find()) taskId = taskPath.group(1);
if (id != null) requireOwn(parse(id), source);
if (taskId != null) {
Long task = parse(taskId);
requireOwn(tasks.findById(task).orElseThrow(() -> denied()).getInstanceId(), source);
}
return true;
}
private void requireOwn(Long id, String source) {
FlwInstance instance = instances.findById(id).orElseThrow(() -> denied());
if (!source.equals(instance.getSourcePrincipalId())) throw denied();
}
public static String current() {
String source = currentOr(null);
if (source == null) throw denied();
return source;
}
public static String currentOr(String fallback) {
if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes)) return fallback;
Object value = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest().getAttribute(ATTRIBUTE);
return value instanceof String ? (String) value : fallback;
}
private Long parse(String value) {
try { return Long.valueOf(value); } catch (NumberFormatException e) { throw denied(); }
}
private static BizException denied() { return new BizException(ErrorCode.FORBIDDEN, "仅允许访问本应用来源的流程事实"); }
}
@@ -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());
@@ -304,7 +307,8 @@ public class FlowQueryService {
rows.add(new TimedRow(r.getDecideTime(), 1, r.getId(), routeTraceRow(r)));
}
for (FlwCc c : ccs) {
rows.add(new TimedRow(c.getSentTime(), 2, c.getId(), ccTraceRow(c)));
rows.add(new TimedRow(c.getSentTime(), 2, c.getId(),
ccTraceRow(c, model, instance, deptNames)));
}
rows.sort(Comparator.comparing((TimedRow r) -> r.time,
Comparator.nullsLast(Comparator.naturalOrder()))
@@ -355,8 +359,11 @@ public class FlowQueryService {
return row;
}
/** 抄送轨迹行(kind=CC):no taskId/opinion,接收人记在 assignee* 字段(前端复用人列) */
private static Map<String, Object> ccTraceRow(FlwCc c) {
/** 抄送轨迹行(kind=CC):no taskId/opinion,接收人记在 assignee* 字段(前端复用人列)。
* 2026-10-12 补科室口径:抄送节点用 receivers,走 {@link #fillCcScope} 富化 assigneeType,
* 前端 dept 档据此显科室名 / "抄送对象",不再回落接收人真实姓名。 */
private Map<String, Object> ccTraceRow(FlwCc c, FlowDefModel model, FlwInstance instance,
Map<String, String> deptNames) {
Map<String, Object> row = new HashMap<>();
row.put("rowKey", "cc" + c.getId());
row.put("ccId", c.getId());
@@ -367,6 +374,7 @@ public class FlowQueryService {
row.put("assigneeName", c.getUserName());
row.put("status", null);
row.put("time", str(c.getSentTime()));
fillCcScope(row, model, c.getNodeId(), instance.getSchId(), deptNames);
return row;
}
@@ -469,6 +477,25 @@ public class FlowQueryService {
Long schId, Map<String, String> deptNames) {
FlowDefModel.Node node = model == null ? null : model.node(nodeId);
FlowDefModel.Assignee assignee = node == null ? null : node.assignee;
applyAssigneeScope(row, assignee, schId, deptNames);
}
/**
* 抄送行(kind=CC)科室口径富化(2026-10-12):NOTIFY 节点的接收人存在 {@code receivers}
* 字段而非 {@code assignee},故不能直接复用 {@link #fillAssigneeScope}——否则取到的 assignee
* 恒为 null,dept 档下抄送行只能回落接收人真实姓名(原先的泄露点)。
* 节点在定义里找不到(重提后的老节点、已删节点)不填,前端统一显示"抄送对象"。
*/
private void fillCcScope(Map<String, Object> row, FlowDefModel model, String nodeId,
Long schId, Map<String, String> deptNames) {
FlowDefModel.Node node = model == null ? null : model.node(nodeId);
FlowDefModel.Assignee receivers = node == null ? null : node.receivers;
applyAssigneeScope(row, receivers, schId, deptNames);
}
/** 两种口径的公共落值:assigneeType 必填;DEPT_KEY 另填 deptKey + 映射里的科室名 */
private void applyAssigneeScope(Map<String, Object> row, FlowDefModel.Assignee assignee,
Long schId, Map<String, String> deptNames) {
if (assignee == null || assignee.type == null) {
return;
}
@@ -7,12 +7,14 @@ import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.entity.FlwRouter;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwRouterRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -23,6 +25,7 @@ import java.util.List;
* 条件路由管理(2026-09-24 §4.2/§4.4):flw_router 注册 CRUD(全局表,管理员维护)
* + 挂起办件的重跑路由入口。发布校验引用本表(routerKey 白名单),停用注册会拦住新发布。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowRouterAdminService {
@@ -30,6 +33,7 @@ public class FlowRouterAdminService {
private final FlwRouterRepository routerRepository;
private final FlwInstanceRepository instanceRepository;
private final FlwDefRepository defRepository;
private final com.yunhai.flowcenter.repository.FlwTaskRepository taskRepository;
private final FlowEngine flowEngine;
private final ObjectMapper objectMapper;
@@ -69,6 +73,75 @@ public class FlowRouterAdminService {
return routerRepository.save(router);
}
/**
* 自注册登记(2026-10-12 SERVICE 收口,由 yh-flow-callback starter 启动时调用)——<b>登记即持有</b>:
* <ul>
* <li>新键:建行,{@code path=judgePath}、{@code directHost} 非空(对端据此跳过服务目录三级解析
* 直连),{@code serviceKey} 填归属 appKey(NOT NULL 列,管理端"来源"展示用),status=1;</li>
* <li>已有行:{@code regAppKey} 非空且 ≠ 调用方 → 业务错拒绝(防跨应用覆盖);为空(管理端手工行)
* → 首次自注册即认领。<b>status 不动</b>:停用状态不被自注册翻正,管理员保持控制权;</li>
* <li>幂等:同 appKey 重复登记=更新地址,无 secret 轮换风险(与 callback 注册的差异)。</li>
* </ul>
* 管理端既有 CRUD({@link #save}/{@link #setStatus})不受影响——管理员仍可改一切。
*/
@Transactional
public FlwRouter registerByApp(String appKey, OapiModels.RouterRegBody body) {
if (isBlank(appKey)) {
throw new BizException(ErrorCode.UNAUTHORIZED, "判断服务登记需应用签名(appKey 为空)");
}
if (body == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "登记体不能为空");
}
String routerKey = requireLength(body.getRouterKey(), 64, "routerKey");
String name = requireLength(body.getName(), 128, "name");
String judgePath = body.getJudgePath() == null ? "" : body.getJudgePath().trim();
if (!judgePath.startsWith("/")) {
throw new BizException(ErrorCode.PARAM_ERROR,
"judgePath 必须以 / 开头(如 /oapi/flow/route/judge)");
}
String directHost = body.getDirectHost() == null ? "" : body.getDirectHost().trim();
if (!directHost.startsWith("http://") && !directHost.startsWith("https://")) {
throw new BizException(ErrorCode.PARAM_ERROR,
"directHost 必须是 http:// 或 https:// 开头的基址:" + directHost);
}
FlwRouter router = routerRepository.findByRouterKey(routerKey).orElse(null);
if (router == null) {
FlwRouter created = new FlwRouter();
created.setRouterKey(routerKey);
created.setName(name);
created.setServiceKey(appKey);
created.setPath(judgePath);
created.setDirectHost(directHost);
created.setRegAppKey(appKey);
created.setStatus(1);
log.info("[flow-center] 判断服务自注册新增:routerKey={} appKey={} host={} path={}",
routerKey, appKey, directHost, judgePath);
return routerRepository.save(created);
}
if (!isBlank(router.getRegAppKey()) && !router.getRegAppKey().equals(appKey)) {
throw new BizException(ErrorCode.FORBIDDEN,
"routerKey 已被应用 " + router.getRegAppKey() + " 登记:" + routerKey);
}
router.setName(name);
router.setPath(judgePath);
router.setDirectHost(directHost);
router.setRegAppKey(appKey);
log.info("[flow-center] 判断服务自注册更新:routerKey={} appKey={} host={} path={} status 保持不变={}",
routerKey, appKey, directHost, judgePath, router.getStatus());
return routerRepository.save(router);
}
private static String requireLength(String raw, int max, String field) {
String value = raw == null ? "" : raw.trim();
if (value.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, field + " 必填");
}
if (value.length() > max) {
throw new BizException(ErrorCode.PARAM_ERROR, field + " 长度超 " + max + ":" + value);
}
return value;
}
/** 启用/停用(停用拦新发布引用;存量在途办件重跑会失败挂起,注意时机) */
@Transactional
public FlwRouter setStatus(Long id, boolean enabled) {
@@ -81,9 +154,26 @@ public class FlowRouterAdminService {
/**
* 重跑路由(挂起恢复唯一入口,§4.4):办件须在途且挂在 CONDITION 节点(currentNodes 含之、
* 该节点无待办任务)。重新调判断,成功即续走推进;仍失败抛业务错(挂起不变,留痕已落新行)。
* 默认按办件落库的 def 快照重跑(现行为逐字不变)。
*/
@Transactional
public void reroute(Long schId, Long instanceId) {
reroute(schId, instanceId, false);
}
/**
* 重跑路由(挂起恢复唯一入口,§4.4)。2026-10-25 T03 在途锁版收口:useLatestDef=true 的
* "切到最新发布版续走"旁路已按"在途实例固定原版本"原则停用——新发布版只影响新申报;
* 挂起件按原定义重跑(修复表达式后按<b>原版本</b>重新发布或由发起方重新申报)。
* 保留参数与显式拒绝(不静默忽略),旧调用方拿到人类可读业务错。
*/
@Transactional
public void reroute(Long schId, Long instanceId, boolean useLatestDef) {
if (useLatestDef) {
throw new BizException(ErrorCode.PARAM_ERROR,
"在途换版已停用(在途实例固定使用原版本,新版本只影响新申报);"
+ "请修复表达式后按原版本重新发布,或让发起方终态后重新申报");
}
FlwInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
if (!instance.getSchId().equals(schId)) {
@@ -0,0 +1,46 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class FlowSourcePrincipalResolver {
private final OpenApiAppClient appClient;
public Principal resolve(String authenticatedAppKey) {
if (authenticatedAppKey == null || authenticatedAppKey.trim().isEmpty()) {
throw new BizException(ErrorCode.UNAUTHORIZED, "缺少已认证的调用应用");
}
JsonNode app;
try { app = appClient.getAppByKey(authenticatedAppKey.trim()); }
catch (RuntimeException unavailable) {
throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体查询不可用");
}
if (app == null || !app.path("id").isIntegralNumber() || app.path("id").intValue() <= 0) {
throw new BizException(ErrorCode.UNAUTHORIZED, "调用应用主体不可解析");
}
String source = app.path("sourcePrincipalId").asText("");
try {
if (!UUID.fromString(source).toString().equals(source)) throw new IllegalArgumentException();
} catch (IllegalArgumentException e) {
throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 稳定来源尚未迁移,禁止回退到数字 ID/appKey");
}
return new Principal(source, app.path("id").intValue(), authenticatedAppKey,
app.path("appCode").isTextual() ? app.path("appCode").textValue() : null);
}
@lombok.RequiredArgsConstructor
public static class Principal {
public final String id;
public final Integer appId;
public final String appKey;
public final String appCode;
}
}
@@ -246,7 +246,7 @@ public class FlowTaskService {
/**
* 移交(B4):办理权转给他人。当前任务置 TRANSFERRED 留痕,同节点新建一条待办给目标人
* (deadline 沿用原任务);推进监听器因新待办 pending>0 自然不放行,无需改推进逻辑。
* 目标人存在性由 AssigneeResolver 严格校验(查不到必须拒绝,否则产生僵尸待办)。
* 目标人资格由 AssigneeResolver 终审(存在/启用/本校,D11)——查不到必须拒绝,否则产生僵尸待办。
*/
@Transactional
public void transfer(Long schId, Long taskId, Integer toUserId, String reason, Integer operatorId) {
@@ -259,7 +259,9 @@ public class FlowTaskService {
}
FlwInstance instance = instanceRepository.findById(task.getInstanceId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
String toUserName = assigneeResolver.requireUserName(toUserId);
// D11 移交终审:目标必须存在/启用/属于本校;全部校验在抢占原任务之前完成,
// 失败不会先替换人员或改写任务状态。
String toUserName = assigneeResolver.requireEligibleAssigneeName(task.getSchId(), toUserId);
LocalDateTime now = LocalDateTime.now();
String fromLabel = task.getNodeName() == null ? task.getNodeId() : task.getNodeName();
@@ -0,0 +1,128 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.flowcenter.entity.FlwUsageGrant;
import com.yunhai.flowcenter.repository.FlwUsageGrantRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 应用使用授权管理面(2026-10-25 T01):学校×流程线×稳定主体的授予/撤销/查询。
*
* <p>授予时把 appKey 解析成 UserBase 稳定主键 appId 落库(授权对象是主体不是凭据),
* 并按学校开通事实做发放闸(enforce 开启时未开通拒发)。撤销只置 {@code enabled=0}
* (不删行,审计与幂等判据保留);撤权只拦新发起,不回写在途实例与已受理命令。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowUsageGrantAdminService {
private final FlwUsageGrantRepository grantRepository;
private final UserBaseActivationClient activationClient;
@Autowired(required = false)
private OpenApiAppClient appClient;
public List<FlwUsageGrant> listByFlow(Long schId, String flowGuid) {
return grantRepository.findBySchIdAndFlowGuidOrderByUpdateTimeDescIdDesc(schId, flowGuid);
}
public List<FlwUsageGrant> listByApp(Long schId, Integer appId) {
return grantRepository.findBySchIdAndAppIdOrderByUpdateTimeDescIdDesc(schId, appId);
}
/**
* 授予/重申(三元键 upsert)。
*
* @throws BizException appKey 不可解析为稳定主体(fail-closed,管理面必须授到真实主体上);
* enforce 开启且应用未在该校开通或核验不可用
*/
@Transactional
public FlwUsageGrant grant(Long schId, String flowGuid, String appKey, String remark, String operator) {
if (schId == null || schId <= 0 || flowGuid == null || flowGuid.trim().isEmpty()
|| appKey == null || appKey.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "schId/flowGuid/appKey 必填");
}
String cleanKey = appKey.trim();
JsonNode registered = fetchRegistered(cleanKey);
Integer appId = registered == null ? null : readId(registered);
if (appId == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"appKey 未能在 UserBase 解析出稳定应用主体,拒绝授权:" + cleanKey);
}
if (!activationClient.isAppActivatedStrict(appId, schId)) {
throw new BizException(ErrorCode.FORBIDDEN,
"该应用未在学校 " + schId + " 开通,不能授予流程使用许可");
}
FlwUsageGrant grant = grantRepository
.findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId)
.orElseGet(FlwUsageGrant::new);
boolean isNew = grant.getId() == null;
if (isNew) {
grant.setSchId(schId);
grant.setFlowGuid(flowGuid.trim());
grant.setAppId(appId);
}
{
String source = registered.path("sourcePrincipalId").asText("");
try {
if (!java.util.UUID.fromString(source).toString().equals(source)) throw new IllegalArgumentException();
} catch (IllegalArgumentException invalid) {
throw new BizException(ErrorCode.FORBIDDEN, "UserBase 稳定来源未迁移,不能发放正式许可");
}
grant.setSourcePrincipalId(source);
}
grant.setAppKeySnapshot(cleanKey);
grant.setAppNameSnapshot(registered.path("appName").asText(null));
grant.setEnabled(1);
grant.setRemark(remark);
FlwUsageGrant saved = grantRepository.save(grant);
log.info("[usage-grant] 授予 schId={} flowGuid={} appId={} appKey={} operator={} 新建={}",
schId, flowGuid, appId, cleanKey, operator, isNew);
return saved;
}
/** 撤销(enabled=0,行保留);幂等:对已撤销行重复撤销不报错 */
@Transactional
public FlwUsageGrant revoke(Long schId, String flowGuid, Integer appId, String remark, String operator) {
FlwUsageGrant grant = grantRepository
.findBySchIdAndFlowGuidAndAppId(schId, flowGuid, appId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"使用许可不存在:school=" + schId + " flow=" + flowGuid + " app=" + appId));
grant.setEnabled(0);
if (remark != null && !remark.trim().isEmpty()) {
grant.setRemark(remark.trim());
}
FlwUsageGrant saved = grantRepository.save(grant);
log.info("[usage-grant] 撤销 schId={} flowGuid={} appId={} operator={}(只拦新发起,在途不受影响)",
schId, flowGuid, appId, operator);
return saved;
}
private JsonNode fetchRegistered(String appKey) {
if (appClient == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 应用注册通道未装配,拒绝授权");
}
try {
JsonNode node = appClient.getAppByKey(appKey);
return node == null || node.isNull() ? null : node;
} catch (RuntimeException e) {
log.warn("[usage-grant] UserBase 注册解析失败 appKey={}:{}", appKey, e.toString());
throw new BizException(ErrorCode.UNAUTHORIZED, "UserBase 应用注册解析不可用,请稍后重试");
}
}
private Integer readId(JsonNode registered) {
JsonNode idNode = registered.path("id");
return idNode.isNumber() ? idNode.intValue() : null;
}
}
@@ -0,0 +1,85 @@
package com.yunhai.flowcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.flowcenter.entity.FlwUsageGrant;
import com.yunhai.flowcenter.repository.FlwUsageGrantRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 应用使用许可闸门(2026-10-25 T01):新发起必须持有生效的 (school, flowGuid, appId) 许可。
*
* <p>三层独立事实,缺一不可(所有环境):</p>
* <ol>
* <li><b>稳定主体</b>:appKey 验签凭据 → UserBase 注册行主键 appId(appKey 可重置/复用,
* 不是主体;主体解析不可达=fail-closed 拒绝);</li>
* <li><b>FL 本地使用许可</b>:{@code flw_usage_grant} 生效行(独立于 flw_app 定义保存白名单
* 与流程定义管理权);</li>
* <li><b>UB 学校开通</b>:该应用在该校的平台开通事实(第二重证明,不因本地 grant 而豁免)。</li>
* </ol>
*
* <p><b>撤权边界(P0-3)</b>:本闸只拦"新发起";已受理命令的回执恢复与在途实例不回读本表——
* 恢复路径的"是否已受理"判定归命令协议(T04),调用方负责在恢复命中时跳过本闸。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowUsageGrantGuard {
private final FlwUsageGrantRepository grantRepository;
private final UserBaseActivationClient activationClient;
@Autowired(required = false)
private OpenApiAppClient appClient;
/** Mandatory in every profile; the historical enforce flag cannot disable authorization. */
public boolean isEnforce() {
return true;
}
/**
* 新发起资格核验:返回稳定主体 appId(供命令快照/审计使用)。
*
* @throws BizException 所有环境:主体解析不可用、无生效许可、未在该校开通、
* UB 资格核验不可用——四类都拒绝,不猜不降级
*/
public Integer requireNewApplyGrant(Long schId, String flowGuid, String appKey) {
if (schId == null || flowGuid == null || flowGuid.trim().isEmpty()
|| appKey == null || appKey.trim().isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少使用许可判定所需的学校/流程线/调用主体");
}
return requireNewApplyGrant(schId, flowGuid, new FlowSourcePrincipalResolver(appClient).resolve(appKey));
}
/** UserBase app-school and FL app-flow are independent mandatory gates. */
public Integer requireNewApplyGrant(Long schId, String flowGuid, FlowSourcePrincipalResolver.Principal source) {
if (schId == null || schId <= 0 || flowGuid == null || flowGuid.trim().isEmpty() || source == null) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少有效申报学校/流程线/来源主体");
}
Integer appId = source.appId;
if (appId == null || appId <= 0 || source.id == null) {
throw new BizException(ErrorCode.FORBIDDEN, "调用应用主体解析不可用,已按未授权拒绝");
}
FlwUsageGrant grant = grantRepository.findBySchIdAndFlowGuidAndAppId(schId, flowGuid.trim(), appId)
.orElse(null);
if (grant == null || !Integer.valueOf(1).equals(grant.getEnabled())
|| !source.id.equals(grant.getSourcePrincipalId())) {
log.warn("[usage-grant] 拒绝新发起(无生效使用许可):schId={} flowGuid={} appId={} appKey={}",
schId, flowGuid, appId, source.appKey);
throw new BizException(ErrorCode.FORBIDDEN,
"该应用未获此流程线的使用许可(school=" + schId + " flow=" + flowGuid + ")");
}
if (!activationClient.isAppActivatedStrict(appId, schId)) {
log.warn("[usage-grant] 拒绝新发起(应用未在该校开通):schId={} appId={}", schId, appId);
throw new BizException(ErrorCode.FORBIDDEN,
"该应用未在学校 " + schId + " 开通,不能发起新申报");
}
return appId;
}
}
@@ -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;
}
}

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