Compare commits

44 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
chenxing 4eefe078e3 fix(test): FlowDefAdminService 两个测试跟随 de332d1 的 save 8 参签名与 FlwInstanceRepository 注入(B1 漏提交,HEAD 测试编译红修复)
build / build-push (push) Successful in 56s
2026-10-12 17:22:53 +08:00
chenxing de332d1c66 B1 后端基础:flw_def.change_log + 在途统计接口 + 路由 trace 透出 rawResponse
- change_log(议题二③):FlwDef 附加列 + save 加 changeLog 入参,只在三个新建行分支写入
  (def.getId()==null 判据),草稿直改不写也不清空;>500 service 侧截断不抛错;补生产备案 SQL
- GET /admin/def/inflight(议题二④):flowGuid→审批中(10)办件数,口径=def_id ∈ 该线全部版本;
  请求的每个 guid 都建 0 键;逗号分隔去重限 100;FlowDefAdminService 注入 FlwInstanceRepository
- routeTraceRow(议题三②):kind=ROUTE 行增 rawResponse(null→"",>500 截断加 …)

验证:mvn.cmd 单模块编译通过;spring-boot:run 启动成功无循环依赖;ddl-auto 已自动建 change_log 列;
SQL 口径核对在途 37 件/8 条线,flw_route_log 24 行均有 raw_response
2026-10-12 15:59:47 +08:00
chenxing c896fbb90b feat(flow): 用户面四查询(todo/done/cc/my)支持 appKey 按应用筛选
build / build-push (push) Successful in 48s
工作台四页签加「按应用筛选」:controller 收可选 appKey,经 FlowQueryService
透传到分页查询;口径=办件定义归属 flw_def.app_key(join flw_instance→flw_def,
均多对一,不影响已办「一个办件一行」的去重与分页)。appKey 空串按不过滤处理
(emptyToNull),避免清筛选后误成空列表。FlwCcRepository 抄送派生查询相应改
@Query 以支持 join 过滤;FlowEngineIT 两处调用补 null 实参。

门禁:mvn compile/test-compile ✓ + 非 DB 单测 44 例 ✓;本机无 MySQL(3306
未监听),新 JPQL 未在真实库执行,FlowEngineIT/OapiFlowContractIT 待有库环境补跑
2026-10-12 11:26:11 +08:00
chenxing b4856a480d fix(msg): 消息 jump_app 换源 apply 调用方 app_code 快照,替代 bizType 冒充
build / build-push (push) Successful in 49s
P1/P2 联合修复(消息归属 + jump_app 值域污染):
- FlwInstance 新增 apply_app_key/apply_app_code 双列快照(自动 DDL)
- apply 从验签上下文(yh.app.auth)取调用方身份,by-key 一次换得 app_code
  (域间边界只在 apply 转换一次;查无/不可达降级 null,不阻断 apply)
- UserMsgNotifier 两处 jumpApp 改源 applyAppCode——bizType 是消息分类
  不是路由键;降级空时接收方按发送方回落(现状行为)
- FlowDefAdminService.save appKey 必填化(2026-10-12 拍板):新建必须
  显式传;升版本/直改可继承源行,源行为空必须补填(存量 19 条 NULL 随编辑收敛)
- 单测:SaveTest 新增必填拦截 3 用例、BizUrlTest 换源断言+降级用例(19 绿)
- E2E(8085+UserBase):100617->apply_app_code=notice 正向落库、
  100018(app_code NULL)->降级成功、错签名 401,三断言全过
2026-10-12 10:44:07 +08:00
chenxing d687ba7d56 fix(flow): 待办/已办列表补充 submitterName 字段
build / build-push (push) Successful in 51s
- FlowModels.TaskItem 新增 submitterName(办件发起人姓名快照)
- FlowQueryService.fillInstanceInfo 复用已加载的 FlwInstance 补 set,零额外查询
- 修复前端「我的待办」列头「提交人」因字段缺失而回落显示当前办理人的问题
2026-10-12 10:28:24 +08:00
chenxing 0ecbf1740b 外观SDK化任务E:本地 AppearanceProxyController 下沉平台 starter
build / build-push (push) Successful in 58s
删本地哑管道(与 Notice/svc-hall 三副本之一),加 yh-appearance-spring-boot-starter
(BOM 管版本,YschOpenCall 走聚合包)。端点同 URL /api/appearance 同语义:三态
proxy/local/off,503 直写铁律内置。聚合包不收 starter(三端本地类未收敛时会
Ambiguous mapping,本轮三端同批收敛后解除)。
2026-10-12 09:39:05 +08:00
chenxing 6f68e1ebe7 fix(diagram): CONDITION 多出口分支分列——不等长/空分支不再被拓扑分层压成单列直穿(用户实测 def-32 或签流程报错)。spreadConditionBranches 后处理:各出口分支按列对称展开(链形分支逐节点对列中心,内嵌并行回退整块平移),隐式汇合点(各分支可达集交集 BFS 最早节点)吸附回条件节点中线;等长同层分支为恒等变换不回归(golden 8/8)
build / build-push (push) Successful in 52s
2026-10-12 08:59:05 +08:00
chenxing 6cd33abc67 fix(test): FlowAppAdminServiceTest 构造器补 FlwCallbackRegRepository mock——跟随 probe/stats 新依赖(CI testCompile 失败修复)
build / build-push (push) Successful in 49s
2026-10-11 22:55:23 +08:00
chenxing f07ac6da93 feat(callback/app): 回调探测端点 /admin/callback/probe/{id}——签 flow.ping 未知事件码走真实管道同步一发(信封同构 CallbackDispatcher,结果落 flw_callback_log 一次性行+resp_body 响应摘要列);应用辅助统计 /admin/app/stats(appKey→本校回调注册数,应用设置页对接深度列)
build / build-push (push) Failing after 14s
2026-10-11 22:40:44 +08:00
chenxing cd046782d9 fix(flow): 科室/成员端点补 requireId 边界防护——缺 id 回业务错而非 findById(null) 笼统 500(用户验收反馈 R2)
build / build-push (push) Successful in 2m54s
2026-10-10 22:43:01 +08:00
chenxing a06b610d9d feat(dept): 科室管理——flw_dept/flw_dept_member 组+成员模型取代单用户映射行(议题3,拍板D1选B)
build / build-push (push) Successful in 54s
- deptKey=本地自增 id;source_group_id 幂等(一键拉取=初始化/刷新语义,不重复建组);不做存量迁移
- 四处读源全部切新表:AssigneeResolver(宽容口径:deptKey 不存在/停用返回空,图示/轨迹不炸)、
  enabledDeptKeys 发布校验白名单、DeptLabelSupport 唯一出口(图示科室视图+轨迹富化)、编辑器 deptList 聚合
- /admin/dept/* 全套端点(CRUD/成员/拉取预览导入);旧 /map/* 映射 CRUD 端点删除
  (保留 /map/lookupUser 选人检索与 /user/teacherTree);flw_assignee_map 实体/表保留不删
- IT 种子切源(FlowTestSupport tag→deptKey 注册表,用例主体零改动);全量 191 绿;E2E 22/22
  (local-debug/four-topics-e-e2e.py,含切源后果验证与生产盘点 SQL)
2026-10-10 19:47:58 +08:00
chenxing 4293c9628a feat(def): updateMeta 元数据原地编辑——已发布可改 name/appKey 不升版本(议题2)
- 独立于 save 的升版本路径:不碰 code/flow_guid/version/status/contentJson 结构
- contentJson 顶层 name 同步(ObjectNode 保序;消除行 name 与 JSON name 脏窗口,拍板 D4)
- appKey 校验同 save 口径(显式传入须 flw_app 启用集合;可置空)
- 单测 5 例(三不变断言/白名单/补写/跨校)+ E2E 14/14(含升版本路径回归与 oapi 摘要)
2026-10-10 19:23:44 +08:00
chenxing d6b7a68ec3 feat(app): 应用设置·应用管理——flw_app 白名单 + 学校池同步 + defSave 归属应用校验
- flw_app(sch,app_key uk):学校池(UserBase list-by-school,学校组单点已开通、clientType=null 不过滤端)
  diff 同步——新增即 enabled=1、改名静默刷新、池外保留;UserBase 不可达降级本地行
- /admin/app/list(触发同步)/ /admin/app/enable;defSave 显式 appKey 须 ∈ 启用集合(不传不拦,升版本继承不受影响)
- SQL 备案:flw_app 建表 + UserBase 菜单 107 新增/104 下线(人工执行)
- 单测 +8;E2E 13/13(local-debug/four-topics-c-e2e.py)
- 关联 SDK 修复:BoaApp.createTime @JsonFormat 对齐 UserBase 时间契约(BasePlatform 单独 commit,Nexus -73)
2026-10-10 19:17:38 +08:00
chenxing 92619508f0 feat(condition): CONDITION 判断方式二选一——EXPRESSION 内置 Aviator 表达式(沙箱)+ SERVICE 原口径;JudgeBody 补 submitterId
- ExpressionEvaluator:Aviator 5.4.3 沙箱(FEATURE_SET 只留 If、ALLOWED_CLASS_SET 空=禁类访问/反射)、编译缓存、
  顶层变量缺失抛错挂起(Aviator 缺失变量按 nil 且 nil 视作最小值,<= 兜底出口会静默放行——宁挂起不误放行)
- ConditionRouteService.decide 分流:EXPRESSION 按出边顺序取首个 true(逐边结果摘要落 raw_response),
  全 false/异常挂起沿用 SERVICE 纪律(route_log outlet=NULL + EXPRESSION 常量 + 告警 + 管理端重跑)
- DefValidator 发布期分层校验:EXPRESSION 出口缺条件/语法错/违禁表达式硬拦;表单变量放行(运行时核对);
  内置变量疑似拼错 warn;judgeMode 非法值拦
- /admin/router/eval 调试端点(同一求值核心,不落库不走流程)
- 单测 22 新增 + 全量 170 绿;E2E 18/18(发布/分支选路/挂起留痕/重跑/调试)见 local-debug/four-topics-a-e2e.py
2026-10-10 18:52:40 +08:00
chenxing a1f99fd7e0 feat(def): 流程定义唯一键改按流程线管 + 归属应用筛选
build / build-push (push) Successful in 8m31s
- flw_def 唯一键 (sch,code,ver)→(sch,flow_guid,ver)(uk_flw_def_guid_ver,生产/本地已手工切索引):升版本 maxByGuid+1 与唯一键同口径,同编码多条流程线(work-leave 三条线实锤追尾)不再死锁
- 新建永远新开流程线 v1,删除同编码草稿复用;撞唯一键转"保存冲突"友好提示
- 新增 flw_def.app_key 归属应用列:save 全链路透传(升版本缺省继承源线、传非空覆盖);/admin/def/list 支持 appKey 过滤;新增 /admin/def/appkeys 应用清单(flw_def 已标注 ∪ 已注册回调)
- FlowDefAdminServiceSaveTest 10 用例锁 save 分派语义
2026-10-10 14:34:25 +08:00
185 changed files with 24196 additions and 835 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、不启动对账、不做前端视觉验收。
+27
View File
@@ -50,6 +50,17 @@
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- 外观偏好同源读端点 GET /api/appearance(三态 proxy/local/off,默认 proxy 经 YschOpenCall
代理 UserHome /oapi/appearance,userhome 地址走平台 application-platform.yml 兜底)。
平台件不进聚合包:本地同名控制器已删(2026-10-12 任务 E 收敛,starter 端点同 URL 同语义) -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-appearance-spring-boot-starter</artifactId>
<!-- 显式版本(2026-10-17 过夜任务环境修复,与 NoticeService 同因):该件只由 BasePlatform
的 feat/appearance-sdk 分支及其 BOM 托管,本次接入件工作基于 feat/flow-embed(BOM 无此条目),
不写版本则依赖解析直接失败、本仓无法打包。appearance 分支合流后应删本行回落 BOM。 -->
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- 平台 auth starter(0928 后快照)的 WeChatAuthAutoConfiguration 签名引用 StringRedisTemplate,
data-redis 为 optional,接入前提=应用自带(见该 starter pom 注释);缺失时 bean 签名解析直接
TypeNotPresentException,应用起不来 -->
@@ -70,6 +81,14 @@
<scope>runtime</scope>
</dependency>
<!-- 条件路由表达式引擎(2026-10-10 四议题):CONDITION 节点 judgeMode=EXPRESSION 时
出边 condition 用 AviatorScript 求值(JDK8 兼容;5.4.3 含安全修复,沙箱见 ExpressionEvaluator) -->
<dependency>
<groupId>com.googlecode.aviator</groupId>
<artifactId>aviator</artifactId>
<version>5.4.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -81,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,11 +237,61 @@ 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();
logId = logStore.persist(callbackLog);
} catch (DuplicateKeyException dup) {
return; // uk(event_id, reg_id) 并发兜底:另一路径已建行,幂等成功
}
submitPush(logId);
}
/** 异步首推提交:失败不回退建行结果(行已在,重试扫描接手) */
private void submitPush(Long logId) {
try {
executor().submit(() -> pushService.pushAsync(logId));
} catch (Exception 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());
@@ -120,29 +310,12 @@ public class CallbackDispatcher {
if (event.getData() != null) {
data.putAll(event.getData());
}
payload.put("data", data);
String payloadJson = objectMapper.writeValueAsString(payload);
FlwCallbackLog callbackLog = new FlwCallbackLog();
callbackLog.setSchId(instance.getSchId());
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 起)
executor().submit(() -> pushService.pushAsync(logId));
} catch (Exception e) {
log.error("[flow-center] 回调日志落库失败(对账兜底会追平):instance={} event={} {}",
instance.getId(), event.getType().getCode(), e.toString(), e);
// 身份在事件发布线程中冻结,不能用 AFTER_COMMIT 重读到的新轮给旧事件重新贴标。
if (event.getRoundIdentity() != null) {
data.putAll(event.getRoundIdentity());
}
payload.put("data", data);
return payload;
}
/** 重推扫描(fixedDelay 串行防重入;推拉结合里 Webhook 永远只是加速器) */
@@ -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,11 +236,102 @@ public class CallbackPushService {
}
}
private void saveQuietly(FlwCallbackLog callbackLog) {
/**
* 回调探测(2026-10-11 管理端增强):以该注册的 secret 按现行信封签一条未知事件码 flow.ping,
* 走真实推送管道同步一发——接收方按规范 §6.4 对未知事件码"记日志后 200",不破坏业务数据。
* 探测是一次性行为:结果落 flw_callback_log(成功=1 / 失败=2,永不进重试队列),
* 响应体摘要落 resp_body。返回 {success, httpStatus, elapsedMs, respSnippet, error}。
*/
public java.util.Map<String, Object> probe(Long schId, FlwCallbackReg reg) {
String eventId = UUID.randomUUID().toString().replace("-", "");
String body = probePayload(schId, reg, eventId);
long start = System.currentTimeMillis();
String ts = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
Integer httpStatus = null;
String respSnippet = null;
String error = null;
try {
logRepository.save(callbackLog);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Flw-EventId", eventId);
headers.set("X-Flw-Timestamp", ts);
headers.set("X-Flw-Nonce", nonce);
headers.set("X-Flw-Signature", sign(reg.getSecret(), ts, nonce, body));
RestTemplate template = CallbackAppSigner.signedTemplate(
appIdentityProvider.getIfAvailable(), reg.getBasePath(), 5000, 10000);
ResponseEntity<String> resp = template.exchange(reg.getCallbackUrl(), HttpMethod.POST,
new HttpEntity<>(body, headers), String.class);
httpStatus = resp.getStatusCodeValue();
respSnippet = resp.getBody() == null ? null : truncate(resp.getBody());
} catch (Exception e) {
log.error("[flow-center] 回调日志落库失败:eventId={} {}", callbackLog.getEventId(), e.toString());
error = rootMessage(e);
}
long elapsed = System.currentTimeMillis() - start;
boolean ok = httpStatus != null && httpStatus >= 200 && httpStatus < 300;
persistProbeLog(schId, reg, eventId, body, ok, httpStatus, respSnippet, error);
log.info("[flow-center] 回调探测:reg={} url={} ok={} http={} elapsed={}ms",
reg.getId(), reg.getCallbackUrl(), ok, httpStatus, elapsed);
java.util.Map<String, Object> out = new java.util.HashMap<>();
out.put("success", ok);
out.put("httpStatus", httpStatus);
out.put("elapsedMs", elapsed);
out.put("respSnippet", respSnippet);
out.put("error", error);
return out;
}
/** 探测信封:与 CallbackDispatcher 现行事件同构,event=flow.ping(未知码),instanceId=0 哨兵 */
private String probePayload(Long schId, FlwCallbackReg reg, String eventId) {
java.util.Map<String, Object> payload = new java.util.HashMap<>();
payload.put("eventId", eventId);
payload.put("event", "flow.ping");
payload.put("schId", schId);
payload.put("flowGuid", reg.getFlowGuid());
payload.put("instanceId", 0L);
payload.put("instanceVersion", 0);
payload.put("bizType", "probe");
payload.put("bizId", String.valueOf(reg.getId()));
payload.put("bizTitle", "回调探测 flow.ping");
payload.put("occurredTime", LocalDateTime.now().format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME));
payload.put("taskId", null);
payload.put("operatorUserId", null);
payload.put("operatorName", null);
java.util.Map<String, Object> data = new java.util.HashMap<>();
data.put("probe", true);
data.put("message", "flow-center 回调连通性探测(未知事件码,按规范 §6.4 记日志后 200 即可)");
payload.put("data", data);
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(payload);
} catch (Exception e) {
throw new IllegalStateException("探测信封序列化失败", e);
}
}
/** 探测结果落日志行(一次性行为:成功=1 / 失败=2,status≠PENDING 永不被重试扫描捞起) */
private void persistProbeLog(Long schId, FlwCallbackReg reg, String eventId, String body,
boolean ok, Integer httpStatus, String respSnippet, String error) {
try {
FlwCallbackLog row = new FlwCallbackLog();
row.setSchId(schId);
row.setRegId(reg.getId());
row.setEventId(eventId);
row.setEvent("flow.ping");
row.setInstanceId(0L);
row.setBizType("probe");
row.setBizId(String.valueOf(reg.getId()));
row.setPayloadJson(body);
row.setRespBody(respSnippet);
row.setStatus(ok ? FlwEnums.CallbackLogStatus.SUCCESS.getCode()
: FlwEnums.CallbackLogStatus.DEAD.getCode());
row.setRetryCount(0);
row.setNextRetryTime(LocalDateTime.now());
row.setLastHttpStatus(httpStatus == null ? null : String.valueOf(httpStatus));
row.setLastError(error == null ? null : truncate(error));
row.setPushTime(LocalDateTime.now());
logRepository.save(row);
} catch (Exception e) {
log.warn("[flow-center] 探测日志落库失败:reg={} {}", reg.getId(), e.toString());
}
}
@@ -0,0 +1,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/**");
}
}
@@ -1,61 +0,0 @@
package com.yunhai.flowcenter.controller.api;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.model.BoaUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* 外观偏好同源读代理(oapi 代理轮 2026-10-08,全端统一形态)。
*
* <p>浏览器(通道 init 异步段)只与本应用【同源】取数——CAS 会话天然有效,无需 iframe 引导;
* 后端经 open-gateway 签名直调 UserHome(target=userhome,寻址走 yh.open.services,
* 勿写死 host)。设置入口收敛在 UserHome 设置中心,本端点【只读不写】。</p>
*
* <p>契约 = UserHome /oapi/appearance 出参【整体透传】(JsonNode 哑管道):字段加减只在
* UserHome + 通道 init 两端演进,本应用零改动。现行字段 {@code {theme, avatar, logoUrl}}——
* theme/avatar 为服务端合成后的【生效值】(个人→学校→平台级联,不拆 my/sch);
* null=未配置,init 侧清对应本地键回落默认观感。</p>
*
* <p>降级铁律:UserHome/网关不可达时【必须】以非 2xx 返回(503),绝不能伪装成 200+全 null——
* init 会把全 null 收敛成清键,等于 UserHome 停服一次就清空全员本地观感。</p>
*/
@RestController
@RequestMapping(value = "/api")
public class AppearanceProxyController {
private static final Logger log = LoggerFactory.getLogger(AppearanceProxyController.class);
/** open_api_target 注册表中的 userhome;寻址 yh.call.targets → yh.open.services → 枚举兜底 */
private static final String TARGET_USERHOME = "userhome";
private final YschOpenCall openCall;
public AppearanceProxyController(YschOpenCall openCall) {
this.openCall = openCall;
}
@GetMapping("appearance")
public JsonNode appearance(BoaUser authUserWithDept, HttpServletResponse response) throws IOException {
try {
Map<String, String> q = new HashMap<>();
q.put("userId", String.valueOf(authUserWithDept.getId())); // 服务端取登录人,不收前端参数
return openCall.get(TARGET_USERHOME, "/oapi/appearance", q, JsonNode.class);
} catch (Exception e) {
// 降级铁律:sendError 直写 503 绕过 GlobalExceptionHandler(否则被兜成 200+code:6 信封)
log.warn("[appearance] UserHome 外观偏好不可达(503,init 侧静默不动本地键)userId={}:{}",
authUserWithDept.getId(), e.toString());
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
return null;
}
}
}
@@ -6,12 +6,15 @@ import com.yunhai.flowcenter.entity.FlwCallbackReg;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.model.FlowModels;
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.service.FlowAdminGuard;
import com.yunhai.flowcenter.service.FlowCallbackAdminService;
import com.yunhai.flowcenter.service.FlowDefAdminService;
import com.yunhai.flowcenter.service.FlowDiagramService;
import com.yunhai.flowcenter.service.FlowMapAdminService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BasePageResult;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
@@ -43,19 +46,107 @@ public class FlowAdminController {
private final FlowAdminGuard adminGuard;
private final FlowDefAdminService defAdminService;
/**
* 表单中心目录客户端(v3/TASK_1)。
*
* <p><b>字段注入而不是加进构造参数</b>:本类的构造器已被 {@code FlowAdminInstanceHungTest} 与
* {@code FlowDiagramServiceTest} 逐位钉住,而那两份测试不在本任务的可改清单里——多一个入参
* 会让整个测试树编译不过。</p>
*/
@org.springframework.beans.factory.annotation.Autowired
private com.yunhai.flowcenter.service.FormCenterCatalogClient formCenterCatalogClient;
private final FlowMapAdminService mapAdminService;
private final FlowCallbackAdminService callbackAdminService;
private final com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService;
private final FlwInstanceRepository instanceRepository;
private final FlowDiagramService diagramService;
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
private final com.yunhai.flowcenter.service.FlowAppAdminService appAdminService;
private final com.yunhai.flowcenter.service.FlowDeptAdminService deptAdminService;
private final com.yunhai.flowcenter.repository.FlwRouteLogRepository routeLogRepository;
// ==================== 流程定义 ====================
@GetMapping("/def/list")
public List<FlwDef> defList(BoaUser user,
@RequestParam(value = "status", required = false) Integer status) {
return defAdminService.list(adminGuard.requireAdmin(user, null), status);
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "appKey", required = false) String appKey) {
return defAdminService.list(adminGuard.requireAdmin(user, null), status, appKey);
}
/** 应用清单("按应用筛选"下拉):本校 flw_def 已标注 ∪ 已注册回调的应用 */
@GetMapping("/def/appkeys")
public List<String> defAppKeys(BoaUser user) {
return defAdminService.distinctAppKeys(adminGuard.requireAdmin(user, null));
}
// ==================== 应用设置(2026-10-10 四议题) ====================
/**
* 应用管理列表:本校"可对接流程"白名单(flw_app)。附带一次学校池静默同步
* (UserBase list-by-school;不可达降级本地行)。开通过程在 UserBase 管理端做。
*/
@GetMapping("/app/list")
public List<com.yunhai.flowcenter.entity.FlwApp> appList(BoaUser user) {
return appAdminService.list(adminGuard.requireAdmin(user, null));
}
/** 开关某应用是否对接流程(停用后 defSave 归属应用校验拦截新引用) */
@PostMapping("/app/enable")
public void appEnable(BoaUser user,
@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
appAdminService.enable(schId,
body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id"))),
Boolean.parseBoolean(String.valueOf(body.get("enabled"))));
}
/** 应用辅助统计(2026-10-11 增强):appKey → 回调注册数(本校),应用设置页"对接深度"列 */
@GetMapping("/app/stats")
public Map<String, Long> appStats(BoaUser user) {
return appAdminService.stats(adminGuard.requireAdmin(user, null));
}
// ==================== 应用使用授权(T01) ====================
/**
* 使用授权管理面(字段注入:构造被既有单测逐位钉住,同 formCenterCatalogClient 先例)。
* 授权对象是稳定应用主体(UserBase appId),非 appKey 凭据;撤权只拦新发起。
*/
@org.springframework.beans.factory.annotation.Autowired
private com.yunhai.flowcenter.service.FlowUsageGrantAdminService usageGrantAdminService;
/** 某流程线的使用授权清单(含已撤销行,审计可见) */
@GetMapping("/usage-grant/list")
public List<com.yunhai.flowcenter.entity.FlwUsageGrant> usageGrantList(
BoaUser user, @RequestParam("flowGuid") String flowGuid) {
return usageGrantAdminService.listByFlow(adminGuard.requireAdmin(user, null), flowGuid);
}
/** 授予/重申使用授权(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")
@@ -63,14 +154,60 @@ public class FlowAdminController {
return defAdminService.get(adminGuard.requireAdmin(user, null), id);
}
/** 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿) */
/**
* 表单中心目录代理(v3/TASK_1):编辑器"关联表单"选择器的数据源,浏览器只打本应用同源接口,
* appKey/secret 不在前端出现。校域由 {@link FlowAdminGuard#requireAdmin} 从 CAS 会话派生,
* 前端没有可越界的 schId 入口;中心侧只回"已发布 ∩ 本应用可读"的表单,
* 没授权的表压根不出现(不是"看得见但选不了")。
*/
@GetMapping("/form-center/forms")
public com.yunhai.flowcenter.service.FormCenterCatalogClient.CatalogPage formCenterForms(
BoaUser user,
@RequestParam(value = "appCode", required = false) String appCode,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
return formCenterCatalogClient.listForms(adminGuard.requireAdmin(user, null),
appCode, keyword, page, pageSize);
}
/**
* 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;
* changeLog=升版本改动说明,仅新版本行落库,>500 截断;
* formUuid=关联表单定义 uuid(C1,2026-10-20):null=保持、空串=取消关联、发布后不可改改=升版;
* <b>formVersion=v3 确切版本(与 formUuid 成对,单传一个即拒;取消关联时同批清空)</b>。
*/
@PostMapping("/def/save")
public FlwDef defSave(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
Long id = body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id")));
return defAdminService.save(schId, id,
(String) body.get("code"), (String) body.get("name"),
(String) body.get("contentJson"), (String) body.get("baseGuid"));
(String) body.get("contentJson"), (String) body.get("baseGuid"),
(String) body.get("appKey"), (String) body.get("changeLog"),
(String) body.get("formUuid"),
parseFormVersion(body.get("formVersion")));
}
private static Integer parseFormVersion(Object value) {
if (value == null) return null;
try {
return Integer.valueOf(String.valueOf(value));
} catch (NumberFormatException e) {
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "formVersion 必须是正整数", e);
}
}
/**
* 元数据原地编辑(2026-10-10 议题2):只改 name/appKey 不升版本(version/flowGuid/status/contentJson
* 结构均不动,仅同步 contentJson 顶层 name);已发布亦可。结构改动请走编辑器升版本。
*/
@PostMapping("/def/updateMeta")
public FlwDef defUpdateMeta(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
return defAdminService.updateMeta(schId,
body.get("id") == null ? null : Long.valueOf(String.valueOf(body.get("id"))),
(String) body.get("name"), (String) body.get("appKey"));
}
@PostMapping("/def/publish/{id}")
@@ -94,6 +231,34 @@ public class FlowAdminController {
return defAdminService.versions(adminGuard.requireAdmin(user, null), flowGuid);
}
// ==================== 线级停发/恢复(T03) ====================
/** 线级停发:整条 flowGuid 停止新发起/预览;在途实例按原版本跑完不受影响 */
@PostMapping("/def/offlineLine")
public int defOfflineLine(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
return defAdminService.offlineLine(schId,
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
}
/** 线级恢复:只恢复线可用性,不改写任何版本状态或发布内容 */
@PostMapping("/def/enableLine")
public int defEnableLine(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
return defAdminService.enableLine(schId,
body.get("flowGuid") == null ? null : String.valueOf(body.get("flowGuid")));
}
/** 在途办件数(议题二④):flowGuids 逗号分隔,去重后最多 100;返回 flowGuid → 审批中办件数(无在途也返 0 键) */
@GetMapping("/def/inflight")
public Map<String, Long> defInflight(BoaUser user, @RequestParam("flowGuids") String flowGuids) {
Long schId = adminGuard.requireAdmin(user, null);
List<String> guids = java.util.Arrays.stream(flowGuids.split(","))
.map(String::trim).filter(s -> !s.isEmpty()).distinct().limit(100)
.collect(java.util.stream.Collectors.toList());
return defAdminService.inflightCounts(schId, guids);
}
/** 流程定义示意图(方案 §3.1):requireAdmin 后按 defId 任意版本出图(草稿可预览) */
@GetMapping("/def/diagram")
public FlowModels.DiagramVo defDiagram(BoaUser user, @RequestParam("id") Long id,
@@ -118,35 +283,100 @@ public class FlowAdminController {
.body(imageService.renderPng(diagramService.build(def, null, mode)));
}
// ==================== 办理人映射 ====================
// ==================== 科室管理(2026-10-10 四议题·议题3,取代 /map/* 映射 CRUD) ====================
@GetMapping("/map/list")
public List<FlwAssigneeMap> mapList(BoaUser user,
@RequestParam(value = "deptKey", required = false) String deptKey) {
return mapAdminService.listByDept(adminGuard.requireAdmin(user, null), deptKey);
/** 科室列表(编辑器 ctx.depts 同结构:deptKey=id 字符串 + 启用成员数) */
@GetMapping("/dept/list")
public List<com.yunhai.flowcenter.service.FlowDeptAdminService.DeptVo> deptList(BoaUser user) {
return deptAdminService.list(adminGuard.requireAdmin(user, null));
}
@PostMapping("/map/save")
public FlwAssigneeMap mapSave(BoaUser user, @RequestBody Map<String, Object> body) {
@PostMapping("/dept/create")
public com.yunhai.flowcenter.entity.FlwDept deptCreate(BoaUser user, @RequestBody Map<String, Object> body) {
return deptAdminService.create(adminGuard.requireAdmin(user, null), (String) body.get("deptName"));
}
@PostMapping("/dept/rename")
public com.yunhai.flowcenter.entity.FlwDept deptRename(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
return mapAdminService.save(schId,
(String) body.get("deptKey"), (String) body.get("deptName"),
body.get("userId") == null ? null : Integer.valueOf(String.valueOf(body.get("userId"))),
(String) body.get("userName"));
return deptAdminService.rename(schId, requireId(body.get("id")), (String) body.get("deptName"));
}
@PostMapping("/map/disable/{id}")
public void mapDisable(BoaUser user, @PathVariable("id") Long id) {
mapAdminService.disable(adminGuard.requireAdmin(user, null), id);
/** 停用/启用科室(停用=解析不出办理人、发布校验拦新引用;成员不动) */
@PostMapping("/dept/status")
public void deptStatus(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
deptAdminService.setStatus(schId, requireId(body.get("id")), boolVal(body.get("enabled")));
}
/** 启用映射(停用行的恢复入口;DEPT_KEY 解析只取启用行) */
@PostMapping("/map/enable/{id}")
public FlwAssigneeMap mapEnable(BoaUser user, @PathVariable("id") Long id) {
return mapAdminService.enable(adminGuard.requireAdmin(user, null), id);
/** 删除科室(级联删成员;被流程引用的 deptKey 将解析失败——发布校验/办理时会拦) */
@PostMapping("/dept/delete")
public void deptDelete(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
deptAdminService.delete(schId, requireId(body.get("id")));
}
/** 成员检索(UserBase /oapi/user/byId) */
@GetMapping("/dept/members")
public List<com.yunhai.flowcenter.entity.FlwDeptMember> deptMembers(BoaUser user,
@RequestParam("deptId") Long deptId) {
return deptAdminService.members(adminGuard.requireAdmin(user, null), deptId);
}
/** 批量加成员(已存在跳过;姓名经 UserBase 补全),返回新增数 */
@PostMapping("/dept/memberAdd")
public int deptMemberAdd(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
List<Integer> userIds = new java.util.ArrayList<>();
Object raw = body.get("userIds");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (o != null) {
userIds.add(Integer.valueOf(String.valueOf(o)));
}
}
}
return deptAdminService.memberAdd(schId, requireId(body.get("deptId")), userIds);
}
@PostMapping("/dept/memberRemove")
public void deptMemberRemove(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null);
deptAdminService.memberRemove(schId, requireId(body.get("memberId")));
}
/**
* 一键拉取教师组·预览:学校子树内教师组容器 → 科室+组内成员(UserBase 组 id 即将落
* flw_dept.source_group_id)。导入前先展示给管理员确认,不落库。
*/
@GetMapping("/dept/groupPreview")
public List<com.yunhai.flowcenter.service.FlowMapAdminService.GroupDeptVo> groupPreview(BoaUser user) {
return mapAdminService.previewGroupDepts(adminGuard.requireAdmin(user, null));
}
/** 一键拉取教师组·导入:upsert flw_dept(source_group_id 幂等,重复拉取=刷新组名与成员集) */
@PostMapping("/dept/importGroups")
public com.yunhai.flowcenter.service.FlowDeptAdminService.GroupImportResultVo importGroups(BoaUser user) {
return deptAdminService.importGroups(adminGuard.requireAdmin(user, null));
}
private static Long longVal(Object o) {
return o == null ? null : Long.valueOf(String.valueOf(o));
}
/** 科室/成员行 id 缺失=调用方bug/脏请求:回业务错提示,不让 findById(null) 炸成笼统 500 */
private static Long requireId(Object o) {
Long v = longVal(o);
if (v == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少 id");
}
return v;
}
private static boolean boolVal(Object o) {
return o == null ? false : Boolean.parseBoolean(String.valueOf(o));
}
/** 成员检索(UserBase /oapi/user/byId;编辑器选人/姓名补全共用,非映射 CRUD,保留) */
@GetMapping("/map/lookupUser")
public com.yunhai.platform.utils.model.BoaUser lookupUser(BoaUser user,
@RequestParam("userId") Integer userId) {
@@ -154,21 +384,6 @@ public class FlowAdminController {
return mapAdminService.lookupUser(userId);
}
/**
* 一键拉取教师组·预览:学校子树内教师组容器 → 科室+组内成员(deptKey=UserBase 组 id)。
* 导入前先展示给管理员确认,不落库。
*/
@GetMapping("/map/groupPreview")
public List<com.yunhai.flowcenter.service.FlowMapAdminService.GroupDeptVo> groupPreview(BoaUser user) {
return mapAdminService.previewGroupDepts(adminGuard.requireAdmin(user, null));
}
/** 一键拉取教师组·导入:批量落映射(已存在且启用中的 sch+dept+user 跳过),返回计数 */
@PostMapping("/map/importGroups")
public com.yunhai.flowcenter.service.FlowMapAdminService.GroupImportResultVo importGroups(BoaUser user) {
return mapAdminService.importGroups(adminGuard.requireAdmin(user, null));
}
// ==================== 选人组件数据源 ====================
/** 选人组件树:学校组树(组→组内用户叶子),管理端「选择用户」弹窗共用 */
@@ -206,6 +421,16 @@ public class FlowAdminController {
return java.util.Collections.singletonMap("secret", secret);
}
/**
* 回调探测(2026-10-11 管理端增强):对该注册同步推送一条 flow.ping(未知事件码,接收方按
* 规范 §6.4 记日志后 200),返回 {success, httpStatus, elapsedMs, respSnippet, error};
* 结果同时落推送日志一行(event=flow.ping)。
*/
@PostMapping("/callback/probe/{id}")
public Map<String, Object> probe(BoaUser user, @PathVariable("id") Long id) {
return callbackAdminService.probe(adminGuard.requireAdmin(user, null), id);
}
@GetMapping("/callback/logs")
public BasePageResult<FlwCallbackLog> logs(BoaUser user,
@RequestParam(value = "status", required = false) Integer status,
@@ -242,6 +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 下拉与管理页共用) */
@@ -274,26 +516,86 @@ public class FlowAdminController {
/**
* 重跑路由(判断失败挂起的恢复入口,2026-09-24 §4.4):重新调判断服务,成功即按出口续走;
* 仍失败回业务错(办件继续挂起,route_log 已落新行)。
* useLatestDef=true(F5-4 僵尸逃生门):按该 flowGuid 当前已发布最高版本重跑并刷新办件定义快照。
*/
@PostMapping("/instance/{id}/reroute")
public void instanceReroute(BoaUser user, @PathVariable("id") Long id) {
routerAdminService.reroute(adminGuard.requireAdmin(user, null), id);
public void instanceReroute(BoaUser user, @PathVariable("id") Long id,
@RequestParam(value = "useLatestDef", required = false, defaultValue = "false")
boolean useLatestDef) {
routerAdminService.reroute(adminGuard.requireAdmin(user, null), id, useLatestDef);
}
/**
* 表达式调试求值(2026-10-10 四议题):body {expression, variablesJson},与运行时同一求值核心
* (ExpressionEvaluator);不落库、不走流程,发布前/挂起排查用。内置变量需在 variablesJson 里
* 自行提供样例值(线上是引擎注入的办件上下文)。
*/
@PostMapping("/router/eval")
public Map<String, Object> routerEval(BoaUser user, @RequestBody Map<String, Object> body) {
adminGuard.requireAdmin(user, null);
String expression = body.get("expression") == null ? "" : String.valueOf(body.get("expression")).trim();
String variablesJson = body.get("variablesJson") == null ? "" : String.valueOf(body.get("variablesJson")).trim();
Map<String, Object> result = new java.util.HashMap<>();
if (expression.isEmpty()) {
result.put("result", false);
result.put("error", "表达式为空");
return result;
}
Map<String, Object> env;
if (variablesJson.isEmpty()) {
env = new java.util.HashMap<>();
} else {
try {
env = objectMapper.readValue(variablesJson,
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {
});
} catch (Exception e) {
result.put("result", false);
result.put("error", "样例变量不是合法 JSON 对象:" + e.getMessage());
return result;
}
}
try {
Object value = com.yunhai.flowcenter.engine.ExpressionEvaluator.evaluate(expression, env);
result.put("result", Boolean.TRUE.equals(value));
result.put("value", String.valueOf(value));
} catch (Exception e) {
result.put("result", false);
result.put("error", String.valueOf(e.getMessage() != null ? e.getMessage() : e.toString()));
}
return result;
}
// ==================== 办件查询 ====================
/**
* 管理端办件分页(F11-4 增 hung 筛选与行级红标):hung=true 只看路由挂起办件
* (口径见 {@link com.yunhai.flowcenter.repository.FlwInstanceRepository#pageAdmin});
* 每行回填 {@code routeHung}(按页一次 {@code findHungInstanceIds} 查询,非逐行 N+1)。
*/
@GetMapping("/instance/page")
public BasePageResult<FlwInstance> instancePage(
BoaUser user,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "hung", required = false) Boolean hung,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
Long schId = adminGuard.requireAdmin(user, null);
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword,
Page<FlwInstance> p = instanceRepository.pageAdmin(schId, status, keyword, hung,
PageRequest.of(page == null || page < 1 ? 0 : page - 1,
size == null || size < 1 || size > 100 ? 10 : size,
Sort.by(Sort.Order.desc("id"))));
List<Long> pageIds = new java.util.ArrayList<>();
for (FlwInstance i : p.getContent()) {
pageIds.add(i.getId());
}
// 行级红标:不在挂起集合内的行显式置 false(前端只判 truthy,null 也行,但显式更可读)
java.util.Set<Long> hungIds = pageIds.isEmpty() ? java.util.Collections.emptySet()
: new java.util.HashSet<>(routeLogRepository.findHungInstanceIds(pageIds));
for (FlwInstance i : p.getContent()) {
i.setRouteHung(hungIds.contains(i.getId()));
}
int pi = page == null || page < 1 ? 1 : page;
int ps = size == null || size < 1 || size > 100 ? 10 : size;
return new BasePageResult<>(p.getContent(), (int) p.getTotalElements(), pi, ps,
@@ -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,37 +42,57 @@ public class FlowUserController {
private final FlowDiagramService diagramService;
private final com.yunhai.flowcenter.service.DiagramImageService imageService;
/** oapi 预览服务复用(T07 托管选人:候选+版本锚同源,字段注入防构造钉) */
@org.springframework.beans.factory.annotation.Autowired
private com.yunhai.flowcenter.service.OapiFlowService oapiFlowService;
/**
* 发起时指派预览(2026-10-25 T07 托管选人 iframe 数据源,F6 预置件点亮):
* CAS 会话取学校+提交人,复用 oapi applyPreview(含 T03 线级停发守卫)——
* 返回 {flowGuid, defVersion, nodes:[{nodeId,nodeName,countersign,selection,candidates}]}。
* <b>只读预览</b>:不建实例、不建任务、不落任何流程事实;正式提交仍走 apply 终审
* (前端候选不是授权凭据)。
*/
@GetMapping("/apply/preview")
public OapiModels.ApplyPreviewVo applyPreview(BoaUser user, @RequestParam("flowGuid") String flowGuid) {
return oapiFlowService.applyPreview(requireSchId(user), flowGuid, user.getId());
}
/** 我提交的办件 */
@GetMapping("/my")
public BasePageResult<FlowModels.InstanceItem> my(BoaUser user,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "appKey", required = false) String appKey,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
return queryService.pageMy(requireSchId(user), user.getId(), status, page, size);
return queryService.pageMy(requireSchId(user), user.getId(), status, appKey, page, size);
}
/** 我的待办(主路径,走 assignee 索引) */
@GetMapping("/todo")
public BasePageResult<FlowModels.TaskItem> todo(BoaUser user,
@RequestParam(value = "appKey", required = false) String appKey,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
return queryService.pageTodo(requireSchId(user), user.getId(), page, size);
return queryService.pageTodo(requireSchId(user), user.getId(), appKey, page, size);
}
/** 我的已办 */
@GetMapping("/done")
public BasePageResult<FlowModels.TaskItem> done(BoaUser user,
@RequestParam(value = "appKey", required = false) String appKey,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
return queryService.pageDone(requireSchId(user), user.getId(), page, size);
return queryService.pageDone(requireSchId(user), user.getId(), appKey, page, size);
}
/** 我的抄送(NOTIFY 节点落库;知会不占待办,独立页签) */
@GetMapping("/cc/my")
public BasePageResult<FlowModels.CcItem> ccMy(BoaUser user,
@RequestParam(value = "appKey", required = false) String appKey,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size) {
return queryService.pageCc(requireSchId(user), user.getId(), page, size);
return queryService.pageCc(requireSchId(user), user.getId(), appKey, page, size);
}
/** 办件详情(提交人/办理人/本校管理员可见) */
@@ -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) {
@@ -54,8 +64,33 @@ public class OapiFlowController {
@PostMapping("/apply")
public OapiModels.ApplyResultVo apply(@RequestParam("schId") Long schId,
@RequestAttribute(value = "yh.app.auth", required = false) String appKey,
@RequestBody OapiModels.ApplyBody body) {
return flowService.apply(schId, body);
return flowService.apply(schId, appKey, body);
}
/**
* 带请求标识/轮次守卫的代发起(v2 §8,TASK_2 C.3)。
*
* <p>与 {@code /apply} 是<b>两条显式分支</b>而不是一个端点加 flag:旧端点行为逐字不变
* (同 bizKey 命中 30/40 仍按 R4 重启推进),新端点声明"我带幂等守卫"——
* 同 {@code requestToken} 重投只回读现状,{@code retryOnly} 命中终态也不重启,
* 陈旧轮次拒绝重启。响应多带一个稳定分支码 {@code branch},消费方按码处置。
* 请求标识与轮次由消费方的持久化出站命令携带(svc-hall {@code flow_apply_command})。</p>
*/
@PostMapping("/apply/v2")
public FlowApplyRetryModels.ApplyResultV2 applyV2(@RequestParam("schId") Long schId,
@RequestAttribute(value = "yh.app.auth",
required = false) String appKey,
@RequestBody FlowApplyRetryModels.ApplyRequestV2 body) {
return flowService.applyV2(schId, appKey, body);
}
/** 发起预览:返回开启发起时指派的节点与候选人(已剔除发起人本人),供子应用提交页选人 */
@GetMapping("/apply/preview")
public OapiModels.ApplyPreviewVo applyPreview(@RequestParam("schId") Long schId,
@RequestParam("flowGuid") String flowGuid, @RequestParam("submitterId") Integer submitterId) {
return flowService.applyPreview(schId, flowGuid, submitterId);
}
@GetMapping("/instance")
@@ -100,8 +135,9 @@ public class OapiFlowController {
@RequestParam(value = "bizType", required = false) String bizType,
@RequestParam(value = "bizId", required = false) String bizId,
@RequestParam(value = "mode", required = false) String mode,
@RequestParam(value = "taskId", required = false) Long taskId,
@RequestParam("schId") Long schId) {
return flowService.embedUrl(schId, instanceId, bizType, bizId, mode);
return flowService.embedUrl(schId, instanceId, bizType, bizId, mode, taskId);
}
/**
@@ -123,6 +159,16 @@ public class OapiFlowController {
return flowService.registerCallback(schId, appKey, body);
}
/**
* 判断服务登记(2026-10-12 SERVICE 收口):由 {@code yh-flow-callback-spring-boot-starter} 启动自注册调用。
* 无 schId——{@code flw_router} 是全局表;归属即验签后的 appKey(登记即持有,跨应用覆盖拒绝)。
*/
@PostMapping("/router/reg")
public void registerRouter(@RequestAttribute(value = "yh.app.auth", required = false) String appKey,
@RequestBody OapiModels.RouterRegBody body) {
routerAdminService.registerByApp(appKey, body);
}
// ===== 应用内审批(第三步):查询 + 办理 =====
/** 我的待办(oapi):assigneeId 维度分页,bizType 可选过滤 */
@@ -136,6 +182,49 @@ public class OapiFlowController {
return flowService.pageTodoTasks(schId, assigneeId, bizType, page, size);
}
/**
* 按业务单据查当前人待办(v2.1 业务详情卡片数据源):与 tasks/todo 互补——那个是"人→单据",
* 这个是"单据→我的待办"。0/1/N 三种计数都由 HTTP 200 + tasks 数组表达,办件不存在与
* 他人待办同样出空(不用于探测)。operatorId 由子应用后端从 CAS 会话取,前端不可传。
*/
@GetMapping("/tasks/by-biz")
public OapiModels.ByBizTasksVo tasksByBiz(
@RequestParam("schId") Long schId,
@RequestParam("bizType") String bizType,
@RequestParam("bizId") String bizId,
@RequestParam("operatorId") Integer operatorId) {
return flowService.tasksByBiz(schId, bizType, bizId, operatorId);
}
/**
* 我的已办(P3.5 应用内审批四页签):过滤接缝=<b>签名方应用身份</b>(d.appKey=验签 appKey),
* 不收调用方 bizType 裸透传——办件挂本应用定义即含(天然覆盖新旧 bizType 存量),共享定义
* 跨应用不泄漏(P2-02 裁决)。assigneeId 由子应用后端从 CAS 会话取。
*/
@GetMapping("/tasks/done")
public BasePageResult<FlowModels.TaskItem> tasksDone(
@RequestParam("schId") Long schId,
@RequestParam("assigneeId") Integer assigneeId,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "size", required = false) Integer size,
@RequestAttribute(value = "yh.app.auth", required = false) String appKey) {
return queryService.pageDone(schId, assigneeId, 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;
@@ -1,7 +1,7 @@
package com.yunhai.flowcenter.engine;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.flowcenter.entity.FlwDeptMember;
import com.yunhai.flowcenter.service.FlowDeptAdminService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.exception.ErrorCode;
@@ -14,15 +14,16 @@ import java.util.ArrayList;
import java.util.List;
/**
* 办理人解析(概览 §六):DEPT_KEY 查显式映射表(零跨服务依赖);USERS 定义直配,
* 姓名经 UserBase /oapi/user/byId 补全(失败降级空名不阻塞)。SUBMITTER_DEPT/FORM_FIELD 二期。
* 办理人解析(概览 §六):DEPT_KEY 查科室管理(flw_dept+flw_dept_member,2026-10-10 切源,
* 零跨服务依赖);USERS 定义直配。审批责任人统一走 D11 资格终审(存在+启用+校域);
* 仅 NOTIFY 抄送接收人保留宽容解析(姓名补全失败降级空名、不校验资格)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AssigneeResolver {
private final FlwAssigneeMapRepository mapRepository;
private final FlowDeptAdminService deptAdminService;
private final OpenApiUserClient userClient;
public List<AssigneeRef> resolve(Long schId, FlowDefModel.Node node) {
@@ -32,14 +33,31 @@ public class AssigneeResolver {
}
List<AssigneeRef> refs = new ArrayList<>();
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
List<FlwAssigneeMap> maps = mapRepository.findEnabled(schId, a.deptKey);
for (FlwAssigneeMap m : maps) {
// D11:科室启用 ≠ 成员都可办理——逐人走同一资格终审;不合格成员按既有
// “只取启用成员”口径剔除(local status 已在 resolveEnabled 过滤),并留日志。
List<String> rejected = new ArrayList<>();
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
try {
requireEligibleAssignee(schId, m.getUserId());
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
} catch (BizException e) {
if (e.getErrorCode() == ErrorCode.INTERNAL_SERVER_ERROR) {
throw e; // 权威源不可达≠成员不合格,不能降级成“无人可用”
}
rejected.add(m.getUserId() + "=" + e.getMessage());
log.warn("[flow-center] 科室成员资格校验不通过,跳过:schId={} deptKey={} userId={} reason={}",
schId, a.deptKey, m.getUserId(), e.getMessage());
}
}
if (refs.isEmpty() && !rejected.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"节点[" + node.id + "]无可用办理人:科室成员均不具备办理资格("
+ String.join(";", rejected) + ")");
}
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
if (a.userIds != null) {
for (Integer uid : a.userIds) {
refs.add(new AssigneeRef(uid, fetchName(uid)));
refs.add(new AssigneeRef(uid, requireEligibleAssigneeName(schId, uid)));
}
}
} else {
@@ -47,15 +65,35 @@ public class AssigneeResolver {
}
if (refs.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"节点[" + node.id + "]无可用办理人(映射可能已被停用)");
"节点[" + node.id + "]无可用办理人(科室可能已被停用或成员为空)");
}
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;
@@ -64,7 +102,7 @@ public class AssigneeResolver {
}
List<AssigneeRef> refs = new ArrayList<>();
if (FlowDefModel.ASSIGNEE_DEPT_KEY.equals(a.type)) {
for (FlwAssigneeMap m : mapRepository.findEnabled(schId, a.deptKey)) {
for (FlwDeptMember m : deptAdminService.resolveEnabled(schId, a.deptKey)) {
refs.add(new AssigneeRef(m.getUserId(), m.getUserName()));
}
} else if (FlowDefModel.ASSIGNEE_USERS.equals(a.type)) {
@@ -90,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;
}
}
@@ -42,6 +42,9 @@ public class ConditionRouteService {
*/
public String decide(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node) {
List<String> outlets = outletNames(model, node);
if (node.expressionJudge()) {
return decideByExpression(instance, model, node, outlets);
}
FlwRouter router = routerRepository.findByRouterKey(node.routerKey).orElse(null);
if (router == null || router.getStatus() == null || router.getStatus() != 1) {
log.error("[flow-center] 路由判断失败:routerKey={} 未注册或已停用(挂起):instance={} node={}",
@@ -50,7 +53,8 @@ public class ConditionRouteService {
}
RouteJudgeClient.JudgeBody body = new RouteJudgeClient.JudgeBody(
instance.getSchId(), instance.getId(), instance.getVersion(),
instance.getBizType(), instance.getBizId(), node.id, node.name,
instance.getBizType(), instance.getBizId(), instance.getSubmitterId(),
node.id, node.name,
node.routerKey, outlets, parseVariables(instance.getFormDataJson()));
try {
RouteJudgeClient.JudgeResult result = judgeClient.call(router, body);
@@ -60,7 +64,7 @@ public class ConditionRouteService {
return persistFailure(instance, node, outlets,
"返回出口未命中 outlets:" + result.outlet + ";raw=" + result.rawResponse);
}
persist(instance, node, result.outlet, result.rawResponse);
persist(instance, node, node.routerKey, result.outlet, result.rawResponse);
log.info("[flow-center] 路由判断命中:instance={} node={} router={} outlet={}",
instance.getId(), node.id, node.routerKey, result.outlet);
return result.outlet;
@@ -71,6 +75,54 @@ public class ConditionRouteService {
}
}
/** EXPRESSION 模式留痕的 router_key 快照(真实注册键不适用;列 NOT NULL) */
static final String EXPRESSION_ROUTER_KEY = "EXPRESSION";
/**
* EXPRESSION 模式(2026-10-10 四议题):按出边定义顺序对 condition 求值,首个 true 的出口名
* 即结果;逐边结果摘要落 raw_response。全 false / 求值异常 → 挂起,沿用 SERVICE 模式纪律
* (留痕 outlet=NULL + 告警 + 管理端重跑),不配默认出口。
*/
private String decideByExpression(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node,
List<String> outlets) {
Map<String, Object> env = builtinEnv(instance, node, parseVariables(instance.getFormDataJson()));
StringBuilder trace = new StringBuilder("EXPRESSION judge: ");
try {
for (FlowDefModel.Edge e : model.outEdges(node.id)) {
Object result = ExpressionEvaluator.evaluate(e.condition, env);
trace.append("[").append(e.name).append("] ").append(e.condition)
.append(" => ").append(result).append("; ");
if (Boolean.TRUE.equals(result)) {
int attempt = persist(instance, node, EXPRESSION_ROUTER_KEY, e.name, trace.toString());
log.info("[flow-center] 表达式判断命中:instance={} node={} outlet={} attempt={}",
instance.getId(), node.id, e.name, attempt);
return e.name;
}
}
log.error("[flow-center] 表达式全部未命中(挂起):instance={} node={} outlets={} trace={}",
instance.getId(), node.id, outlets, trace);
return persistFailure(instance, node, outlets, "表达式全部未命中:" + trace);
} catch (Exception e) {
log.error("[flow-center] 表达式求值异常(挂起):instance={} node={} {}",
instance.getId(), node.id, e.toString());
return persistFailure(instance, node, outlets, "表达式求值异常:" + e + ";" + trace);
}
}
/** 内置变量注入(EXPRESSION 求值 env;SERVICE 模式的 JudgeBody 同名字段一一对应) */
static Map<String, Object> builtinEnv(FlwInstance instance, FlowDefModel.Node node,
Map<String, Object> variables) {
variables.put("schId", instance.getSchId());
variables.put("instanceId", instance.getId());
variables.put("instanceVersion", instance.getVersion());
variables.put("bizType", instance.getBizType());
variables.put("bizId", instance.getBizId());
variables.put("nodeId", node.id);
variables.put("nodeName", node.name);
variables.put("submitterId", instance.getSubmitterId());
return variables;
}
/** CONDITION 出边名即判断契约的 outlets(发布期已保证非空唯一) */
private static List<String> outletNames(FlowDefModel model, FlowDefModel.Node node) {
List<String> names = new ArrayList<>();
@@ -97,19 +149,21 @@ public class ConditionRouteService {
private String persistFailure(FlwInstance instance, FlowDefModel.Node node, List<String> outlets,
String error) {
int attempt = persist(instance, node, null, truncate(error));
int attempt = persist(instance, node, node.expressionJudge() ? EXPRESSION_ROUTER_KEY : node.routerKey,
null, truncate(error));
alertSuspended(instance, node, outlets, error, attempt);
return null;
}
/** 落一行留痕(REQUIRES_NEW:外部调用已真实发生,不随外层回滚),返回本轮 attempt(告警 msgNo 用) */
private int persist(FlwInstance instance, FlowDefModel.Node node, String outlet, String raw) {
private int persist(FlwInstance instance, FlowDefModel.Node node, String routerKeyLabel,
String outlet, String raw) {
FlwRouteLog logRow = new FlwRouteLog();
logRow.setSchId(instance.getSchId());
logRow.setInstanceId(instance.getId());
logRow.setNodeId(node.id);
logRow.setNodeName(node.name);
logRow.setRouterKey(node.routerKey);
logRow.setRouterKey(routerKeyLabel);
logRow.setOutlet(outlet);
logRow.setRawResponse(raw);
return routeLogStore.append(logRow).getAttempt();
@@ -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, "流程定义缺少节点");
@@ -66,7 +67,7 @@ public final class DefValidator {
validateNotify(n, enabledDeptKeys, problems);
break;
case FlowDefModel.TYPE_CONDITION:
validateCondition(n, enabledRouterKeys, problems);
validateCondition(model, n, enabledRouterKeys, problems);
break;
case FlowDefModel.TYPE_PARALLEL_SPLIT:
case FlowDefModel.TYPE_PARALLEL_JOIN:
@@ -243,12 +244,37 @@ public final class DefValidator {
}
/**
* CONDITION 条件分支节点校验(2026-09-24 §4):routerKey 必填且已注册启用——运行时才发现
* 无效只会挂起,发布期就拦;出边=互斥分支,出口名即判断接口的 outlets 契约(命名/唯一/
* 同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
* CONDITION 条件分支节点校验(2026-09-24 §4):SERVICE 模式 routerKey 必填且已注册启用——
* 运行时才发现无效只会挂起,发布期就拦;出边=互斥分支,出口名即判断接口的 outlets 契约
* (命名/唯一/同分支上下文由度约束段复用 {@link #validateOutlets} 口径)。
*
* <p>EXPRESSION 模式(2026-10-10 四议题)分层校验:语法错/出口缺条件 → 发布期硬拦
* 新契约的变量引用/类型/值域由 VariableContract 统一检查;历史无声明版本保持兼容。
*/
private static void validateCondition(FlowDefModel.Node n, Set<String> enabledRouterKeys,
private static void validateCondition(FlowDefModel model, FlowDefModel.Node n, Set<String> enabledRouterKeys,
List<String> problems) {
if (n.judgeMode != null && !FlowDefModel.JUDGE_MODE_SERVICE.equals(n.judgeMode)
&& !FlowDefModel.JUDGE_MODE_EXPRESSION.equals(n.judgeMode)) {
problems.add(nodeLabel(n) + " 判断方式取值非法:" + n.judgeMode + "(支持 SERVICE/EXPRESSION)");
return;
}
if (n.expressionJudge()) {
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null || e.condition.trim().isEmpty()) {
problems.add(nodeLabel(n) + " 的出口「" + e.name
+ "」缺少条件表达式(EXPRESSION 模式每个出口必须配置)");
continue;
}
try {
warnBuiltinMisspelling(n, e);
ExpressionEvaluator.compile(e.condition.trim());
} catch (Exception ex) {
problems.add(nodeLabel(n) + " 的出口「" + e.name + "」表达式不合法:"
+ ex.getMessage());
}
}
return;
}
if (n.routerKey == null || n.routerKey.trim().isEmpty()) {
problems.add(nodeLabel(n) + " 缺少判断服务(routerKey)配置");
} else if (enabledRouterKeys != null && !enabledRouterKeys.contains(n.routerKey.trim())) {
@@ -257,6 +283,38 @@ public final class DefValidator {
}
}
/**
* 表达式内置变量白名单(ConditionRouteService.builtinEnv 注入的同名集)。<br>
* 2026-10-20 C1 起 {@link com.yunhai.flowcenter.service.FlowDefAdminService} 的键查①红牌
* 复用本集合(表达式变量 ⊆ 快照字段 ∪ 内置变量)——两处必须是同一份,内置集扩了这边自动跟上。
*/
public static final Set<String> EXPRESSION_BUILTINS = new HashSet<>(java.util.Arrays.asList(
"schId", "instanceId", "instanceVersion", "bizType", "bizId",
"nodeId", "nodeName", "submitterId"));
/**
* 内置变量疑似拼错提示(warn 不硬拦):变量名与某内置变量仅大小写不一致(如 schid)时
* 提示一次——拼错的表达式静默把 null 与数字比较,全部不命中挂起,很难排查。
*/
private static void warnBuiltinMisspelling(FlowDefModel.Node n, FlowDefModel.Edge e) {
try {
for (String v : ExpressionEvaluator.variableNames(e.condition.trim())) {
if (!EXPRESSION_BUILTINS.contains(v)) {
for (String b : EXPRESSION_BUILTINS) {
if (b.equalsIgnoreCase(v)) {
LOG.warn("[flow-center] 节点 {} 出口「{}」表达式变量 {} 疑似内置变量 {} 拼写错误",
n.id, e.name, v, b);
}
}
}
}
} catch (Exception ignore) {
// 语法错时拿不到变量名,交给 compile 路径报问题
}
}
private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(DefValidator.class);
private static void validateApproval(FlowDefModel.Node n, Set<String> enabledDeptKeys, List<String> problems) { FlowDefModel.Assignee a = n.assignee;
if (a == null || a.type == null) {
problems.add(nodeLabel(n) + " 缺少 assignee 配置");
@@ -282,6 +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,
@@ -0,0 +1,72 @@
package com.yunhai.flowcenter.engine;
import com.googlecode.aviator.AviatorEvaluator;
import com.googlecode.aviator.AviatorEvaluatorInstance;
import com.googlecode.aviator.Expression;
import com.googlecode.aviator.Feature;
import com.googlecode.aviator.Options;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 条件路由表达式求值核心(2026-10-10 四议题 Phase 4):CONDITION 节点 judgeMode=EXPRESSION 时,
* 出边 condition 即 AviatorScript 表达式,按出边定义顺序取首个求值为 true 的出口。
* 发布期校验(DefValidator)、运行时(ConditionRouteService)、调试端点(/admin/router/eval)
* 共用本类——三处口径天然一致。
*
* <p><b>沙箱纪律(勿放宽)</b>:FEATURE_SET 只留 If(三元/条件),NewInstance/Module/Use/
* Static* 全部不在集合内即禁用;ALLOWED_CLASS_SET=空不可变集 = 禁一切 Java 类访问(反射/静态
* 方法/类字面量);表达式只能引用变量与 Aviator 内置纯函数(math/string/seq)。
*
* <p><b>缺失变量口径(宁挂起不误放行)</b>:顶层变量缺失(拼写错/表单未传)直接抛错由调用方
* 挂起——Aviator 的 nil 参与大小比较时视作最小值,{@code x <= n} 这类兜底出口会对缺失字段
* <i>静默放行</i>,必须拦住。判空请显式写 {@code x == nil}(嵌套属性缺失走 nil 语义,
* {@code form.amount == nil} 可用)。env 中显式为 null 的值仍按 nil 语义求值。
*
* <p>线程安全:{@link AviatorEvaluatorInstance} 本身线程安全,编译结果带缓存(key=表达式串)。
*/
public final class ExpressionEvaluator {
private ExpressionEvaluator() {
}
private static final AviatorEvaluatorInstance INSTANCE = createSandboxed();
private static AviatorEvaluatorInstance createSandboxed() {
AviatorEvaluatorInstance instance = AviatorEvaluator.newInstance();
instance.setOption(Options.FEATURE_SET, Feature.asSet(Feature.If));
instance.setOption(Options.ALLOWED_CLASS_SET, Collections.emptySet());
instance.setOption(Options.NIL_WHEN_PROPERTY_NOT_FOUND, Boolean.TRUE);
return instance;
}
/**
* 求值(编译缓存复用)。顶层变量缺失直接抛错(调用方挂起)——Aviator 对缺失顶层变量一律
* 按 nil 求值且不可配置,而 nil 视作最小值会让 {@code x <= n} 这类兜底出口静默放行。
* 嵌套属性(form.amount)缺失走 nil 语义,判空请显式 {@code form.amount == nil}。
*
* @return 求值结果;调用方仅把 Boolean.TRUE 视为命中(null/非布尔一律不命中——宁挂起不误放行)
*/
public static Object evaluate(String expression, Map<String, Object> env) {
Expression compiled = compile(expression);
for (String name : compiled.getVariableNames()) {
if (!env.containsKey(name)) {
throw new IllegalStateException("表达式变量未提供:" + name
+ "(拼写错误或表单未传;宁挂起不误放行,不按 nil 求值)");
}
}
return compiled.execute(env);
}
/** 语法校验/预编译(发布期拦语法错;与运行时走同一编译路径) */
public static Expression compile(String expression) {
return INSTANCE.compile(expression, true);
}
/** 表达式引用的顶层变量名(发布期"内置变量疑似拼错"提示用) */
public static List<String> variableNames(String expression) {
return compile(expression).getVariableNames();
}
}
@@ -1,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); }
}
@@ -30,6 +30,11 @@ public class FlowDefModel {
*/
public static final String TYPE_CONDITION = "CONDITION";
/** CONDITION 判断方式(2026-10-10 四议题):外部判断服务(缺省=旧口径,routerKey 寻址) */
public static final String JUDGE_MODE_SERVICE = "SERVICE";
/** CONDITION 判断方式:内置表达式引擎(出边 condition 即 AviatorScript 表达式) */
public static final String JUDGE_MODE_EXPRESSION = "EXPRESSION";
/** 会签模式 */
public static final String COUNTERSIGN_ALL = "ALL";
public static final String COUNTERSIGN_ANY = "ANY";
@@ -41,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 → 左上角),仅前端设计器/查看器复用;引擎不消费。
* 缺失时前端自行做自动布局,故可选、可空。
@@ -63,12 +84,21 @@ public class FlowDefModel {
public Assignee receivers;
/** CONDITION 判断服务注册键(flw_router.router_key,发布期校验须已注册且启用) */
public String routerKey;
/** CONDITION 判断方式:SERVICE(缺省,routerKey 外部服务)|EXPRESSION(出边 condition 表达式) */
public String judgeMode;
/** 是否表达式判断(null 视作 SERVICE,旧 contentJson 无此字段天然走服务口径) */
public boolean expressionJudge() {
return JUDGE_MODE_EXPRESSION.equals(judgeMode);
}
/** ALL(默认)|ANY */
public String countersign;
/** PARALLEL_JOIN 汇聚条件,一期仅 ALL */
public String waitFor;
/** 办理时限小时数(自然时间,任务 arrive_time + N 小时;可空) */
public Integer deadlineHours;
/** 发起时指派策略(2026-10-13 方案):存在即允许指派;缺省/缺失=不允许(向前兼容) */
public AssignPolicy assignPolicy;
}
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -79,6 +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 留痕并挂起)。
@@ -38,13 +39,14 @@ public class RouteJudgeClient {
this.objectMapper = objectMapper;
}
/** 判断请求体(契约冻结:query 带 schId,业务方按 outlets 返回 {outlet}) */
/** 判断请求体(契约冻结:query 带 schId,业务方按 outlets 返回 {outlet};submitterId 2026-10-10 增补,老实现忽略即可) */
public static class JudgeBody {
public final Long schId;
public final Long instanceId;
public final Integer instanceVersion;
public final String bizType;
public final String bizId;
public final Integer submitterId;
public final String nodeId;
public final String nodeName;
public final String routerKey;
@@ -52,13 +54,14 @@ public class RouteJudgeClient {
public final Map<String, Object> variables;
public JudgeBody(Long schId, Long instanceId, Integer instanceVersion, String bizType, String bizId,
String nodeId, String nodeName, String routerKey, List<String> outlets,
Map<String, Object> variables) {
Integer submitterId, String nodeId, String nodeName, String routerKey,
List<String> outlets, Map<String, Object> variables) {
this.schId = schId;
this.instanceId = instanceId;
this.instanceVersion = instanceVersion;
this.bizType = bizType;
this.bizId = bizId;
this.submitterId = submitterId;
this.nodeId = nodeId;
this.nodeName = nodeName;
this.routerKey = routerKey;
@@ -119,14 +122,29 @@ public class RouteJudgeClient {
throw new JudgeUnavailableException("判断服务调用失败(已重试 " + MAX_ATTEMPTS + " 次)", last);
}
/** 目标解析:目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),判断专用 3s 超时 */
/**
* 目标解析:
* <ul>
* <li>{@code direct_host} 非空(2026-10-12 starter 自注册行)→ {@link TargetSpec#fromUrl(String)}
* 拆基址直连,<b>跳过服务目录三级解析</b>(service_key/base_path 手工核对坑消失);</li>
* <li>否则(存量手工行):目录三级 + flw_router.base_path 覆盖(空=沿用目录条目前缀),行为逐字不变。</li>
* </ul>
* 两分支同设判断专用 3s 超时。
*/
private TargetSpec resolveTarget(FlwRouter router) {
String directHost = router.getDirectHost();
if (directHost != null && !directHost.trim().isEmpty()) {
return withJudgeTimeout(TargetSpec.fromUrl(directHost.trim()));
}
TargetSpec resolved = openCall.resolveTarget(router.getServiceKey());
String basePath = resolved.getBasePath();
if (router.getBasePath() != null && !router.getBasePath().trim().isEmpty()) {
basePath = router.getBasePath().trim();
}
TargetSpec spec = TargetSpec.of(resolved.getBaseHost(), basePath);
return withJudgeTimeout(TargetSpec.of(resolved.getBaseHost(), basePath));
}
private static TargetSpec withJudgeTimeout(TargetSpec spec) {
spec.setConnectTimeoutMs((long) TIMEOUT_MS);
spec.setReadTimeoutMs((long) TIMEOUT_MS);
return spec;
@@ -0,0 +1,209 @@
package com.yunhai.flowcenter.engine;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** D10 定义与发起共用的纯校验器;不转换输入,不依赖 FC/数据库。 */
public final class VariableContract {
private VariableContract() { }
private static final Set<String> TYPES = new HashSet<>(Arrays.asList(
"string", "number", "boolean", "date", "datetime", "money", "stringArray", "fileArray", "userArray"));
private static final String TOKEN = "(?:'(?:[^'\\\\]|\\\\.)*'|\"(?:[^\"\\\\]|\\\\.)*\"|-?\\d+(?:\\.\\d+)?|[A-Za-z_][A-Za-z0-9_]*)";
// 先分词以跳过完整字符串;不把函数参数字符串中的 "amount > 3" 当成比较。
private static final Pattern TOKENS = Pattern.compile(TOKEN + "|==|!=|>=|<=|>|<|\\S");
private static final Set<String> COMPARISONS = new HashSet<>(Arrays.asList("==", "!=", ">=", "<=", ">", "<"));
public static Set<String> referencedKeys(FlowDefModel model) {
Set<String> keys = new LinkedHashSet<>();
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !n.expressionJudge()) continue;
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null || e.condition.trim().isEmpty()) continue;
try {
for (String key : ExpressionEvaluator.variableNames(e.condition.trim())) {
if (!DefValidator.EXPRESSION_BUILTINS.contains(key)) keys.add(key);
}
} catch (RuntimeException ex) {
// 历史版本保持原有语法异常跳过口径;新契约必须 fail closed。
if (model.variables != null) fail("表达式不合法:" + ex.getMessage());
}
}
}
return keys;
}
public static void validateDefinition(FlowDefModel model, boolean publishing) {
if (model.variables == null) {
if (publishing) fail("新发布版本必须声明 variables(无变量请显式声明 [])");
return;
}
Map<String, FlowDefModel.Variable> fields = new LinkedHashMap<>();
for (FlowDefModel.Variable v : model.variables) {
if (v == null || v.key == null || !v.key.matches("[A-Za-z0-9_]{1,64}")) fail("变量 key 不合法");
if (DefValidator.EXPRESSION_BUILTINS.contains(v.key)) fail("变量不能占用内置键:" + v.key);
if (fields.put(v.key, v) != null) fail("变量 key 重复:" + v.key);
if (!TYPES.contains(v.type)) fail("变量类型不支持:" + v.key + " / " + v.type);
if (v.enumValues != null) {
if (!"string".equals(v.type) && !"stringArray".equals(v.type)) fail("enum 仅适用于字符串及字符串数组:" + v.key);
if (v.enumValues.isEmpty() || v.enumValues.contains(null)
|| new HashSet<>(v.enumValues).size() != v.enumValues.size()) fail("enum 值域为空或重复:" + v.key);
for (String option : v.enumValues) {
if (blank(option)) fail("enum 值域不能含空白:" + v.key);
validateValue(v, "stringArray".equals(v.type) ? Collections.singletonList(option) : option, true);
}
}
}
for (String key : referencedKeys(model)) {
if (!fields.containsKey(key)) fail("表达式变量 " + key + " 未声明(不在关联表单字段集内或 variables 中)");
}
for (FlowDefModel.Node n : model.nodes) {
if (n == null || !n.expressionJudge()) continue;
for (FlowDefModel.Edge e : model.outEdges(n.id)) {
if (e.condition == null) continue;
Matcher m = TOKENS.matcher(e.condition);
List<String> tokens = new ArrayList<>();
while (m.find()) tokens.add(m.group());
for (int i = 1; i + 1 < tokens.size(); i++) {
if (!COMPARISONS.contains(tokens.get(i))) continue;
checkLiteral(fields.get(tokens.get(i-1)), tokens.get(i+1));
checkLiteral(fields.get(tokens.get(i+1)), tokens.get(i-1));
}
}
}
}
private static void checkLiteral(FlowDefModel.Variable field, String token) {
if (field == null || "null".equals(token)) return;
Object value;
if (token.startsWith("'") || token.startsWith("\"")) {
// Aviator 的转义交由编译器;带转义字面量不做正则域推断。
if (token.indexOf('\\') >= 0) return;
value = token.substring(1, token.length() - 1);
} else if ("true".equals(token) || "false".equals(token)) value = Boolean.valueOf(token);
else if (token.matches("-?\\d+(\\.\\d+)?")) value = new BigDecimal(token);
else return;
try { validateValue(field, value, true); }
catch (BizException invalid) { fail("类型桶查②/域查③:" + invalid.getMessage()); }
}
public static void validateInput(FlowDefModel model, Map<String, Object> submitted) {
Set<String> refs = referencedKeys(model);
if (model.variables == null) {
for (String key : refs) {
if (!submitted.containsKey(key) || submitted.get(key) == null) fail("流程条件所需变量缺失或为空(key=" + key + ")");
}
return;
}
validateDefinition(model, false);
Set<String> declared = new HashSet<>();
for (FlowDefModel.Variable v : model.variables) {
declared.add(v.key);
validateValue(v, submitted.get(v.key), Boolean.TRUE.equals(v.required) || refs.contains(v.key));
}
for (String key : submitted.keySet()) if (!declared.contains(key)) fail("未声明的变量:" + key);
}
/** 必填/表达式引用均需实值;可选 null 可省略,其他非 null 值始终按类型与值域检查。 */
private static void validateValue(FlowDefModel.Variable v, Object value, boolean required) {
if (value == null) {
if (required) fail("变量缺失或为空:" + v.key);
return;
}
if (required && ((value instanceof String && blank((String) value))
|| (value instanceof Collection && ((Collection<?>) value).isEmpty()))) fail("变量未填写:" + v.key);
// FC 文本/单选和日期控件的清空值为 "";仅非必填且未被表达式引用时视作未填。
// 数值控件空值是 null,不能借空字符串绕过 number/money/boolean/数组的类型检查。
if (!required && "".equals(value) && Arrays.asList("string", "date", "datetime").contains(v.type)) return;
boolean valid = false;
switch (v.type) {
case "string": valid = value instanceof String; break;
case "number": case "money":
if (value instanceof Number) {
try { new BigDecimal(value.toString()); valid = true; } catch (NumberFormatException ignored) { }
}
break;
case "boolean": valid = value instanceof Boolean; break;
case "date":
if (value instanceof String && ((String)value).matches("\\d{4}-\\d{2}-\\d{2}")) {
try { LocalDate.parse((String)value); valid = true; } catch (RuntimeException ignored) { }
}
break;
case "datetime":
if (value instanceof String && ((String)value).matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})?")) {
try { LocalDateTime.parse((String)value); valid = true; }
catch (RuntimeException local) {
try { OffsetDateTime.parse((String)value); valid = true; } catch (RuntimeException ignored) { }
}
}
break;
case "stringArray": case "fileArray": case "userArray":
valid = value instanceof List;
if (valid) for (Object item : (List<?>)value) {
if ("stringArray".equals(v.type)) {
if (!(item instanceof String) || (v.enumValues != null && !v.enumValues.contains(item))) valid = false;
} else if (!validRecord(item, "fileArray".equals(v.type))) valid = false;
}
break;
default: break;
}
if (!valid) fail("变量类型/格式不合法:" + v.key + "(应为 " + v.type + ")");
if (value instanceof String && v.enumValues != null && !v.enumValues.contains(value)) fail("变量 " + v.key + " 不在枚举值域 " + v.enumValues + ":" + value);
}
private static boolean validRecord(Object value, boolean file) {
if (!(value instanceof Map)) return false;
Map<?, ?> row = (Map<?, ?>)value;
Object id = row.get("id");
try {
if (!(id instanceof Number) || new BigDecimal(id.toString()).scale() > 0 || ((Number)id).longValue() <= 0) return false;
} catch (NumberFormatException invalid) { return false; }
return file ? nonblank(row.get("fileName")) && nonblank(row.get("key")) : nonblank(row.get("name"));
}
private static boolean nonblank(Object value) { return value instanceof String && !blank((String)value); }
private static boolean blank(String value) {
return value.codePoints().allMatch(c -> Character.isWhitespace(c) || Character.isSpaceChar(c));
}
/** FC 快照是唯一派生来源,保存时传入的 variables 不能覆盖绑定表单的契约。 */
public static List<FlowDefModel.Variable> fromSnapshot(Map<String, Object> snapshot) {
Object fields = snapshot.get("fields");
if (!(fields instanceof List)) fail("formContract.fields 必须为数组");
List<FlowDefModel.Variable> variables = new ArrayList<>();
for (Object item : (List<?>)fields) {
if (!(item instanceof Map)) fail("formContract 字段不合法");
Map<?, ?> field = (Map<?, ?>)item;
FlowDefModel.Variable v = new FlowDefModel.Variable();
v.key = (String)field.get("key");
v.required = Boolean.TRUE.equals(field.get("required"));
String type = String.valueOf(field.get("type"));
switch (type) {
case "text": case "textarea": case "phone": case "select": case "radio": v.type = "string"; break;
case "number": case "date": v.type = type; break;
case "checkbox": case "multiselect": case "address": v.type = "stringArray"; break;
case "image": case "upload": v.type = "fileArray"; break;
case "user": case "users": v.type = "userArray"; break;
default: fail("FC 字段类型不支持:" + v.key + " / " + type);
}
if (Arrays.asList("select", "radio", "checkbox", "multiselect").contains(type)) {
Object enums = field.get("enum");
if (!(enums instanceof List) || ((List<?>)enums).isEmpty()) fail("FC 枚举值域缺失:" + v.key);
v.enumValues = new ArrayList<>();
for (Object option : (List<?>)enums) {
if (!(option instanceof String)) fail("FC 枚举必须为字符串:" + v.key);
v.enumValues.add((String)option);
}
}
variables.add(v);
}
return variables;
}
private static void fail(String message) { throw new BizException(ErrorCode.PARAM_ERROR, message); }
}
@@ -0,0 +1,107 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.Version;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 待推进意图(2026-10-25 T05):任务办理事务内持久化"这个节点该继续推进了"的事实,
* 事务外 worker 领取执行——办理事务提交成功 ⇔ 推进意图已持久化,进程崩溃/重启后
* 扫描续跑(旧链路 AFTER_COMMIT 三次内存重试,耗尽只 log.error,重启即丢)。
*
* <p>D09:每个激活批次记录精确任务成员,未办理时以 DONE 等待;办理同事务串行刷新
* 本批次的末笔任务及 PENDING 工作。worker 按 id+revision 锁定当前读,推进与结算同事务;
* NULL 代次只允许明确接棒或留待有证据的人工回填,不作为本代放行。</p>
*/
@Getter
@Setter
@Entity
@Table(name = "flw_advance_intent",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_advance_activation", columnNames = "activation_task_id"),
indexes = {
@Index(name = "idx_flw_advance_intent_due", columnList = "status, next_retry_at"),
@Index(name = "idx_flw_advance_intent_instance", columnList = "instance_id")})
public class FlwAdvanceIntent extends BaseEntity {
public static final String STATE_PENDING = "PENDING";
public static final String STATE_DONE = "DONE";
public static final String STATE_EXHAUSTED = "EXHAUSTED";
@Column(name = "instance_id", nullable = false)
private Long instanceId;
/** 触发推进的节点(TASK_HANDLED 的 nodeId) */
@Column(name = "node_id", nullable = false, length = 64)
private String nodeId;
/** First task CREATED in this activation batch; never the last task to finish. NULL = unanchored legacy work. */
@Column(name = "activation_task_id")
private Long activationTaskId;
/** Exact membership, including transfers; comma-delimited positive task IDs, e.g. ,12,14,. */
@Column(name = "activation_task_ids", columnDefinition = "LONGTEXT")
private String activationTaskIds;
@Column(name = "handled_task_ids", columnDefinition = "LONGTEXT")
private String handledTaskIds;
@Column(name = "trigger_task_id")
private Long triggerTaskId;
@Column(name = "activation_def_id")
private Long activationDefId;
@Column(name = "activation_def_version")
private Integer activationDefVersion;
@Column(name = "activation_apply_round")
private Integer activationApplyRound;
/** A detached worker may act/defer only the revision it actually observed. */
@Version
@Column(name = "revision", nullable = false)
private Long revision = 0L;
public boolean containsTask(Long id) { return contains(activationTaskIds, id); }
public boolean handledTask(Long id) { return contains(handledTaskIds, id); }
public void addTask(Long id) { if (!containsTask(id)) activationTaskIds = append(activationTaskIds, id); }
public void addHandledTask(Long id) { if (!handledTask(id)) handledTaskIds = append(handledTaskIds, id); }
private static boolean contains(String ids, Long id) { return ids != null && id != null && ids.contains("," + id + ","); }
private static String append(String ids, Long id) {
if (id == null || id <= 0) throw new IllegalArgumentException("Missing activation task identity");
return (ids == null ? "," : ids) + id + ",";
}
/** 出边名(可空=引擎默认路由) */
@Column(name = "edge_name", length = 128)
private String edgeName;
/** 办理人随行(推进链操作人信封,可空) */
@Column(name = "assignee_id")
private Integer assigneeId;
@Column(name = "assignee_name", length = 64)
private String assigneeName;
/** PENDING=待执行;DONE=已执行(含推进判定为无需推进的幂等结论);EXHAUSTED=重试耗尽留人工 */
@Column(name = "status", nullable = false, length = 16)
private String status = STATE_PENDING;
@Column(name = "attempts", nullable = false)
private Integer attempts = 0;
/** 退避到期时间(耗尽前按指数退避续扫) */
@Column(name = "next_retry_at")
private LocalDateTime nextRetryAt;
@Column(name = "last_error", length = 255)
private String lastError;
}
@@ -0,0 +1,45 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 流程对接应用(2026-10-10 四议题·应用设置):本校"哪些应用可对接流程"的白名单。
* 行来源=应用设置页从学校池(UserBase /oapi/app/list-by-school,学校组单点已开通)同步,
* 同步即 enabled=1;管理员可停用(停用后 defSave 的归属应用校验拦截新引用)。
* app_name 是学校池快照(含学校别名),每次同步静默刷新。
*/
@Getter
@Setter
@Entity
@Table(name = "flw_app",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_app_sch_key", columnNames = {"sch_id", "app_key"}),
indexes = @Index(name = "idx_flw_app_sch_enabled", columnList = "sch_id, enabled"))
public class FlwApp extends BaseEntity {
/** 租户键 */
@Column(name = "sch_id", nullable = false)
private Long schId;
/** 子应用 appKey(UserBase application_info.app_key 的字符串形态,与 flw_def.app_key 同口径) */
@Column(name = "app_key", nullable = false, length = 64)
private String appKey;
/** 应用名快照(学校池返回值,含学校别名;同步时刷新) */
@Column(name = "app_name", nullable = false, length = 128)
private String appName;
/** 1=对接流程(白名单内);0=停用(defSave 校验拦截) */
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
/** 展示排序(学校池 orderNumber 快照,可空=0) */
@Column(name = "sort_no")
private Integer sortNo;
}
@@ -0,0 +1,24 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
/** Immutable settled command fact: ACCEPTED or REJECTED. Never repurpose a request ID. */
@Entity @Getter @Setter
@Table(name="flw_apply_command", uniqueConstraints=@UniqueConstraint(name="uk_flw_command_request",columnNames={"sch_id","source_principal_id","request_id"}),indexes=@Index(name="idx_flw_command_instance",columnList="instance_id"))
public class FlwApplyCommand extends BaseEntity {
@Column(name="sch_id",nullable=false) private Long schId;
@Column(name="source_principal_id",nullable=false,length=36) private String sourcePrincipalId;
@Column(name="request_id",nullable=false,length=64) private String requestId;
@Column(name="canonical_hash",nullable=false,length=64) private String canonicalHash;
@Column(name="canonical_version",nullable=false) private Integer canonicalVersion;
@Column(name="biz_type",nullable=false,length=32) private String bizType;
@Column(name="biz_id",nullable=false,length=64) private String bizId;
@Column(name="flow_guid",nullable=false,length=32) private String flowGuid;
@Column(name="state",nullable=false,length=16) private String state;
@Column(name="instance_id") private Long instanceId;
@Column(name="apply_round") private Integer round;
@Column(name="def_version") private Integer defVersion;
@Column(name="def_id") private Long defId;
@Column(name="reject_code",length=64) private String rejectCode;
@Column(name="reject_detail",length=500) private String rejectDetail;
}
@@ -0,0 +1,15 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
@Entity @Getter @Setter
@Table(name="flw_biz_coordinate", uniqueConstraints=@UniqueConstraint(name="uk_flw_coord", columnNames={"sch_id","source_principal_id","biz_type","biz_id"}))
public class FlwBizCoordinate extends BaseEntity {
@Column(name="sch_id",nullable=false) private Long schId;
@Column(name="source_principal_id",nullable=false,length=36) private String sourcePrincipalId;
@Column(name="biz_type",nullable=false,length=32) private String bizType;
@Column(name="biz_id",nullable=false,length=64) private String bizId;
@Column(name="last_round",nullable=false) private Integer lastRound = 0;
@Column(name="last_instance_id") private Long lastInstanceId;
@Column(name="active_instance_id") private Long activeInstanceId;
}
@@ -12,14 +12,16 @@ import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 回调推送日志 = 重试队列(概览 §4.9):eventId 唯一(重试不变,业务方按它幂等去重);
* 回调推送日志 = 重试队列(概览 §4.9;2026-10-25 R2 幂等键改复合):eventId 全订阅方复用
* (重试不变,业务方按它幂等去重),(event_id, reg_id) 复合唯一——同一事件对同一订阅方只建
* 一行,多订阅方各一行;并发 exists→insert 竞态由复合 uk 兜底(DuplicateKeyException=已建行)。
* payload_json 完整快照可重放;索引 (status, next_retry_time) 是扫描主路径。
*/
@Getter
@Setter
@Entity
@Table(name = "flw_callback_log",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event", columnNames = "event_id"),
uniqueConstraints = @UniqueConstraint(name = "uk_flw_log_event_reg", columnNames = {"event_id", "reg_id"}),
indexes = {
@Index(name = "idx_flw_log_scan", columnList = "status, next_retry_time"),
@Index(name = "idx_flw_log_inst", columnList = "instance_id")})
@@ -76,6 +78,10 @@ public class FlwCallbackLog extends BaseEntity {
@Column(name = "last_error", length = 1000)
private String lastError;
/** 响应体摘要(探测/probe 落,截 1000;常规推送不落——2xx 即成功不解析响应体) */
@Column(name = "resp_body", columnDefinition = "TEXT")
private String respBody;
/** 成功时间 */
@Column(name = "push_time")
private LocalDateTime pushTime;
@@ -21,6 +21,9 @@ import javax.persistence.UniqueConstraint;
columnNames = {"sch_id", "app_key", "flow_guid"}),
indexes = @Index(name = "idx_flw_reg_flow", columnList = "sch_id, flow_guid, status"))
public class FlwCallbackReg extends BaseEntity {
@Column(name="source_principal_id",length=36)
private String sourcePrincipalId;
/** 租户键 */
@Column(name = "sch_id", nullable = false)
@@ -0,0 +1,10 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
/** Persistent admission mutex. Rows survive application deletion; never recycle. */
@Entity @Table(name="flw_command_source") @Getter @Setter
public class FlwCommandSource {
@Id @Column(length=36) private String id;
@Column(nullable=false) private Integer appId;
}
@@ -14,14 +14,14 @@ import java.time.LocalDateTime;
/**
* 流程定义(概览 §4.1 + V2.0 Part D #2/#3 修订)。
* flow_guid 绑定"这条流程线"而非某个版本:升版后业务方绑定关系零改动,新发起自动走
* 当前已发布最高版本;item_code/form_id 留可空列(M1 不启用,原生模式/表单二期)。
* 当前已发布最高版本;item_code/form_uuid 留可空列(M1 不启用,原生模式/表单二期)。
*/
@Getter
@Setter
@Entity
@Table(name = "flw_def",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_sch_code_ver",
columnNames = {"sch_id", "code", "version"}),
uniqueConstraints = @UniqueConstraint(name = "uk_flw_def_guid_ver",
columnNames = {"sch_id", "flow_guid", "version"}),
indexes = @Index(name = "idx_flw_def_guid", columnList = "sch_id, flow_guid, status, version"))
public class FlwDef extends BaseEntity {
@@ -33,7 +33,10 @@ public class FlwDef extends BaseEntity {
@Column(name = "flow_guid", nullable = false, length = 32)
private String flowGuid;
/** 流程编码(本应用内部管理键) */
/**
* 流程编码(管理端分类/检索标签,可重名)。2026-10-10 起唯一键改为 (sch_id, flow_guid, version),
* 编码不再承担唯一性——多条流程线(如同为请假的产假/丧假)可共用一个编码。
*/
@Column(name = "code", nullable = false, length = 64)
private String code;
@@ -41,6 +44,10 @@ public class FlwDef extends BaseEntity {
@Column(name = "name", nullable = false, length = 128)
private String name;
/** 归属应用(子应用 appKey,可空=未分配;管理端"按应用筛选"维度,2026-10-10) */
@Column(name = "app_key", length = 64)
private String appKey;
/** 版本,发布后不可改,改定义=升版本 */
@Column(name = "version", nullable = false)
private Integer version;
@@ -49,9 +56,32 @@ public class FlwDef extends BaseEntity {
@Column(name = "item_code", length = 64)
private String itemCode;
/** 关联表单定义(外部托管模式可无表单,M1 恒 null;二期通用表单预留) */
@Column(name = "form_id")
private Long formId;
/** 关联表单定义 uuid(表单中心对外身份,32 位;M1 恒 null,二期对接时启用) */
@Column(name = "form_uuid", length = 32)
private String formUuid;
/**
* 关联表单的<b>确切版本</b>(v3/TASK_1):与 {@link #formUuid} 成对,发布期按这组坐标取快照,
* 中心返回的坐标与请求不符即阻断发布。
*
* <p>为什么必须钉版本而不是"发布时取当前发布版":同一条流程线的两次发布可能落到不同的表结构上,
* 而表达式校验、办件回显都按嵌入的 formContract 判——版本不钉住,"这条流程当时用的哪张表"
* 就成了只能猜的历史。存的是服务器侧核验过的版本号。</p>
*
* <p>null 只出现在本次改造之前已经关联了表单的存量行上(C1 第一刀没这列):
* 那种行发布时仍走"当前发布版"的旧口径,改造后新保存/新关联一律成对写入。</p>
*/
@Column(name = "form_version")
private Integer formVersion;
/**
* 线级停发标志(2026-10-25 T03):1=该 flowGuid 整线停发——阻止新发起/预览,不影响在途实例
* (在途按实例固定 defId 原版推进)。与版本级 status(DISABLED) 正交:线级停发后 enable()
* 恢复的只是版本状态,线仍不可新发起;enableLine 才恢复线可用性,且不改写任何发布内容字节。
* 批量 UPDATE 全版本行同值,读任一行即读线状态。
*/
@Column(name = "line_offline", nullable = false, columnDefinition = "INT NOT NULL DEFAULT 0")
private Integer lineOffline = 0;
/** §5.1 流程定义 JSON */
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
@@ -64,4 +94,8 @@ public class FlwDef extends BaseEntity {
/** 发布时间 */
@Column(name = "publish_time")
private LocalDateTime publishTime;
/** 升版本改动说明(议题二③,可跳过;仅新版本行有值,草稿直改不写) */
@Column(name = "change_log", length = 500)
private String changeLog;
}
@@ -0,0 +1,44 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 科室(2026-10-10 四议题·科室管理):"组+成员"模型取代单用户映射行(flw_assignee_map)。
* <b>deptKey = 本表自增主键 id 的字符串形态</b>(拍板 D1:本地自增,不做存量迁移——旧映射的
* deptKey=UserBase 组 id,切源后解析失败属拍板接受的预期,需在科室管理重建后重新配置流程)。
* source_group_id 存 UserBase 组 id 防重复拉取重复建组(uk 对 NULL 不生效,手工建组天然多行)。
*/
@Getter
@Setter
@Entity
@Table(name = "flw_dept",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_dept_sch_srcgroup", columnNames = {"sch_id", "source_group_id"}),
indexes = @Index(name = "idx_flw_dept_sch_status", columnList = "sch_id, status"))
public class FlwDept extends BaseEntity {
/** 租户键 */
@Column(name = "sch_id", nullable = false)
private Long schId;
/** 科室名 */
@Column(name = "dept_name", nullable = false, length = 128)
private String deptName;
/** 0=启用 / 1=停用(沿用 flw_assignee_map 的 status 语义;停用科室解析不出办理人) */
@Column(name = "status", nullable = false)
private Integer status = 0;
/**
* 来源 UserBase 组 id(一键拉取时落,防重复建组);手工建组为 NULL(uk 对 NULL 不生效,
* MySQL 允许多 NULL——手工多科室天然支持)
*/
@Column(name = "source_group_id")
private Integer sourceGroupId;
}
@@ -0,0 +1,38 @@
package com.yunhai.flowcenter.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 科室成员(2026-10-10 四议题·科室管理):user_name 是拉取/添加时快照(与旧映射行同款读模型——
* 解析零跨服务调用)。status 预留停用语义(管理面只做物理增删,引擎只认 0)。
*/
@Getter
@Setter
@Entity
@Table(name = "flw_dept_member",
uniqueConstraints = @UniqueConstraint(name = "uk_flw_dept_member", columnNames = {"dept_id", "user_id"}),
indexes = @Index(name = "idx_flw_dept_member_dept", columnList = "dept_id, status"))
public class FlwDeptMember extends BaseEntity {
/** 所属科室(flw_dept.id) */
@Column(name = "dept_id", nullable = false)
private Long deptId;
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 姓名快照(拉取/添加时落;解析与轨迹展示用) */
@Column(name = "user_name", nullable = false, length = 128)
private String userName;
/** 0=启用 / 1=停用 */
@Column(name = "status", nullable = false)
private Integer status = 0;
}
@@ -14,8 +14,9 @@ import java.time.LocalDateTime;
/**
* 办件实例(概览 §4.3 + V2.0 Part D):托管模式下业务对象留在业务方,本表只存
* biz_title/biz_url/biz_snapshot 快照与关联键;uk(sch_id, biz_type, biz_id) 幂等——
* 同一业务对象一条办件,重复代发起返回原办件;驳回/撤回后重提复用同一行(R4 重启语义)。
* biz_title/biz_url/biz_snapshot 快照与关联键。正式命令每轮独立实例,唯一域为
* (school, sourcePrincipalId, bizType, bizId, applyRound),单在途由坐标表事务指针保护。
* 旧入口以 sourcePrincipalId + 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;
@@ -74,6 +86,22 @@ public class FlwInstance extends BaseEntity {
@Column(name = "biz_url", length = 500)
private String bizUrl;
/**
* 代发起调用方 app_key 快照(验签上下文 yh.app.auth,2026-10-12):验签域身份,
* 审计/对账用——与 flw_callback_reg/flw_def 的 app_key 同一口径。
*/
@Column(name = "apply_app_key", length = 32)
private String applyAppKey;
/**
* 代发起调用方 app_code 快照(apply 时按 app_key 查 UserBase /oapi/app/by-key 换得):
* 消息 jump_app 的权威来源(路由/跳转/判权域标识,projman app.name 口径如 notice/svc-hall)。
* biz_url 页面即调用方页面,验签身份=页面属主由平台结构保证。UserBase 不可达时为空
* →消息 jumpApp 空、回落发送方归属(现状行为),不阻断 apply。
*/
@Column(name = "apply_app_code", length = 32)
private String applyAppCode;
/** 业务摘要 JSON 快照:办理页展示用(只收摘要,不收富文本正文,概览 §十边界) */
@Column(name = "biz_snapshot", columnDefinition = "LONGTEXT")
private String bizSnapshot;
@@ -108,4 +136,48 @@ public class FlwInstance extends BaseEntity {
@Version
@Column(name = "version", nullable = false)
private Integer version;
/**
* 最近一次成功受理代发起的<b>请求标识</b>(消费方持久化命令里带的 token,v2 §8,TASK_2 C.3)。
*
* <p>它回答的是"这次 apply 是同一命令的重投,还是新一轮发起"——biz uk 只说得出
* "这个业务键已经有办件了",说不出这句。没有它时,消费方在响应丢失后重发同业务键,
* 命中 30/40 会被 R4 语义重启推进,等于<b>把网络重试当成用户重提</b>。
* 同 token 一律只回读现状、不重启;换 token 才是新一轮。</p>
*/
@Column(name = "apply_request_token", length = 64)
private String applyRequestToken;
/**
* 当前发起轮次(1 起)。正式命令由服务端坐标锁内分配,不接受客户端 attempt 分轮。
*
* <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
* 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p>
*/
@Column(name = "apply_round")
private Integer applyRound;
/**
* 受理时规范载荷指纹(2026-10-25 T04,SHA-256 十六进制):同 requestToken 必须同指纹——
* 同 ID 不同载荷是两份命令,撞在同一个 token 上按冲突拒绝,不伪装成幂等成功。
* 存量行(指纹列为 NULL)跳过该核对(守卫缺省是关,与 token/round 存量纪律一致)。
*/
@Column(name = "apply_payload_hash", length = 64)
private String applyPayloadHash;
/**
* 事件序号计数器(2026-10-25 T05):实例内事件事实 eventSeq 的发号器。发号时在事务内
* 悲观锁住本行(findByIdForUpdate 当前读)再 +1——REPEATABLE READ 下对聚合做快照读会
* 拿到陈旧 max(FlowEngineIT 会签并发实证),行上计数器+行锁才是串行安全的。
*/
@Column(name = "event_seq_counter")
private Integer eventSeqCounter;
/**
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
* 随实体序列化给前端画红标;业务代码不得读写该字段。
*/
@javax.persistence.Transient
private Boolean routeHung;
}
@@ -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() {
}
}
}
@@ -41,6 +41,10 @@ public final class FlowModels {
private String nodeId;
private String nodeName;
private String assigneeName;
/**
* 提交人姓名(办件发起人快照)。列表列"提交人"以此为准,勿与 assigneeName(当前办理人)混用。
*/
private String submitterName;
private Integer status;
private String opinion;
private String arriveTime;
@@ -81,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;
}
}
@@ -67,9 +67,10 @@ public class UserMsgClient {
/**
* 发送站内信(content 必填;msgNo 幂等,重发安全)。
*
* @param jumpApp 跳转页面归属应用(可空):页面属业务方(bizUrl 落地页)时填业务方应用名
* (=instance.bizType),接收方优先按它查基址;页面属流程中心自己(/task、/instance)
* 传 null,由接收方按 bizType 回落
* @param jumpApp 跳转页面归属应用(可空,值域=app_code 如 notice/svc-hall):页面属业务方
* (bizUrl 落地页)时填 apply 调用方 app_code(=instance.applyAppCode,apply
* 时由验签身份按 by-key 换得),接收方优先按它查基址;页面属流程中心自己
* (/task、/instance)传 null,由接收方按发送方 appKey 回落
*/
public void sendUserMsg(Integer schId, List<Integer> userIds, Integer msgType, String bizType,
String bizId, String title, String content, String jumpPath, String jumpApp,
@@ -20,6 +20,9 @@ import java.util.Collections;
* - 任务被办理:todo -1(办理人)
* - 完成/拒绝/撤回:msgType=1、bizType=flow.result、content 必填(结果+意见)、msgNo 带 version
* (重提重启多轮终态互不幂等吞掉)
* - 退回:bizType=flow.returned、msgNo 带 version,通知被退回节点的新待办人
* 结果/退回与待办/抄送同跳转策略(2026-10-17 F8 缺口①):bizUrl 优先 + jumpApp=applyAppCode,
* 空回落中心页且归属留空——四类消息的页面归属口径从此一致。
* - 催办:bizType=flow.urge、msgNo=urge:{taskId}:{第N次}
* 全部在 AFTER_COMMIT 监听器里发送,失败不阻断(UserMsgClient 内部容错)。
*/
@@ -73,11 +76,12 @@ public class UserMsgNotifier {
String content = "「" + safe(instance.getBizTitle()) + "」等待您审批"
+ (task.getNodeName() == null ? "" : "(" + task.getNodeName() + ")");
// 跳转路径:办件自带 bizUrl(业务方详情页,改造后为裸相对路径)优先——应用内审批免跳
// 流程中心,页面归属=业务方(jumpApp=instance.bizType,接收方按它查基址拼绝对地址);
// 未传 bizUrl 回落流程中心办理页 /task/{taskId},页面属自己(jumpApp 留空按 bizType 回落)
boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
String jumpPath = useBizUrl ? instance.getBizUrl() : "/task/" + task.getId();
String jumpApp = useBizUrl ? instance.getBizType() : null;
// 流程中心,页面归属=apply 调用方(jumpApp=instance.applyAppCode,apply 时由验签身份
// 按_by-key 换得的 app_code,接收方按它查基址拼绝对地址);applyAppCode 空(老数据/
// UserBase 不可达)或未传 bizUrl 回落流程中心页(/task/{taskId}、/instance/{id}),
// 页面属自己(jumpApp 留空按发送方回落)。bizType 是消息分类,不是路由键,不再冒充。
String jumpPath = jumpPath(instance, "/task/" + task.getId());
String jumpApp = jumpApp(instance);
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
Collections.singletonList(task.getAssigneeId()), 2,
"flow.task", String.valueOf(instance.getId()), title, content,
@@ -122,7 +126,8 @@ public class UserMsgNotifier {
userMsgClient.sendUserMsg(instance.getSchId().intValue(), targets, 1,
"flow.returned", String.valueOf(instance.getId()),
"办件被退回:" + safe(instance.getBizTitle()), content.toString(),
"/instance/" + instance.getId(), null,
// F8 缺口①:退回消息与待办/抄送同策略——业务方详情页优先、归属申报给 apply 调用方
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance),
"returned:" + instance.getId() + ":" + instance.getVersion(), 30);
}
@@ -143,15 +148,13 @@ public class UserMsgNotifier {
String content = "「" + bizTitle + "」抄送给您"
+ (nodeName == null || String.valueOf(nodeName).isEmpty()
? "" : "(" + nodeName + ")");
// 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=业务方)优先,空回落办件详情(属自己)
boolean useBizUrl = instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
String jumpPath = useBizUrl ? instance.getBizUrl() : "/instance/" + instance.getId();
String jumpApp = useBizUrl ? instance.getBizType() : null;
// 跳转与任务到达同策略:bizUrl(业务方详情页,页面归属=apply 调用方 applyAppCode)优先,
// 空回落办件详情(属自己)
userMsgClient.sendUserMsg(instance.getSchId().intValue(),
Collections.singletonList((Integer) ccUserId), 1,
"flow.cc", String.valueOf(instance.getId()),
"您有新的抄送:" + bizTitle, content,
jumpPath, jumpApp, "cc:" + ccId, 30);
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance), "cc:" + ccId, 30);
}
private void notifyResult(FlowEvent event) {
@@ -185,7 +188,9 @@ public class UserMsgNotifier {
Collections.singletonList(instance.getSubmitterId()), 1,
"flow.result", String.valueOf(instance.getId()),
"办件状态更新:" + bizTitle, content.toString(),
"/instance/" + instance.getId(), null,
// F8 缺口①:结果消息与待办/抄送同策略——业务方详情页优先(子应用详情页 iframe
// 显终态进度),jumpApp 申报给 apply 调用方;bizUrl 空仍回落中心办件详情
jumpPath(instance, "/instance/" + instance.getId()), jumpApp(instance),
"result:" + instance.getId() + ":" + instance.getVersion(), 30);
}
@@ -206,6 +211,23 @@ public class UserMsgNotifier {
"/task/" + task.getId(), null, "urge:" + task.getId() + ":" + count, 7);
}
/**
* 四类消息(待办/抄送/结果/退回)共用的跳转路径策略:办件 bizUrl 优先,空/空白回落中心页。
* bizUrl 自 2026-09-26 起是裸相对路径(不含域名/ctx/#),接收方按 jumpApp 查基址拼绝对地址。
*/
private static String jumpPath(FlwInstance instance, String centerFallback) {
return hasBizUrl(instance) ? instance.getBizUrl() : centerFallback;
}
/** 页面归属:走 bizUrl 时申报给 apply 调用方(applyAppCode),回落中心页时留空按发送方归属 */
private static String jumpApp(FlwInstance instance) {
return hasBizUrl(instance) ? instance.getApplyAppCode() : null;
}
private static boolean hasBizUrl(FlwInstance instance) {
return instance.getBizUrl() != null && !instance.getBizUrl().trim().isEmpty();
}
private FlwInstance instanceOf(Long instanceId) {
return instanceId == null ? null : instanceRepository.findById(instanceId).orElse(null);
}
@@ -0,0 +1,35 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwAdvanceIntent;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.jpa.repository.Lock;
import javax.persistence.LockModeType;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface FlwAdvanceIntentRepository extends JpaRepository<FlwAdvanceIntent, Long> {
/** 到点待执行:PENDING 且(无退避或已到期) */
@Query(value="select * from flw_advance_intent where status=:status "
+ "and (next_retry_at is null or next_retry_at<=:now) order by create_time,id limit 50", nativeQuery=true)
List<FlwAdvanceIntent> findTop50ByStatusAndNextRetryAtLessThanEqualOrderByCreateTimeAsc(
@Param("status") String status, @Param("now") LocalDateTime now);
List<FlwAdvanceIntent> findByInstanceIdAndNodeIdOrderByIdDesc(Long instanceId, String nodeId);
Optional<FlwAdvanceIntent> findFirstByInstanceIdAndNodeIdAndActivationTaskIdIsNotNullOrderByIdDesc(
Long instanceId, String nodeId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select i from FlwAdvanceIntent i where i.id=:id")
Optional<FlwAdvanceIntent> findByIdForUpdate(@Param("id") Long id);
/** 幂等记账:同实例同节点是否已有未完成意图(办理事务内去重,避免并发办理堆积重复行) */
@Query("select count(i) > 0 from FlwAdvanceIntent i where i.instanceId = :instanceId "
+ "and i.nodeId = :nodeId and i.status = 'PENDING'")
boolean existsPending(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
}
@@ -0,0 +1,19 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwApp;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface FlwAppRepository extends JpaRepository<FlwApp, Long> {
List<FlwApp> findBySchIdOrderBySortNoAscIdAsc(Long schId);
List<FlwApp> findBySchIdAndEnabledOrderBySortNoAscIdAsc(Long schId, Integer enabled);
/** defSave 归属应用白名单校验:本校启用集合是否包含该 appKey */
@Query("select count(a) > 0 from FlwApp a where a.schId = :schId and a.appKey = :appKey and a.enabled = 1")
boolean isEnabledApp(@Param("schId") Long schId, @Param("appKey") String appKey);
}
@@ -0,0 +1,8 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwApplyCommand;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FlwApplyCommandRepository extends JpaRepository<FlwApplyCommand,Long> {
boolean existsBySchIdAndSourcePrincipalIdAndFlowGuidAndState(Long school, String source, String flow, String state);
Optional<FlwApplyCommand> findBySchIdAndSourcePrincipalIdAndRequestId(Long schId,String sourcePrincipalId,String requestId);
}
@@ -0,0 +1,14 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwBizCoordinate;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import javax.persistence.LockModeType;
import java.util.Optional;
public interface FlwBizCoordinateRepository extends JpaRepository<FlwBizCoordinate,Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from FlwBizCoordinate c where c.schId=:sch and c.sourcePrincipalId=:source and c.bizType=:type and c.bizId=:biz")
Optional<FlwBizCoordinate> lock(@Param("sch") Long sch,@Param("source") String source,@Param("type") String type,@Param("biz") String biz);
@Modifying
@Query("update FlwBizCoordinate c set c.activeInstanceId=null where c.schId=:sch and c.sourcePrincipalId=:source and c.bizType=:type and c.bizId=:biz and c.activeInstanceId=:instance and c.lastRound=:round")
int release(@Param("sch") Long sch,@Param("source") String source,@Param("type") String type,@Param("biz") String biz,@Param("instance") Long instance,@Param("round") Integer round);
}
@@ -13,6 +13,9 @@ import java.util.List;
public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog, Long> {
/** T05 投递幂等:同一事件事实对同一订阅方只建一行(补分发与正常分发竞争时去重) */
boolean existsByEventIdAndRegId(String eventId, Long regId);
/** 重推扫描主路径:待推/重试中且到点 */
List<FlwCallbackLog> findTop50ByStatusAndNextRetryTimeLessThanEqualOrderByNextRetryTimeAsc(
Integer status, LocalDateTime now);
@@ -35,4 +38,53 @@ public interface FlwCallbackLogRepository extends JpaRepository<FlwCallbackLog,
Page<FlwCallbackLog> pageHung(@Param("schId") Long schId, @Param("minRetry") int minRetry, Pageable pageable);
List<FlwCallbackLog> findByIdInAndSchId(Collection<Long> ids, Long schId);
// ==================== 健康度体检聚合(2026-10-15) ====================
// 全部为只读统计,不新增表;口径见 FlowCallbackAdminService#health。
/** 近 7 日推送成败计数:按 状态(FULLY_QUALIFIED) 返回 [status, count] */
@Query("select l.status, count(l) from FlwCallbackLog l "
+ "where l.schId = :schId and l.pushTime >= :since "
+ "group by l.status")
List<Object[]> countByStatusSince(@Param("schId") Long schId, @Param("since") LocalDateTime since);
/** 死信数(status=FULLY_QUALIFIED 由调用方传入) */
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.status = :status")
long countByStatus(@Param("schId") Long schId, @Param("status") Integer status);
/**
* 挂起数:待推且重试次数达阈值。
* 与 pageHung 同口径(管理端"挂起"列表的计数版)。
*/
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.status = 0 "
+ "and l.retryCount >= :minRetry")
long countHung(@Param("schId") Long schId, @Param("minRetry") int minRetry);
/** 某应用的最近一次推送时间(判活) */
@Query("select max(l.pushTime) from FlwCallbackLog l where l.schId = :schId and l.status = 1")
LocalDateTime lastSuccessTime(@Param("schId") Long schId);
// -------- 按应用 appKey 维度过滤(健康度面板每应用一行,用 regId 归属关联) --------
/** 某注册集合近 7 日成败计数 */
@Query("select l.status, count(l) from FlwCallbackLog l "
+ "where l.schId = :schId and l.regId in :regIds and l.pushTime >= :since "
+ "group by l.status")
List<Object[]> countByStatusSinceForRegs(@Param("schId") Long schId,
@Param("regIds") Collection<Long> regIds,
@Param("since") LocalDateTime since);
/** 某注册集合的死信数 */
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId "
+ "and l.regId in :regIds and l.status = :status")
long countByStatusForRegs(@Param("schId") Long schId,
@Param("regIds") Collection<Long> regIds,
@Param("status") Integer status);
/** 某注册集合的挂起数 */
@Query("select count(l) from FlwCallbackLog l where l.schId = :schId and l.regId in :regIds "
+ "and l.status = 0 and l.retryCount >= :minRetry")
long countHungForRegs(@Param("schId") Long schId,
@Param("regIds") Collection<Long> regIds,
@Param("minRetry") int minRetry);
}
@@ -2,6 +2,8 @@ package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwCallbackReg;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@@ -14,4 +16,8 @@ public interface FlwCallbackRegRepository extends JpaRepository<FlwCallbackReg,
List<FlwCallbackReg> findBySchIdAndFlowGuidAndStatus(Long schId, String flowGuid, Integer status);
List<FlwCallbackReg> findBySchIdOrderByUpdateTimeDesc(Long schId);
/** "按应用筛选"下拉数据源之一:本校注册过回调的应用 */
@Query("select distinct r.appKey from FlwCallbackReg r where r.schId = :schId")
List<String> findDistinctAppKeysBySchId(@Param("schId") Long schId);
}
@@ -4,14 +4,23 @@ import com.yunhai.flowcenter.entity.FlwCc;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface FlwCcRepository extends JpaRepository<FlwCc, Long> {
/** 抄送我的(用户面页签,走 user+sent_time 索引) */
Page<FlwCc> findBySchIdAndUserIdOrderBySentTimeDesc(Long schId, Integer userId, Pageable pageable);
/** 抄送我的(用户面页签,走 user+sent_time 索引;appKey 可空=不按应用过滤,口径=定义归属 flw_def.app_key) */
@Query("select c from FlwCc c join FlwInstance i on i.id = c.instanceId join FlwDef d on d.id = i.defId "
+ "where c.schId = :schId and c.userId = :userId "
+ "and (:appKey is null or i.sourcePrincipalId = :appKey or (i.sourcePrincipalId is null and i.applyAppKey = :appKey)) order by c.sentTime desc")
Page<FlwCc> pageCc(@Param("schId") Long schId, @Param("userId") Integer userId,
@Param("appKey") String appKey, Pageable pageable);
/** 办件轨迹/进度留痕 */
List<FlwCc> findByInstanceIdOrderByIdAsc(Long instanceId);
/** 可见性守卫用(F7 前置补丁):本办件是否抄送过该人——只读可见,不授予办理权 */
boolean existsByInstanceIdAndUserId(Long instanceId, Integer userId);
}
@@ -0,0 +1,10 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwCommandSource;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import javax.persistence.LockModeType;
import java.util.Optional;
public interface FlwCommandSourceRepository extends JpaRepository<FlwCommandSource,String> {
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select s from FlwCommandSource s where s.id=:id")
Optional<FlwCommandSource> lock(@Param("id") String id);
}
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -18,16 +19,12 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
/** 任意状态(回调注册只要求流程线存在) */
Optional<FlwDef> findFirstBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid);
/** 同名草稿(保存新建时复用,避免 uk 冲突) */
Optional<FlwDef> findFirstBySchIdAndCodeAndStatus(Long schId, String code, Integer status);
@Query("select coalesce(max(d.version), 0) from FlwDef d where d.schId = :schId and d.code = :code")
int maxVersionByCode(@Param("schId") Long schId, @Param("code") String code);
@Query("select coalesce(max(d.version), 0) from FlwDef d where d.schId = :schId and d.flowGuid = :flowGuid")
int maxVersionByGuid(@Param("schId") Long schId, @Param("flowGuid") String flowGuid);
Optional<FlwDef> findFirstBySchIdAndCodeOrderByVersionDesc(Long schId, String code);
/** "按应用筛选"下拉数据源之一:本校 flw_def 已标注的应用 */
@Query("select distinct d.appKey from FlwDef d where d.schId = :schId and d.appKey is not null")
List<String> findDistinctAppKeys(@Param("schId") Long schId);
List<FlwDef> findBySchIdAndFlowGuidOrderByVersionDesc(Long schId, String flowGuid);
@@ -35,9 +32,22 @@ public interface FlwDefRepository extends JpaRepository<FlwDef, Long> {
List<FlwDef> findBySchIdOrderByUpdateTimeDesc(Long schId);
/** 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本 */
@Query("select d from FlwDef d where d.schId = :schId and d.status = 1 "
/**
* 已发布流程列表(oapi list 数据源):每条流程线取最高已发布版本。
* 2026-10-25 T03:外层追加 {@code lineOffline=0}——线级停发的整线不出现在新发起预览源,
* 且不存在"停发后回退旧版"的形态(内层仍是该线已发布最高版本,停发不改变版本事实)。
*/
@Query("select d from FlwDef d where d.schId = :schId and d.status = 1 and d.lineOffline = 0 "
+ "and d.version = (select max(d2.version) from FlwDef d2 where d2.schId = d.schId "
+ "and d2.flowGuid = d.flowGuid and d2.status = 1) order by d.updateTime desc")
List<FlwDef> findPublishedLatestPerGuid(@Param("schId") Long schId);
/** 线级停发/恢复(T03):批量 UPDATE 全版本行同值;返回受影响行数 */
@org.springframework.data.jpa.repository.Modifying
@Query("update FlwDef d set d.lineOffline = :offline where d.schId = :schId and d.flowGuid = :flowGuid")
int updateLineOffline(@Param("schId") Long schId, @Param("flowGuid") String flowGuid,
@Param("offline") Integer offline);
/** 在途统计(议题二④):一次取回多条流程线的全部版本行 */
List<FlwDef> findBySchIdAndFlowGuidIn(Long schId, Collection<String> flowGuids);
}
@@ -0,0 +1,20 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwDeptMember;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface FlwDeptMemberRepository extends JpaRepository<FlwDeptMember, Long> {
List<FlwDeptMember> findByDeptIdOrderByIdAsc(Long deptId);
List<FlwDeptMember> findByDeptIdAndStatusOrderByIdAsc(Long deptId, Integer status);
Optional<FlwDeptMember> findByDeptIdAndUserId(Long deptId, Integer userId);
void deleteByDeptId(Long deptId);
long countByDeptIdAndStatus(Long deptId, Integer status);
}
@@ -0,0 +1,19 @@
package com.yunhai.flowcenter.repository;
import com.yunhai.flowcenter.entity.FlwDept;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface FlwDeptRepository extends JpaRepository<FlwDept, Long> {
List<FlwDept> findBySchIdOrderByIdAsc(Long schId);
List<FlwDept> findBySchIdAndStatusOrderByIdAsc(Long schId, Integer status);
Optional<FlwDept> findBySchIdAndId(Long schId, Long id);
/** 一键拉取幂等:同源 UserBase 组只建一行 */
Optional<FlwDept> findBySchIdAndSourceGroupId(Long schId, Integer sourceGroupId);
}
@@ -7,28 +7,73 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long> {
@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);
/** 我提交的办件(分页,状态可空) */
@Query("select i from FlwInstance i where i.schId = :schId and i.submitterId = :submitterId "
+ "and (:status is null or i.status = :status) order by i.id desc")
/** 我提交的办件(分页,状态/应用可空;应用口径=定义归属 flw_def.app_key) */
@Query("select i from FlwInstance i join FlwDef d on d.id = i.defId "
+ "where i.schId = :schId and i.submitterId = :submitterId "
+ "and (:status is null or i.status = :status) "
+ "and (:appKey is null or d.appKey = :appKey) order by i.id desc")
Page<FlwInstance> pageMy(@Param("schId") Long schId, @Param("submitterId") Integer submitterId,
@Param("status") Integer status, Pageable pageable);
@Param("status") Integer status, @Param("appKey") String appKey,
Pageable pageable);
/** 管理端办件分页(状态/关键字可空) */
/**
* 管理端办件分页(状态/关键字可空)。
* hung=true 只看「路由挂起」:审批中(status=10)且存在某 (instance,node) 的<b>最大 attempt</b>
* flw_route_log 行 outlet 仍为 NULL(与 {@link com.yunhai.flowcenter.repository.FlwRouteLogRepository#findHungInstanceIds}
* 同口径——历史低 attempt 失败行不参与判定,防「重跑已成功仍报挂起」误报);hung=null/false 不过滤。
*/
@Query("select i from FlwInstance i where i.schId = :schId "
+ "and (:status is null or i.status = :status) "
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) order by i.id desc")
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) "
+ "and (:hung is null or :hung = false or (i.status = 10 and exists ("
+ "select r.id from FlwRouteLog r where r.instanceId = i.id and r.outlet is null "
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)))) "
+ "order by i.id desc")
Page<FlwInstance> pageAdmin(@Param("schId") Long schId, @Param("status") Integer status,
@Param("keyword") String keyword, Pageable pageable);
@Param("keyword") String keyword, @Param("hung") Boolean hung,
Pageable pageable);
/** 按业务类型/状态分页(oapi instances 查询;bizType/status 均可空=不过滤) */
@Query("select i from FlwInstance i where i.schId = :schId "
@@ -36,4 +81,8 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
+ "and (:status is null or i.status = :status) order by i.id desc")
Page<FlwInstance> pageByBiz(@Param("schId") Long schId, @Param("bizType") String bizType,
@Param("status") Integer status, Pageable pageable);
/** 在途办件数(议题二④):按 def_id 分组统计审批中(10)办件,一行一版本 */
@Query("select i.defId, count(i) from FlwInstance i where i.schId = :schId and i.defId in :defIds and i.status = 10 group by i.defId")
List<Object[]> countApprovingByDefIds(@Param("schId") Long schId, @Param("defIds") Collection<Long> defIds);
}
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long> {
@@ -16,4 +17,16 @@ public interface FlwRouteLogRepository extends JpaRepository<FlwRouteLog, Long>
@Query("select coalesce(max(l.attempt), 0) from FlwRouteLog l "
+ "where l.instanceId = :instanceId and l.nodeId = :nodeId")
int maxAttempt(@Param("instanceId") Long instanceId, @Param("nodeId") String nodeId);
/**
* 台账「路由挂起」红标数据源(F11-4):页内办件中仍挂起的 instanceId 集合。
* 判定口径:某 (instance, node) 的<b>最大 attempt</b> 行 outlet 仍为 NULL 才算挂着——
* 退回重走/管理端重跑会写更高 attempt 的新行,历史低 attempt 的失败行(outlet NULL)不参与判定,
* 防「已重跑成功仍报挂起」的误报。
*/
@Query("select distinct r.instanceId from FlwRouteLog r where r.instanceId in :instanceIds "
+ "and r.outlet is null "
+ "and r.attempt = (select max(r2.attempt) from FlwRouteLog r2 "
+ "where r2.instanceId = r.instanceId and r2.nodeId = r.nodeId)")
List<Long> findHungInstanceIds(@Param("instanceIds") Collection<Long> instanceIds);
}
@@ -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,16 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
@Query("update FlwTask t set t.status = 3 where t.instanceId = :instanceId and t.status = 0 and t.id <> :exceptTaskId")
int invalidatePendingExcept(@Param("instanceId") Long instanceId, @Param("exceptTaskId") Long exceptTaskId);
/** 我的待办(主路径,走 assignee 索引) */
@Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 "
@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 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, Pageable pageable);
Page<FlwTask> pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
@Param("appKey") String appKey, Pageable pageable);
/**
* 我的待办(oapi 版,bizType 可选过滤):join 办件表过滤业务类型,排序口径与 {@link #pageTodo}
@@ -75,14 +87,19 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
*
* <p>状态集由调用方传 {@link com.yunhai.flowcenter.enums.FlwEnums.TaskStatus#handledCodes()}
* (勿硬编码字面量);留痕事件行 6/7 不在其中,故不会出现在已办里。
*
* <p>appKey 可空=不过滤,join 均为多对一(办件→定义各一行),不影响"一个办件一行"的分页口径。
*/
@Query("select t from FlwTask t where t.schId = :schId and t.assigneeId = :assigneeId "
@Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId join FlwDef d on d.id = i.defId "
+ "where t.schId = :schId and t.assigneeId = :assigneeId "
+ "and t.status in :statuses "
+ "and (:appKey is null or 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")
Page<FlwTask> pageDone(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
@Param("statuses") List<Integer> statuses, Pageable pageable);
@Param("statuses") List<Integer> statuses, @Param("appKey") String appKey,
Pageable pageable);
/** 已办累计次数(按办件批量,配合 pageDone 的"一个办件一行") */
@Query("select t.instanceId, count(t) from FlwTask t where t.instanceId in :instanceIds "
@@ -0,0 +1,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,13 +1,9 @@
package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import java.util.List;
/**
* 科室口径文案的唯一出口(2026-09-18):DEPT_KEY 节点的"科室名"取值只在 {@link #mappedDeptName}
* 里实现一次——流程图(FlowDiagramService 的科室视图)与流程轨迹(FlowQueryService 的科室口径)
* 共用,避免两边对同一份映射表给出不同名字。
* 科室口径文案的唯一出口(2026-09-18 建,2026-10-10 切源 flw_dept):DEPT_KEY 节点的"科室名"
* 取值只在这里实现一次——流程图(FlowDiagramService 的科室视图)与流程轨迹(FlowQueryService
* 的科室口径)共用,避免两边对同一份科室表给出不同名字。
*/
final class DeptLabelSupport {
@@ -15,18 +11,14 @@ final class DeptLabelSupport {
}
/**
* 映射行里的科室名:取第一个非空 deptName(一个 deptKey 的表现通常一致;科室名可空,
* 空则返回 null 由调用方决定回落链)。
* 科室名(flw_dept.dept_name):科室不存在/已删返回 null,由调用方决定回落链
* (科室不存在≠科室停用——停用科室仍有名字,轨迹/图示照常显示)。
*/
static String mappedDeptName(List<FlwAssigneeMap> maps) {
if (maps == null) {
static String deptName(FlowDeptAdminService deptAdminService, Long schId, String deptKey) {
try {
return deptAdminService.deptName(schId, deptKey);
} catch (Exception e) {
return null;
}
for (FlwAssigneeMap m : maps) {
if (m.getDeptName() != null && !m.getDeptName().trim().isEmpty()) {
return m.getDeptName();
}
}
return null;
}
}
@@ -111,6 +111,10 @@ public class DiagramLayoutService {
// 并行分支组以 SPLIT 竖直中线为轴左右居中(内层组先处理,最后整体推回左边界 0)
centerParallelGroups(nodes, out, box);
// 条件分支分列(2026-10-12):CONDITION 多出口各分支水平分列、隐式汇合点吸附回条件节点
// 中线——修复不等长/空分支被拓扑分层压成单列、跳层边直穿中间节点且出口文案错位的问题
spreadConditionBranches(nodes, out, box);
// 归零(2026-10-03 三视图统一新增):全体平移使 minX=minY=0,positions 消费端无需再算包围盒
if (!box.isEmpty()) {
int minX = Integer.MAX_VALUE;
@@ -129,6 +133,139 @@ public class DiagramLayoutService {
return box;
}
/**
* 条件分支分列(2026-10-12):CONDITION 多出口按 SPLIT 同款"分支列"处理——第 i 个出口的
* 分支成员整体平移到「条件节点竖直中线 + (i-(n-1)/2)·列距」的列中心,出口汇合点(各分支
* 可达集交集的 BFS 最早节点,即隐式 JOIN)不移动、留在中线上。
* <p>
* 背景:等长分支天然被拓扑分层排进同一层横排(旧测试 conditionBranches 同层用例);
* 不等长/空分支(如"校长室→抄送→审批 / 其他→直达结束")各层退化单列,跳层出口边变成
* 穿过中间节点的直线、出口文案叠在链中间。本 pass 对已正常的等长场景是恒等变换
* (列目标=当前居中位),对不等长场景把各分支拉回各自列。
* 嵌套/顺序多个条件节点按定义序处理:外层先整体平移分支(内层条件节点随之移动),
* 内层处理时以移动后的自身中线为轴再展开,位移可组合。
*/
private void spreadConditionBranches(List<FlowModels.NodeVo> nodes, Map<String, List<String>> out,
Map<String, int[]> box) {
int colSpan = SLOT_W + GAP_X;
for (FlowModels.NodeVo cond : nodes) {
if (!FlowDefModel.TYPE_CONDITION.equals(cond.getType()) || !box.containsKey(cond.getId())) {
continue;
}
List<String> heads = out.getOrDefault(cond.getId(), new ArrayList<>());
if (heads.size() < 2) {
continue;
}
// 各分支可达集(含汇合点),用于定位隐式 JOIN
List<java.util.Set<String>> reachSets = new ArrayList<>();
for (String head : heads) {
java.util.Set<String> set = new java.util.HashSet<>();
List<String> q = new ArrayList<>();
q.add(head);
while (!q.isEmpty()) {
String cur = q.remove(0);
if (!box.containsKey(cur) || set.contains(cur)) {
continue;
}
set.add(cur);
q.addAll(out.getOrDefault(cur, new ArrayList<>()));
}
reachSets.add(set);
}
java.util.Set<String> common = reachSets.isEmpty() ? new java.util.HashSet<>() : reachSets.get(0);
for (java.util.Set<String> set : reachSets) {
common = new java.util.HashSet<>(common);
common.retainAll(set);
}
if (common.isEmpty()) {
continue;
}
// 隐式 JOIN:BFS 序最早(近者优先,跳过条件节点自身)
Map<String, Integer> order = new HashMap<>();
order.put(cond.getId(), 0);
List<String> bfs = new ArrayList<>();
bfs.add(cond.getId());
while (!bfs.isEmpty()) {
String cur = bfs.remove(0);
for (String to : out.getOrDefault(cur, new ArrayList<>())) {
if (!order.containsKey(to)) {
order.put(to, order.size());
bfs.add(to);
}
}
}
String join = null;
int joinOrder = Integer.MAX_VALUE;
for (String id : order.keySet()) {
if (id.equals(cond.getId()) || !common.contains(id)) {
continue;
}
int o = order.get(id);
if (o < joinOrder) {
joinOrder = o;
join = id;
}
}
if (join == null) {
continue;
}
int[] cb = box.get(cond.getId());
int axisX = cb[0] + cb[2] / 2;
int n = heads.size();
java.util.Set<String> forkClaimed = new java.util.HashSet<>();
for (int i = 0; i < n; i++) {
// 分支成员:从 head 出发、不穿隐式 JOIN、同一汇合点内不重复认领(菱形交叉兜底)
List<String> members = new ArrayList<>();
List<String> q = new ArrayList<>();
q.add(heads.get(i));
while (!q.isEmpty()) {
String cur = q.remove(0);
if (cur.equals(join) || !box.containsKey(cur) || forkClaimed.contains(cur)) {
continue;
}
forkClaimed.add(cur);
members.add(cur);
q.addAll(out.getOrDefault(cur, new ArrayList<>()));
}
if (members.isEmpty()) {
continue;
}
// 链形分支(每层至多一个成员)逐节点对齐到分支列中心——多层分支整块平移保不了同列;
// 非链形(分支内嵌并行等)回退整块平移,保住内部结构不塌列
boolean chain = true;
java.util.Set<Integer> rows = new java.util.HashSet<>();
for (String m : members) {
if (!rows.add(box.get(m)[1])) {
chain = false;
break;
}
}
int left = Integer.MAX_VALUE;
int right = Integer.MIN_VALUE;
for (String m : members) {
int[] b = box.get(m);
left = Math.min(left, b[0]);
right = Math.max(right, b[0] + b[2]);
}
// (i·列距) − ((n−1)·列距)/2:偶数出口也有精确的 ±列距/2 偏移(colSpan 恒偶,整除无误差)
int target = axisX + i * colSpan - (n - 1) * colSpan / 2;
if (chain) {
for (String m : members) {
int[] b = box.get(m);
b[0] += target - (b[0] + b[2] / 2);
}
} else {
int delta = target - (left + right) / 2;
if (delta != 0) {
for (String m : members) {
box.get(m)[0] += delta;
}
}
}
}
}
}
/**
* 并行分支组水平居中:分支不再从 SPLIT 行向右堆叠,而是以 SPLIT 竖直中线为轴左右对称展开。
* SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的 PARALLEL_JOIN);

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