From 15e0ae5b675a89084a20b002cc6c84eec42c140e Mon Sep 17 00:00:00 2001 From: oneao Date: Tue, 13 Oct 2026 22:32:50 +0800 Subject: [PATCH] u --- code/g3soft-erp/docs/前端架构设计.md | 220 ----- code/g3soft-erp/docs/后端架构设计.md | 117 --- code/g3soft-erp/docs/工作流迁移-交接说明.md | 261 ------ code/g3soft-erp/docs/核心表结构设计.md | 195 ---- code/g3soft-erp/server/pom.xml | 28 + .../permission/SqlPermissionService.java | 28 +- .../workflow/WorkflowActorResolver.java | 5 +- .../workflow/WorkflowConfigService.java | 705 +++++++------- .../workflow/WorkflowExpirySweepService.java | 30 +- .../workflow/WorkflowInstancePolicy.java | 391 ++++++++ .../workflow/WorkflowRuntimeLedger.java | 201 ++++ .../workflow/WorkflowRuntimeService.java | 758 ++++++--------- .../erp/service/workflow/WorkflowSqlKit.java | 85 ++ .../workflow/WorkflowActorResolverTests.java | 136 +++ .../workflow/WorkflowDbIntegrationTests.java | 687 ++++++++++++++ .../workflow/WorkflowDelegationTests.java | 130 +++ .../service/workflow/WorkflowDraftTests.java | 242 +++++ .../workflow/WorkflowEndToEndTests.java | 868 ++++++++++++++++++ .../workflow/WorkflowExpirySweepTests.java | 436 +++++++++ .../workflow/WorkflowInstancePolicyTests.java | 377 ++++++++ .../workflow/WorkflowInstanceRowTests.java | 79 ++ .../service/workflow/WorkflowJsonTests.java | 102 ++ .../service/workflow/WorkflowSerialTests.java | 208 +++++ .../20_registration/13_table_test_menu.sql | 86 ++ .../sql/20_registration/14_dashboard_menu.sql | 39 + code/g3soft-erp/sql/README.md | 38 + .../web/src/components/DomainPlaceholder.vue | 68 -- .../web/src/components/PlaceholderPage.vue | 75 ++ code/g3soft-erp/web/src/constants/menu.ts | 78 -- .../web/src/layouts/LayoutHeader.vue | 22 +- .../web/src/layouts/LayoutHeaderMenu.vue | 7 +- .../web/src/layouts/LayoutMixedColumn.vue | 24 +- .../web/src/layouts/LayoutSidebar.vue | 5 +- .../web/src/layouts/LayoutTabbar.vue | 38 +- .../web/src/layouts/SidebarMenuItem.vue | 16 + code/g3soft-erp/web/src/layouts/icons.ts | 72 +- .../web/src/layouts/useMixedMenu.ts | 66 +- .../web/src/layouts/useSidebarMenu.ts | 49 +- code/g3soft-erp/web/src/router/index.ts | 291 ++++-- code/g3soft-erp/web/src/router/modules/air.ts | 27 - .../web/src/router/modules/container.ts | 27 - code/g3soft-erp/web/src/router/modules/crm.ts | 27 - .../web/src/router/modules/finance.ts | 43 - .../web/src/router/modules/ocean.ts | 36 - .../web/src/router/modules/system.ts | 39 - code/g3soft-erp/web/src/services/api/auth.ts | 39 + code/g3soft-erp/web/src/services/api/data.ts | 125 +++ .../g3soft-erp/web/src/services/api/export.ts | 128 +++ code/g3soft-erp/web/src/services/api/file.ts | 86 ++ code/g3soft-erp/web/src/services/api/index.ts | 19 + .../web/src/services/api/workflow.ts | 159 ++++ code/g3soft-erp/web/src/services/request.ts | 78 +- code/g3soft-erp/web/src/stores/index.ts | 5 +- code/g3soft-erp/web/src/stores/menu.ts | 150 +++ code/g3soft-erp/web/src/stores/permission.ts | 218 +++++ code/g3soft-erp/web/src/stores/user.ts | 127 ++- code/g3soft-erp/web/src/types/menu.ts | 15 +- code/g3soft-erp/web/src/utils/powerCodes.ts | 57 ++ code/g3soft-erp/web/src/utils/tree.ts | 76 ++ code/g3soft-erp/web/src/views/air/Awb.vue | 10 - code/g3soft-erp/web/src/views/air/Booking.vue | 7 - code/g3soft-erp/web/src/views/approval/Cc.vue | 15 + .../web/src/views/approval/Done.vue | 15 + .../web/src/views/approval/Initiated.vue | 18 + .../web/src/views/approval/Todo.vue | 18 + code/g3soft-erp/web/src/views/auth/Login.vue | 148 ++- .../g3soft-erp/web/src/views/base/Contact.vue | 14 + .../web/src/views/base/Othercompany.vue | 14 + .../web/src/views/base/Otherdata.vue | 14 + .../web/src/views/container/Ledger.vue | 10 - .../web/src/views/container/Tracking.vue | 10 - .../g3soft-erp/web/src/views/crm/Customer.vue | 10 - code/g3soft-erp/web/src/views/crm/Lead.vue | 10 - .../web/src/views/dashboard/index.vue | 149 +++ .../web/src/views/finance/Invoice.vue | 10 - .../web/src/views/finance/Payable.vue | 10 - .../web/src/views/finance/Receivable.vue | 10 - .../web/src/views/finance/Statement.vue | 10 - code/g3soft-erp/web/src/views/ocean/Bill.vue | 10 - .../web/src/views/ocean/Schedule.vue | 10 - .../web/src/views/system/KeepAliveTest.vue | 80 -- .../web/src/views/system/MenuConfig.vue | 10 - code/g3soft-erp/web/src/views/system/Role.vue | 10 - code/g3soft-erp/web/src/views/system/User.vue | 14 +- .../web/src/views/system/log/Audit.vue | 14 + .../web/src/views/system/log/Login.vue | 14 + .../web/src/views/system/workflow/Index.vue | 14 + .../Booking.vue => table-test/index.vue} | 209 ++++- .../.changeset/table-checkbox-current-cell.md | 11 + .../.changeset/table-editing-cell-visual.md | 12 + .../.changeset/table-fixed-columns.md | 17 + .../.changeset/table-readonly-grey-gate.md | 13 + .../.changeset/table-tab-navigation.md | 14 + .../table-validation-editing-yield.md | 13 + code/g3soft-libs/docs/.vitepress/config.mts | 34 +- .../docs/.vitepress/theme/HeaderSections.vue | 8 +- .../docs/.vitepress/theme/custom.css | 14 + code/g3soft-libs/docs/content/api/auth.md | 140 +++ code/g3soft-libs/docs/content/api/export.md | 124 +++ code/g3soft-libs/docs/content/api/file.md | 203 ++++ .../docs/content/api/workflow-runtime.md | 382 ++++++++ code/g3soft-libs/docs/content/api/workflow.md | 393 ++++++++ .../docs/content/backend/architecture.md | 181 ++++ .../docs/content/backend/schema.md | 791 ++++++++++++++++ .../g3soft-libs/docs/content/ui/data/table.md | 105 ++- .../g3soft-libs/docs/examples/table/basic.vue | 5 +- .../docs/examples/table/editable.vue | 4 +- .../g3soft-libs/packages/ui/e2e/table.spec.ts | 662 +++++++++++++ .../packages/ui/playground/main.ts | 133 ++- .../packages/ui/src/table/G3Table.vue | 765 ++++++++++----- .../ui/src/table/style/_overrides.scss | 107 +++ .../packages/ui/src/table/types.ts | 23 +- .../packages/ui/src/table/utils/column.ts | 12 +- .../ui/src/table/utils/editable.test.ts | 199 +++- .../packages/ui/src/table/utils/editable.ts | 144 ++- 115 files changed, 11399 insertions(+), 2741 deletions(-) delete mode 100644 code/g3soft-erp/docs/前端架构设计.md delete mode 100644 code/g3soft-erp/docs/后端架构设计.md delete mode 100644 code/g3soft-erp/docs/工作流迁移-交接说明.md delete mode 100644 code/g3soft-erp/docs/核心表结构设计.md create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowInstancePolicy.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeLedger.java create mode 100644 code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowSqlKit.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowActorResolverTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDbIntegrationTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDelegationTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDraftTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowEndToEndTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowInstancePolicyTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowInstanceRowTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowJsonTests.java create mode 100644 code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowSerialTests.java create mode 100644 code/g3soft-erp/sql/20_registration/13_table_test_menu.sql create mode 100644 code/g3soft-erp/sql/20_registration/14_dashboard_menu.sql delete mode 100644 code/g3soft-erp/web/src/components/DomainPlaceholder.vue create mode 100644 code/g3soft-erp/web/src/components/PlaceholderPage.vue delete mode 100644 code/g3soft-erp/web/src/constants/menu.ts delete mode 100644 code/g3soft-erp/web/src/router/modules/air.ts delete mode 100644 code/g3soft-erp/web/src/router/modules/container.ts delete mode 100644 code/g3soft-erp/web/src/router/modules/crm.ts delete mode 100644 code/g3soft-erp/web/src/router/modules/finance.ts delete mode 100644 code/g3soft-erp/web/src/router/modules/ocean.ts delete mode 100644 code/g3soft-erp/web/src/router/modules/system.ts create mode 100644 code/g3soft-erp/web/src/services/api/auth.ts create mode 100644 code/g3soft-erp/web/src/services/api/data.ts create mode 100644 code/g3soft-erp/web/src/services/api/export.ts create mode 100644 code/g3soft-erp/web/src/services/api/file.ts create mode 100644 code/g3soft-erp/web/src/services/api/index.ts create mode 100644 code/g3soft-erp/web/src/services/api/workflow.ts create mode 100644 code/g3soft-erp/web/src/stores/menu.ts create mode 100644 code/g3soft-erp/web/src/stores/permission.ts create mode 100644 code/g3soft-erp/web/src/utils/powerCodes.ts create mode 100644 code/g3soft-erp/web/src/utils/tree.ts delete mode 100644 code/g3soft-erp/web/src/views/air/Awb.vue delete mode 100644 code/g3soft-erp/web/src/views/air/Booking.vue create mode 100644 code/g3soft-erp/web/src/views/approval/Cc.vue create mode 100644 code/g3soft-erp/web/src/views/approval/Done.vue create mode 100644 code/g3soft-erp/web/src/views/approval/Initiated.vue create mode 100644 code/g3soft-erp/web/src/views/approval/Todo.vue create mode 100644 code/g3soft-erp/web/src/views/base/Contact.vue create mode 100644 code/g3soft-erp/web/src/views/base/Othercompany.vue create mode 100644 code/g3soft-erp/web/src/views/base/Otherdata.vue delete mode 100644 code/g3soft-erp/web/src/views/container/Ledger.vue delete mode 100644 code/g3soft-erp/web/src/views/container/Tracking.vue delete mode 100644 code/g3soft-erp/web/src/views/crm/Customer.vue delete mode 100644 code/g3soft-erp/web/src/views/crm/Lead.vue create mode 100644 code/g3soft-erp/web/src/views/dashboard/index.vue delete mode 100644 code/g3soft-erp/web/src/views/finance/Invoice.vue delete mode 100644 code/g3soft-erp/web/src/views/finance/Payable.vue delete mode 100644 code/g3soft-erp/web/src/views/finance/Receivable.vue delete mode 100644 code/g3soft-erp/web/src/views/finance/Statement.vue delete mode 100644 code/g3soft-erp/web/src/views/ocean/Bill.vue delete mode 100644 code/g3soft-erp/web/src/views/ocean/Schedule.vue delete mode 100644 code/g3soft-erp/web/src/views/system/KeepAliveTest.vue delete mode 100644 code/g3soft-erp/web/src/views/system/MenuConfig.vue delete mode 100644 code/g3soft-erp/web/src/views/system/Role.vue create mode 100644 code/g3soft-erp/web/src/views/system/log/Audit.vue create mode 100644 code/g3soft-erp/web/src/views/system/log/Login.vue create mode 100644 code/g3soft-erp/web/src/views/system/workflow/Index.vue rename code/g3soft-erp/web/src/views/{ocean/Booking.vue => table-test/index.vue} (68%) create mode 100644 code/g3soft-libs/.changeset/table-checkbox-current-cell.md create mode 100644 code/g3soft-libs/.changeset/table-editing-cell-visual.md create mode 100644 code/g3soft-libs/.changeset/table-fixed-columns.md create mode 100644 code/g3soft-libs/.changeset/table-readonly-grey-gate.md create mode 100644 code/g3soft-libs/.changeset/table-tab-navigation.md create mode 100644 code/g3soft-libs/.changeset/table-validation-editing-yield.md create mode 100644 code/g3soft-libs/docs/content/api/auth.md create mode 100644 code/g3soft-libs/docs/content/api/export.md create mode 100644 code/g3soft-libs/docs/content/api/file.md create mode 100644 code/g3soft-libs/docs/content/api/workflow-runtime.md create mode 100644 code/g3soft-libs/docs/content/api/workflow.md create mode 100644 code/g3soft-libs/docs/content/backend/architecture.md create mode 100644 code/g3soft-libs/docs/content/backend/schema.md diff --git a/code/g3soft-erp/docs/前端架构设计.md b/code/g3soft-erp/docs/前端架构设计.md deleted file mode 100644 index 6b7ebfc9..00000000 --- a/code/g3soft-erp/docs/前端架构设计.md +++ /dev/null @@ -1,220 +0,0 @@ -# g3soft-erp 前端架构设计(v0.1) - -> 状态:**前端骨架已落地并验证通过**。后端与元数据引擎未开始。 - -## 1. 定位与范围 - -**本文档解决**:`g3soft-erp` 前端的项目骨架怎么搭。 - -**不在范围内**(后续单独立项): - -| 项 | 状态 | -|---|---| -| 元数据驱动引擎(通用查询 / 保存 / 权限) | 未设计 | -| 后端设计与 Java 分包 | 已开始(见 `server/README.md`) | -| `stk-table-vue`(虚拟滚动表格) | 待定 | -| LogicFlow(审批流设计器) | 待定 | -| 登录 / 权限 / 用户模块 | 未开始(骨架已留位) | - -## 2. 技术栈(已定) - -| 项 | 选型 | -|---|---| -| 框架 | Vue 3.5 + Vite 8 | -| 语言 | **TypeScript**(strict) | -| 路由 | vue-router 5,**静态路由 + 常量菜单** | -| 状态 | Pinia 4 + `pinia-plugin-persistedstate` | -| 样式 | **Sass** + `@g3soft/tokens` | -| UI 库 | **`@g3soft/ui`**(本地 link 开发,私库版本发版) | -| HTTP | axios(统一拦截器) | -| 图标 | `@lucide/vue` | -| 规范化 | **oxlint + oxfmt** | -| 测试 | Vitest + happy-dom | -| 日期 | dayjs | - -**后端**(未开始,已定):Spring Boot 4.1 + Java 21 + spring-boot-starter-jdbc + Druid + **SQL Server** + JJWT + Apache Fesod。**无 ORM**,纯 SQL 驱动。 - -## 3. 目录结构 - -### 仓库层 - -``` -g3soft-erp/ -├── pnpm-workspace.yaml # 仓库唯一配置文件:overrides + allowBuilds -├── web/ # 前端应用(前端唯一的 package.json 在此) -├── server/ # 后端占位(Maven,未开始) -├── docs/ # 本文档 -└── README.md -``` - -**根目录刻意不放 `package.json`。** 理由: - -- 前端可交付物只有 `web/` 一个包,那个 `package.json` 就是 `web/package.json`,身份唯一。 -- 后端 `server/` 由 Maven 管理,不受 Node 管;一个成员的 workspace 套 monorepo 的壳只是形式主义。 -- 代价:命令要 `cd web` 再跑,根目录敲不了 `pnpm dev`。**这是有意的取舍。** - -`pnpm-workspace.yaml` 仍需保留,因为它承担两件必须在 workspace 根生效的事:`overrides`(把 `@g3soft/ui` 指向 `g3soft-libs`)和 `allowBuilds`(pnpm 11 构建脚本白名单)。**已验证:根目录没有 `package.json` 时 `overrides` 依然生效。** - -### 前端层 - -``` -web/src/ -├── main.ts # 入口:Pinia / Router / G3UI.config / 样式 -├── App.vue -├── router/ -│ ├── index.ts # createRouter + 守卫 + NProgress -│ └── modules/ # 每业务域一个路由文件 -│ ├── ocean.ts air.ts container.ts finance.ts crm.ts system.ts -├── layouts/ -│ ├── DefaultLayout.vue # 主布局:侧栏 + 顶栏 + 页签 + 内容区 -│ ├── LayoutSidebar.vue # 侧栏(含折叠) -│ ├── LayoutHeader.vue # 顶栏(含面包屑标题、用户区) -│ ├── LayoutTabbar.vue # 页签栏 -│ ├── useSidebarMenu.ts # 菜单逻辑(组合式) -│ └── icons.ts # 菜单图标注册表 -├── views/ # 页面,与 router/modules 一一对应 -│ ├── ocean/ air/ container/ finance/ crm/ system/ -│ └── error/NotFound.vue -├── components/ # 跨域共享组件(初始只有 DomainPlaceholder) -├── stores/ -│ ├── pinia.ts # createPinia + 持久化插件 -│ ├── app.ts # 壳层状态:侧栏、页签 -│ ├── user.ts # 用户与会话 -│ └── index.ts # 统一导出 -├── services/ -│ ├── request.ts # axios 实例 + 拦截器 + http() -│ └── api/ # 按域划分的接口封装(暂空) -├── composables/ # 跨域组合式函数(暂空) -├── types/ # env.d.ts、menu.ts -├── styles/ -│ ├── index.scss # 入口:tokens → ui → global -│ ├── variables.scss # 转发 tokens 的 scss API(不 emit CSS) -│ └── global.scss # reset + 全局 -├── constants/ -│ ├── menu.ts # 菜单常量(唯一数据源) -│ └── common.ts # 存储前缀、标题、API_BASE -└── utils/ # 全局工具(暂空) -``` - -### 关键约定 - -1. **`components/`、`composables/`、`utils/` 刻意留空** - 遵循「**为真实出现的第二个使用方而抽象**」:只有一个业务域用的东西放该域内(如 `views/ocean/utils.ts`),出现第二个真实调用方才上提。**不要因为"以后可能复用"提前抽一层。** - -2. **业务域内部自包含** - ``` - views/ocean/ - ├── Index.vue # 页面 - ├── components/ # 只属于海运的组件 - └── utils.ts # 只属于海运的逻辑 - ``` - -3. **样式**:组件库用 `g3-` 前缀 + BEM;业务侧页面样式用 `scoped`;颜色尺寸一律走 `--g3-*` 变量,**不自定义色值**。 - -4. **布局壳的样式不加 `scoped`**(`LayoutSidebar.vue` 等),因为需要被子元素感知尺寸;业务组件必须加 `scoped`。 - -5. **不兼容历史持久化数据** - `localStorage` 里存过的偏好设置(`g3erp:preferences`)、页签(`g3erp:app`)等,**不做版本迁移、不做字段兜底**。 - 删字段或改字段名时直接删,不要写 `if (oldField)` 这类兼容分支——`store.ts` 的 `mergePreferences` 只遍历 `defaultPreferences` 的键,旧数据里多出来的键自然被忽略。 - **理由**:开发期数据结构还在变,为一次性数据写迁移是纯负债。用户清一次 localStorage 即可。 - -## 4. 菜单与路由:单一数据源 - -**这是骨架的核心设计。** - -`constants/menu.ts` 是**菜单与路由的唯一数据源**,新增业务域只改这一处: - -``` -constants/menu.ts - │ - ├──→ 侧栏菜单渲染(useSidebarMenu) - ├──→ 首页重定向目标(router/index.ts 的 firstMenuPath) - └──→ 页签标题来源(DefaultLayout 的 watch,取 route.meta.title) -``` - -**新增一个业务域的完整步骤**: - -1. `constants/menu.ts` 加一项(key / title / path / icon) -2. `layouts/icons.ts` 注册图标(若用了新图标) -3. `router/modules/<域>.ts` 新增路由文件,在 `router/index.ts` 里展开 -4. `views/<域>/Index.vue` 建页面 -5. 在 `DefaultLayout` 的 children 里已自动生效 - -> **页签的标题取自 `route.meta.title`**,所以路由里写了 `title` 的页面会自动出现在页签栏;写 `meta: { affix: true }` 则不可关闭。 - -**未来接入后端动态菜单时**:`constants/menu.ts` 退化为「无权限时的兜底」或直接删除,`useSidebarMenu` 改为从 store 读。**当前不做动态路由**——引擎未设计,现在做等于写死一套假契约。 - -## 5. 状态管理 - -**初始只有两个 store,业务域 store 用到再建。** - -| store | 职责 | 持久化 | -|---|---|---| -| `app.ts` | 侧栏折叠、页签 | 只持久化 `sidebarCollapsed`(`pick` 显式声明) | -| `user.ts` | token、用户信息 | token 存 localStorage | - -**约定**(沿用 `fms` 已验证的规则): - -1. 统一 Setup Store:`defineStore('name', () => {}, options)` -2. 一个 store 一个领域,**避免重复状态源** -3. `persist` 必须写 `pick`;加载态、页签态**不持久化** - -## 6. 样式链路 - -``` -styles/index.scss - ├─ @use '@g3soft/tokens' → :root 的 --g3-* 变量(全局只引一次) - ├─ @use '@g3soft/ui/style.css' → 组件库样式 - └─ @use './global' → reset + 全局 -``` - -局部样式需要变量/mixin 时,用 `styles/variables.scss`(转发 `@g3soft/tokens/scss`,**不 emit CSS**)。 - -> ⚠️ **不要在每个 `.vue` 里 `@use '@g3soft/tokens'`**——那会让 `:root` 在每个组件样式里重复输出。只有 `styles/index.scss` 引一次。 - -## 7. 验证结果 - -**下列验证在「拍平为 `web/` 且删除根 `package.json`」之后重跑过,全部通过:** - -| 项 | 结果 | -|---|---| -| `pnpm typecheck`(vue-tsc strict) | ✅ 0 错误 | -| `pnpm lint`(oxlint --deny-warnings) | ✅ 0 warnings / 0 errors | -| `pnpm fmt:check`(oxfmt) | ✅ 全部符合 | -| `pnpm build` | ✅ 3946 modules,路由级分包生效 | -| `pnpm dev` | ✅ localhost:5090,`/` 与 `/src/*` 均 200 | -| `@g3soft/ui` link 解析 | ✅ Junction → `g3soft-libs/packages/ui`,`dist/index.js` 真实存在 | -| `@g3soft/tokens` link 解析 | ✅ Junction → `g3soft-libs/packages/tokens` | -| Vue 去重 | ✅ 仅一份 `vue@3.5.43` | -| 根目录无 `package.json` 时 `overrides` 是否生效 | ✅ 生效(install 无警告,路径解析正确) | -| `G3UI` / `zhCN` 具名导出 | ✅ 存在于 dist | - -**搭建中实际踩到并修掉的坑**(供参考): - -1. **`pnpm.overrides` 写在包级无效** —— 只在 workspace 根生效,已移到 `pnpm-workspace.yaml`。 -2. **`@lucide/vue@1.53.0` 的图标名与旧版不同** —— `MenuFold`/`MenuUnfold` 不存在,应使用 `PanelLeftClose`/`PanelLeftOpen`;`FileQuestion` 不存在,应为 `FileQuestionMark`。**图标不确定时先查 `node_modules/@lucide/vue/dist/lucide-vue.d.ts`**。 -3. **link 方案必须配 `dedupe: ['vue', ...]`**,否则响应式失效。 - -> 另:验证过程中曾用沙盒推断「无根 package.json 时 `link:` 路径会错位一层」,**该结论是错的**——沙盒的目录层级与真实项目不同。真实项目实测路径解析正确。**教训:相对路径解析这类问题必须用真实项目验证,沙盒结论不可直接采信。** - -## 8. 待确认事项 - -| # | 事项 | 影响 | 阻塞什么 | -|---|---|---|---| -| 1 | **私库是否存在** | 决定 CI 中 `@g3soft/ui` 的引用方式(当前是相对路径 link) | CI 搭建 | -| 2 | **`g3soft-libs/packages/framework` 是否删除** | 若保留,布局壳可复用,本次手写的 `layouts/*` 可简化 | 无(骨架已自建) | -| 3 | `stk-table-vue` / LogicFlow | 列表页与审批流设计器选型 | 对应功能开发 | -| 4 | 登录 / 权限模型 | 影响 `router` 守卫与 `user` store | 用户模块 | -| 5 | 后端启动 | `server/` 目前仅占位 | 联调 | -| 6 | H5 端规划 | `h5/` 与 `web/` 平级、互相独立;`@g3soft/ui` 不给 H5 用 | H5 立项 | - -## 9. 后续建议 - -1. **先做业务域页面,还是先做引擎?** - 建议**先做 1~2 个真实业务页面**(如海运委托单列表 + 明细),用真实需求反推引擎该提供什么能力。直接设计引擎容易做成「想象中通用、实际不好用」。 -2. **SQL Server 方言要单独抽层** - 保留 SQL Server 已定,但元数据引擎的动态 SQL 生成器会被方言绑死(分页 `OFFSET/FETCH`、字符串拼接用 `+` 而非 `||`、JSON 函数弱)。**把 SQL 构建抽成一层方言适配**,不要让 `TOP 10` 这类语法散落在业务代码里——将来真要换库只改那一层。 -3. **提交信息带端前缀** - 同仓建议用 `feat(web):` / `fix(api):`,方便区分改动影响哪端。 -4. **H5 端不要急着抽共享层** - `@g3soft/ui` 不给 H5 用,两端就是独立应用。**等 H5 真正落地、且两处出现形态接近的重复代码时,再考虑抽共享包**——不要为「以后可能要共用」提前建 `packages/`。 diff --git a/code/g3soft-erp/docs/后端架构设计.md b/code/g3soft-erp/docs/后端架构设计.md deleted file mode 100644 index b046e6f1..00000000 --- a/code/g3soft-erp/docs/后端架构设计.md +++ /dev/null @@ -1,117 +0,0 @@ -# FMS 后端架构设计 - -> 文档版本:V1.1(精简方案) -> 编制日期:2026-10-12 -> 状态:待评审 -> 适用范围:沿用现有 FMS API 与 SQL Server 模块配置方式 -> 配套文档:[核心表结构设计.md](./核心表结构设计.md) - -## 1. 设计目标 - -本文以已经使用的 FMS 数据库和 `fms-api` 接口为基础,只补充模块配置的版本历史能力。目标是让新 ERP 后端和其他 IT 团队能够沿用熟悉的表结构、SQL 和通用数据接口,而不是重新建设一套元数据运行时。 - -- 保留 `s_module`、`s_field`、`s_module_schema`、`s_relation` 的现有职责和字段约定。 -- 元数据描述常用模块、字段和页面配置;SQL 是可选的正式扩展方式,遇到配置不便表达的查询时使用。 -- 沿用通用数据 API、机构上下文和 SQL Server 数据源路由;不为每个业务模块新增专用 CRUD API。 -- 只新增轻量模块版本历史;不引入模板引擎、编译发布管线、运行时快照目录或复杂审批流程。 - -## 2. 总体结构 - -```text -模块管理界面 - ├─ s_module:模块树、表/视图、SQL、保存目标 - ├─ s_field:模块字段及业务类型 - ├─ s_module_schema:列表 / 表单 / 查询 JSON - └─ s_relation:模块数据关系 - │ - ├─ 普通数据请求 → 现有 /data 通用接口 → SQL Server - ├─ 自定义查询 SQL → 只读 SQL 校验 → SQL Server - └─ 显式版本保存/恢复 → s_module_version -``` - -运行时继续读取现有模块配置表。`s_module_version` 只做历史记录与恢复,不成为普通业务请求的读取入口。 - -## 3. 模块配置如何驱动请求 - -### 3.1 模块与页面配置 - -- 模块编码 `s_module.b_id` 是稳定业务编码,由配置方提供,不使用运行时雪花 ID。 -- `b_module_type` 使用现有 `module`、`data`、`virtual` 约定:分组、标准数据对象和虚拟业务对象。 -- `b_view_table`、`b_save_table`、`b_key_field` 描述普通查询和保存目标;数据库对象默认使用 `dbo`。 -- `s_field` 声明模块字段编码、名称和业务类型;具体 SQL 类型通过数据库结构探测获得。 -- `s_module_schema` 的 `view`、`edit`、`query` JSON 控制列表、表单和查询界面;字段语义不在多个 JSON 中重复定义。 -- `s_relation` 声明模块间字段关系;`b_parent_id` 只用于模块树,不表示业务数据关系。 - -### 3.2 SQL 兜底 - -- 常规简单查询使用配置的 `b_view_table` 和通用查询接口。 -- 关联、聚合或其他配置表达起来不合适的查询,可在模块配置中使用 `b_query_sql`;它是模块配置数据,不要求在后端代码中为每家公司登记 SQL 编码。 -- 对于按机构隔离的数据库,机构数据源路由继续使用现有 `OrgContext` / `OrgDataSourceManager`。各机构可在自己的配置库维护不同模块 SQL,不新增租户配置层。 -- 运行查询 SQL 必须经过现有只读校验路径;只允许查询语句,不允许用查询 SQL 执行新增、修改、删除或 DDL。写入走现有保存/删除接口或明确的业务服务。 -- `b_order_sql` 是模块默认排序;排序标识符按现有 `SqlText` 校验规则处理。查询参数和值不得通过未校验的字符串拼接进入 SQL。 - -## 4. 沿用的通用数据 API - -已有的 `/data/*` 契约继续作为数据访问入口,不因模块配置版本化而更换: - -| 接口 | 用途 | -|---|---| -| `POST /data/loaddata` | 按表/视图、条件、排序和字段列表查询 | -| `POST /data/page` | 分页查询 | -| `POST /data/loaddatabysql` | 执行只读 `SELECT` / `WITH` 查询 | -| `POST /data/saveobjt` | 在一个事务中保存多表新增、修改 | -| `POST /data/delete` | 按模块或受控目标删除 | -| `POST /data/describe` | 探测表或视图列结构 | -| `POST /data/nextid`、`/data/nextcode` | 取雪花 ID 或模块业务编码 | - -`loaddata` / `page` 使用表或视图名、结构化筛选和排序参数;`loaddatabysql` 用于复杂只读查询。已有 `SqlText` 负责标识符验证与 SQL 只读检查,`MetadataReader` / `RowReader` / `RowWriter` 分别负责表结构探测、只读查询与写入执行。通用保存服务依据真实表结构生成参数化写入,不通过 `b_query_sql` 写业务数据。 - -新接入系统应沿用这些请求/响应格式和既有身份、机构、权限上下文;模块管理页面负责读取模块配置并组织请求,不需要另造一套模块专属 CRUD API。 - -## 5. 模块配置版本历史 - -### 5.1 行为边界 - -- 版本功能只增加 `s_module_version` 一张表;快照 JSON 包含模块及其字段、界面、关系和相关模块级扩展配置。 -- 显式创建版本时,后端从现有配置表读取当前状态,分配递增版本号并保存不可变快照。 -- 查看历史只读取版本表;恢复历史版本时,后端校验后在一个事务中恢复现有模块配置表。 -- 恢复成功后业务运行仍读取原有 `s_module` 等表;不切换运行时版本指针,也不改变 `/data/*` 接口。 -- 恢复内容可另存为新的版本记录;禁止覆盖历史快照。 - -因此这项能力是**配置备份、版本查看与恢复**,不是草稿/发布隔离。配置当前仍按现有保存机制生效;若之后出现明确的灰度发布需求,再单独扩展。 - -### 5.2 最小后端改动 - -增加一个模块版本服务,负责: - -1. 创建版本:在数据库事务中读取模块及子配置,验证并序列化快照。 -2. 查询版本:按模块编码列出版本号、说明、创建人和时间。 -3. 恢复版本:校验快照结构和关联目标,在同一事务内更新模块配置,并记录恢复结果。 - -版本操作可通过模块管理 API 暴露;业务数据 API、模块表主键、普通保存流程和 SQL 查询流程不变。版本号由后端分配,不依赖客户端提供。 - -## 6. SQL 与数据安全约定 - -- 客户端不能以任意表名或列名绕过后端标识符验证。 -- 普通查询的筛选值以参数传递;字段、排序方向等 SQL 标识由校验后的配置控制。 -- `b_query_sql` 和 `/data/loaddatabysql` 仅用于只读查询,并使用现有 `SqlText.validateReadOnlySql()` 校验。 -- SQL 配置及执行需经过现有机构、用户和数据范围权限上下文;不能因为使用 SQL 扩展绕过权限校验。 -- 写操作使用 `/data/saveobjt`、`/data/delete` 或已有业务服务,并纳入现有事务和审计规则。 -- 版本恢复同样执行配置权限校验、引用校验和事务处理。 - -## 7. 不在本次范围内 - -- 不重构现有 FMS `DataController` 接口,也不改变 SQL Server、JDBC 或机构数据源路由方式。 -- 不增加 `s_template` / `s_template_version`、`s_module_snapshot`、用户偏好新表或权限/工作流新表。 -- 不强制运行时从 JSON 快照读取模块定义,不引入 `ModuleDefinition` 编译缓存层。 -- 不把 SQL 移到后端固定代码目录;保留模块配置中的可选 SQL 兜底。 -- 不重新定义业务表、菜单、用户、权限、工作流、文件和审计子系统。 -- 不把模块版本管理等同于数据库 migration;业务表结构仍通过已有 SQL migration 管理。 - -## 8. 接入检查清单 - -1. 确认机构/数据库连接按现有路由建立,业务对象位于 `dbo`。 -2. 普通表格和表单配置 `s_module`、`s_field`、`s_module_schema`;复杂查询按需配置 `b_query_sql`。 -3. 用 `/data/describe` 检查实际表/视图字段,用 `s_field` 和视图 JSON 保持编码一致。 -4. 查询、保存、删除分别调用现有通用接口;不从客户端接收未经验证的 DML SQL。 -5. 重要配置变更后显式保存模块版本;恢复时通过版本服务执行,不手工覆盖版本历史表。 diff --git a/code/g3soft-erp/docs/工作流迁移-交接说明.md b/code/g3soft-erp/docs/工作流迁移-交接说明.md deleted file mode 100644 index dd07467b..00000000 --- a/code/g3soft-erp/docs/工作流迁移-交接说明.md +++ /dev/null @@ -1,261 +0,0 @@ -# 工作流模块迁移 —— 交接说明 - -> 用途:把「把原型 fms-api 的工作流引擎迁移进 g3soft-erp/server 并修复一批缺陷」这件事的**当前状态、已定决策、踩过的坑、剩余工作**完整交接出去。 -> 接手者(人或 AI)只读本文件即可继续,不需要回溯之前的对话。 -> 最近更新:2026-10-13 - ---- - -## 一、背景 - -- **原型**:`D:/workspace/code/fms/fms-api`(包名 `cn.g3soft.fmsapi`),含一套**手写的轻量流程引擎**(无 Flowable/Activiti/Camunda 依赖)。 -- **目标**:`d:/workspace/code/g3soft-erp/server`(包名 `cn.g3soft.erp`,Spring Boot 4.1.0 / Java 21 / 纯 JDBC / 无 `@Transactional` / 多机构按 `OrgContext` 路由数据源)。 -- **原始设计文档**:`D:/workspace/code/fms/FMS工作流与审批设计.md`(§ 编号在本文件与代码注释里频繁引用)。 -- **迁移动因**:g3soft-erp 是 fms-api 的续作,工作流当初"未带入";本次要迁入并**同时修掉评审发现的一批缺陷**。 - -> 迁移前的评审结论:设计(文档)质量不错,但实现只落地七八成,且有几个硬伤(串行会签不通、乐观锁是假的、运行时零测试)。详见下文。 - ---- - -## 二、当前状态总览 - -| 阶段 | 内容 | 状态 | -|---|---|---| -| **P0** | 建 `sql/` 目录与脚本;对真库执行补丁 | ✅ | -| **P1** | 8 个 service + 2 个 controller 改包名迁移;2 个测试随迁 | ✅ | -| **P1.5** | 删 `WorkflowConfigService` 私有 SQL 层(修 C2 时间口径) | ✅ | -| **P2** | A1 串行会签 / A2 claim 越权 / A3 投影丢更新;C2/C4/C5/C6/C7 | ✅ | -| **P3** | B1 乐观锁 / B2 锁序死锁 / B3 严格条件解析 | ✅ | -| **C3** | `WorkflowTx` 事务模板;`WorkflowRuntimeService` 8/8 转换;删孤儿方法 | ✅ | -| **P4** | D1 委托 / D2 超时扫描 / D3 路由留痕 / D4 职责分离 / D5 审计探写 | ✅ | -| **P5** | 拆两个上帝类 | 🔶 **仅完成 1 项**(`WorkflowInstanceRow` 抽取) | -| **P6** | 补测试 + 端到端验证 | ❌ **未开始** | - -### 尚未完成、但**已被批准**的一项(容易漏) - -**把工作流的动作权限校验改为复用 `SqlPermissionService`**: -- 已定方案:给 `SqlPermissionService` 加一个**带 `Connection` 的重载**(现有方法自己开连接、不进 workflow 事务),workflow 侧删除私有的重复 `s_power` 查询与硬编码管理员账号(改用 `isAdministrator`)。 -- **现状**:`SqlPermissionService` 没有 Connection 重载;`WorkflowRuntimeService.java:1285` 仍是私有 `requireAction(Connection, moduleId, userId, action)`,内部硬编码 `ADMIN_ACCOUNT = "g3soft"`。 -- 兼容性已核实:`WorkflowConfigService.ensureActionPowers` 写的是 `b_type='action', b_object_type='module', b_object_id=模块, b_action=动作`,与 `SqlPermissionService.requireAction` 的匹配列**一致**,可以直接对接。 -- 附带:`requireEnabledUser` 在目标侧**没有等价实现**,仍是 `WorkflowRuntimeService` 的私有方法,需自行决定是否抽到 `service/auth`。 - ---- - -## 三、环境与命令 - -### 数据库 - -- 配置:`server/config/dbconfigs/G3HD.properties`(**已 gitignore**) - - `url=...databaseName=G3ERP...`,机构码 `G3HD`,主机 `118.89.70.199:1433` -- 该库**已具备**:13 张 `wf_*` 表、核心表(`s_module`/`s_field`/`s_power`/`s_user_power`/`s_menu`/`b_user`/`b_dept`)、组织扩展(`b_position`/`b_user_position`/`b_org_role`/`b_user_org_role`、`b_dept.b_manager_id`、`b_user.b_leader_id`)、审计表(`s_log_audit`/`s_log_audit_field`)。 -- 已有 mock 数据:定义 `expense_approval`(14 节点 / 17 连线 / 13 审批人规则)、版本 `v1`(published) + `v2`(draft)、1 条**停用**绑定 `demo_expense_binding`(模块 `v_b_othercompany`,`b_canuse=0`)、6 个运行实例、31 条任务、4 条抄送。 - -### 构建 / 测试 / 运行 - -```bash -cd d:/workspace/code/g3soft-erp/server -export JAVA_HOME="D:\\devtool\\jdk\\jdk-21" -export PATH="/d/devtool/jdk/jdk-21/bin:$PATH" - -./mvnw.cmd -q -o compile # 编译 -./mvnw.cmd -o test -Dtest='WorkflowConditionEvaluatorTests,WorkflowValidatorTests' # 现有 19 个测试 -./mvnw.cmd -o spring-boot:run -Dspring-boot.run.arguments="--server.port=18090" # 起服务 -``` - -- 上下文:`/api`,默认端口 8088。 -- 工作流接口:`/api/workflow/**`(配置侧)、`/api/workflow/runtime/**`(运行侧),全部 POST、body 为 `Map`。 -- 无 DB 时 `mvnw test` 会因 `ExportServiceIntegrationTests` 失败(它打真库)。 - -### 执行 SQL 脚本 - -仓库里**没有** SQL 运行器(无 Flyway/Liquibase)。本轮是用一个临时 Java 程序(`java -cp Xxx.java`,读 `G3HD.properties`,按 `GO` 分批 `execute`)跑完即删。JDBC 驱动在 `~/.m2/repository/com/microsoft/sqlserver/mssql-jdbc/13.4.0.jre11/`。 - ---- - -## 四、已完成工作明细 - -### 4.1 新增文件 - -**`g3soft-erp/sql/`(10 个,全部 LF)** -``` -README.md # 执行顺序 / 幂等 / 两个引导陷阱 -00_core_companion/workflow_audit_tables.sql # s_log_audit + s_log_audit_field(幂等,D5 用) -10_workflow/01_fms_workflow.sql # 13 张 wf_* 表(不幂等) -10_workflow/02_fms_workflow_org_ext.sql # 组织主数据扩展 -20_registration/10_fms_workflow_module.sql # 4 个视图 + s_module/s_field/s_module_schema/s_menu_module -20_registration/11_fms_workflow_menu.sql # 菜单 + i18n -20_registration/12_fms_workflow_power.sql # 权限点(菜单 + 11 个动作) -30_patches/01_fms_workflow_index_patch.sql # 补 3 个索引(已执行) -30_patches/02_fms_workflow_unique_index_patch.sql # 恢复 4 个缺失的筛选唯一索引(已执行) -90_mock/01_fms_workflow_mock.sql # 示例流程定义 -90_mock/02_fms_workflow_runtime_mock.sql # 示例运行数据 -``` - -**`server/src/main/java/cn/g3soft/erp/service/workflow/`**(迁移 + 新增) - -| 文件 | 行数 | 说明 | -|---|---|---| -| `WorkflowRuntimeService.java` | 1616 | 运行门面(**待拆**) | -| `WorkflowConfigService.java` | 1026 | 配置门面(**待拆**,另 8 处事务样板待转) | -| `WorkflowRuntimeQueryService.java` | 522 | 待办/已办/进度图等读模型 | -| `WorkflowValidator.java` | 402 | 发布期校验 | -| `WorkflowActorResolver.java` | 325 | 审批人解析 | -| `WorkflowDraft.java` | 224 | 领域草稿 + 结构解析 | -| `WorkflowConditionEvaluator.java` | 198 | 条件 AST 求值(纯函数) | -| `WorkflowSql.java` | 97 | 包内 JDBC 小工具 | -| **`WorkflowTx.java`** | — | **新**:事务模板(2 参 / 3 参带恢复钩子 / `isUniqueViolation`) | -| **`WorkflowJson.java`** | — | **新**:JSON 严格/宽松双入口 | -| **`WorkflowConfigException.java`** | — | **新**:配置损坏异常(extends BusinessException) | -| **`WorkflowInstanceRow.java`** | 64 | **新**:由内嵌类提升为顶层包内类 | -| **`WorkflowDelegationService.java`** | — | **新**:委托(D1) | -| **`WorkflowExpirySweepService.java`** | — | **新**:超时扫描(D2) | - -**其他新增** -- `server/src/main/java/cn/g3soft/erp/controller/WorkflowController.java`、`WorkflowRuntimeController.java` -- `server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowConditionEvaluatorTests.java`、`WorkflowValidatorTests.java`(19 个测试,全绿) -- `server/config/dbconfigs/G3HD.properties`(gitignored) - -### 4.2 修改文件 - -- `database/org/OrgDatabaseConfigLoader.java` — 新增 `listOrgIds()`(供无请求上下文的后台任务 fan-out) -- `src/main/resources/application.yaml` — 新增 `erp.workflow.expire-sweep-interval: 5m` - -### 4.3 关键改动点(每项都有对应缺陷) - -| 编号 | 缺陷 | 改法 | -|---|---|---| -| **A1** | 串行会签完全不通:任务建为 `waiting` 后**没有任何语句把它改回 `pending`**,节点永远凑不满票数 | 新增 `promoteNextSerialTask()`:在"未凑够票数"分支把 `(b_seq,b_id)` 最小的 `waiting` 翻成 `pending`,写 `activate` 动作 + outbox | -| **A2** | `claim` 只查动作权限,**不校验调用者是不是候选审批人** → 有 claim 权限可抢任意待办 | 锁 `node_run` 读 `b_actor_snapshot_json.resolvedUsers` 校验;**候选取自快照而非任务表**(`single` 模式只给第一个候选建任务);解析异常→空集合→拒绝(fail-closed) | -| **A3** | 业务状态投影 `update 业务表 set 状态=? where 主键=?`,**无行数校验、无版本谓词** → 静默丢更新 | 读投影前旧值;业务表有 `b_row_version` 时一并自增(否则会让通用保存的乐观锁读到陈旧版本);行数≠1 写 `wf_outbox(instance.projection_missing)` 留痕 | -| **B1** | 文档承诺的乐观锁**是假的**:`b_row_version` 只递增、从不进 `WHERE` | 新增 `updateInstance(...)` 统一入口,追加 `and b_id=? and b_row_version=?` 并校验行数;`WorkflowInstanceRow` 改**可变**(同事务内会多次更新实例) | -| **B2** | 锁序不一致:`act/claim/transfer/addApprover/urge` 是"任务→实例",`withdraw/cancel/finishInstance` 是"实例→任务" → **AB-BA 死锁** | 统一为**实例优先**:新增 `lockTask(...)`(无锁预读拿 instance_id → `lockInstance` → UPDLOCK 重读并复查状态/归属);`cancelSiblingTasks` 加 `UPDLOCK` | -| **B3** | 脏条件 JSON 被静默降级成空 → **当成"无条件边"命中**,静默错路由;绑定匹配同理 | 新增 `WorkflowJson.readMapOrNull`(严格)/`readMapOrEmpty`(宽松);`WorkflowDraft.conditionOf` 在**发布期**就拒绝"给了值却不是对象";`advance` 捕获 `WorkflowConfigException` → `failInstance` 置 error **并保留本次表决** | -| **C2** | `WorkflowConfigService` 私有复制了一份 `WorkflowSql`,**漏了 `TimeUtils.toApiValue`** → 配置侧时间出参不带 `Z` | 删除私有层,49 处调用统一走 `WorkflowSql` | -| **C4** | 缺 `wf_binding(b_version_id)`、`wf_task(b_completed_by)`、`wf_task(node_run,status,seq)` | `sql/30_patches/01_*`(已在真库执行) | -| **C5** | `wf_actor_rule` 的 `b_condition_json`/`b_fallback_type`/`b_fallback_ref` 解析器会读、但设计器**从来写不进去** | 扩展 `WorkflowDraft.ActorRule`(保留 2 参便捷构造以免测试改动);`writeNode` 补写、`loadDraft` 回读(**严格**,因为 loadDraft 是发布校验的输入);`WorkflowValidator` 补兜底类型 + 规则条件校验 | -| **C6** | `return_previous` 重进上一节点时**没关当前 node_run** → 永久 `running`,会让并行汇合误放行 | 补 `completeNodeRun(...,"completed")` | -| **C7** | `service_task` 的动作码被误标成 `"cc"`(看起来像抄送) | 改为 `"service"`,措辞去掉"第一阶段占位" | -| **C3** | 事务样板块板 | 新增 `WorkflowTx`;`WorkflowRuntimeService` 8/8 转换;删私有 `rollback`/`restoreAutoCommit`/`isUniqueViolation` | -| **D1** | `wf_delegation` 表建了但 **Java 零引用** | `WorkflowDelegationService`;**任务生成时物化**到代理人(`b_assignee_type='delegate'`),原审批人+委托单号进任务快照;动作期 `verifyDelegation` 复核 `actions` 范围 | -| **D2** | `expired` 状态从不写入、没有调度器 | `WorkflowExpirySweepService`(裸 `ScheduledExecutorService`,无请求上下文时按机构 fan-out) | -| **D3** | §9.5 要求的路由留痕从未实现 | `chooseEdge` 输出决策轨迹(每条出线的求值结果 / 是否默认边);`advance` 在**出线>1 的分支点**写一条 `route` 动作 | -| **D4** | §10.3 职责分离策略从不读取(只读了 `allowClaim`) | `WorkflowActorResolver.resolve` 加 `excludeUserIds`;`excludedActors(...)` 支持 `allowInitiator=false` / `excludeCreator` / `excludePreviousActors`;新增三态 `configFlagOrNull` 区分"没配"与"配了 false" | -| **D5** | §11 要求写 `s_log_audit` 但目标库原来没有该表 | 探写:表存在则写事件 + 字段级前后值;不存在写 `wf_outbox(audit.skipped)` 并继续 | - ---- - -## 五、数据库侧的两个重要发现 - -### 5.1 四个"筛选唯一索引"在真库里全部缺失(已修) - -`fms_workflow.sql` 里定义了 4 个 filtered unique index,但 G3ERP 库里**一个都没有**。后果是: - -| 缺失索引 | 后果 | -|---|---| -| `ux_wf_instance_idempotency` | **`submit` 幂等键形同虚设**——重复提交不被拦(代码靠捕获 SQL 2601/2627/23000 做幂等恢复,索引不在就等于没有) | -| `ux_wf_action_request` | **重复审批不被拦** | -| `ux_wf_version_draft` | 同一定义可并存多条草稿 | -| `ux_wf_binding_active_priority` | 同模块/事件可有两条同优先级启用绑定 | - -`sql/30_patches/02_*.sql` 已幂等恢复(创建前先查重,冲突则 `RAISERROR` 报错)。**已在真库执行并确认 7 个索引全部建上。** - -### 5.2 权限点脚本的引导顺序陷阱 - -`20_registration/12_fms_workflow_power.sql` 的动作权限点由 -`select distinct b_module_id from wf_binding where b_canuse=1` 生成——**binding 为空时生成 0 条**。 -缓解:① `sql/README.md` 写明了顺序;② 应用侧 `WorkflowConfigService.saveBinding` 每次保存绑定都会调 `ensureActionPowers` 自愈;③ 脚本可重复执行。 - ---- - -## 六、剩余工作 - -### 6.1 P5:拆两个上帝类(纯移动,零功能价值) - -**`WorkflowRuntimeService`(1616 行)→** - -| 新类 | 职责 / 方法 | -|---|---| -| `WorkflowRuntimeService` | 对外门面,持事务边界,public 方法签名**保持不变** | -| `WorkflowRoutingEngine` | `advance` / `enterNode` / `activateUserTask` / `runCcTask` / `reenterPreviousNode` / `chooseEdge` / `outgoingEdges` / `incomingEdges` / `allIncomingCompleted` / `recordNodeRun` / `completeNodeRun` | -| `WorkflowTaskService` | `claim` / `transfer` / `addApprover` / `urge` / `cancelSiblingTasks` / `cancelAllTasks` / `promoteNextSerialTask` / `writeAction` / `writeOutbox` / `requiredCount` / `dueMinutesOf` | -| `WorkflowInstanceService` | `withdraw` / `cancel` / `finishInstance` / `failInstance` / `lockInstance` / `updateInstance` / `projectBusinessStatus` / `writeAudit` / `requireAction` / `requireEnabledUser` | -| `WorkflowSubmissionService` | `submit` / `matchBinding` / `submittedResult` / `currentStatus` | - -**`WorkflowConfigService`(1026 行)→** `WorkflowDefinitionService` / `WorkflowVersionService` / `WorkflowBindingService` / `WorkflowDraftMapper`,门面保留、控制器签名不变。`WorkflowDraft` / `WorkflowValidator` / `WorkflowActorResolver` 原地保留;`WorkflowSql` 保持包内可见。 - -**拆类时顺手做完 C3 的余量**:`WorkflowConfigService` 还有 **8 处手写事务样板**(`restoreAutoCommit(connection, originalAutoCommit)` 在 150/210/287/372/404/487/590/611 附近),改用 `WorkflowTx.run(...)`,然后删掉它私有的 `rollback` / `restoreAutoCommit`。 - -> **注意**:`WorkflowConfigService` 的 8 处**是否与 RuntimeService 的一样统一**,接手时要**先核实再动手**(本轮在 RuntimeService 上发现"16 处里有 2 处不同",见 §七.2)。 - -### 6.2 P6:测试 + 端到端(**建议先做这个,见 §八**) - -**迁移测试**(已存在,2 个文件 19 个用例,只改了 package)。 - -**新增纯单测**(`cn.g3soft.erp.service.workflow`,JUnit 5 + AssertJ): - -| 覆盖 | 测试类 | -|---|---| -| `WorkflowJson` 严格/宽松(缺省→null;损坏→抛;非对象→抛;合法→Map) | `WorkflowJsonTests` | -| 委托 scope 匹配真值表(空数组=不限、moduleIds/orgIds/actions) | `WorkflowDelegationTests` | -| 投影守卫决策矩阵(有/无版本列 × 行数 0/1) | `WorkflowProjectionGuardTests` | -| `WorkflowDraft.ActorRule` 的 condition/fallback 往返解析 | `WorkflowDraftTests` | -| 职责分离三态开关(`configFlagOrNull` + `excludedActors` 语义) | `WorkflowActorResolverTests`(纯函数部分) | -| 串行提升的排序选择、`successorCount(serial)=候选数` | `WorkflowSerialTests` | -| `WorkflowInstanceRow` 内存版本推进 | `WorkflowInstanceRowTests` | - -**DB 集成测试**(真机 SQL Server,仿 `ExportServiceIntegrationTests`:`@SpringBootTest` + `OrgContext.setOrgId("G3HD")` + `setUserId("g3soft")`): - -A1 串行三连(通过→提升→中途驳回→waiting 取消→全通过→节点完成)/A2 非候选 claim 被拒/A3 投影并发冲突与 0 行/B1 陈旧版本更新失败/B2 两线程 `act` vs `withdraw` 无死锁(SQL 无 1205)/B3 库中坏条件→实例 `error`/C2 配置侧时间出参带 `Z`/C6 `return_previous` 不留 `running` node_run/C7 动作码为 `service`/D1 委托物化/D2 超时只标记不改流程/D3 `route` 动作落库/D5 审计两表落库。 - -> **门禁**:这些 DB 测试**必须加 `@Tag("db")` + Maven profile(默认不随 `mvnw test` 跑)**,否则在没有 workflow 表的库上会构建失败。 - -**端到端(真机)**:启用 `demo_expense_binding`(把 `b_canuse` 置 1),走完 mock 的 6 个实例 + 串行链 + 委托 + 超时 + 审计;确认无死锁、日志正常。 - ---- - -## 七、踩过的坑(接手者务必先看) - -1. **换行符**:fms-api 源文件是 **CRLF**,g3soft-erp 的 `.gitattributes` 强制 `*.java`/`*.sql`/`*.md` 为 **LF**。迁移时逐文件 `sed 's/\r$//'`。 -2. **事务样板"看似统一、实则不统一"**:`WorkflowRuntimeService` 的 16 个块里,只有 6 个是规整的 `catch (SQLException | RuntimeException)`;`submit` / `act` 各自**拆成两段 catch**,SQLException 分支要**先回滚、再查库**判定是否重复请求。简单模板表达不了这个语义——这就是 `WorkflowTx` 有 **3 参版 + `SqlErrorHandler` 钩子**的原因。**不要试图用统一脚本盲改。** -3. **lambda 体里的两条硬约束**(本轮编译失败两次): - - 被捕获的局部变量必须 **effectively final**。`event`(`if (event.isEmpty()) event = "submit"`)和 `deptId`(连续三次回退赋值)都是因此改成单次表达式。 - - **回调里不要自己 commit**。转换后有 6 处遗留了 `connection.commit();`,必须删掉(模板负责提交;留着会破坏恢复钩子的事务语义)。 -4. **`WorkflowInstanceRow` 必须可变**:一个事务内可能对同一实例多次更新(`activateUserTask` 写摘要 → 随后 `failInstance` 改状态)。每次成功更新都要推进**内存版本**,否则第二次更新会拿旧版本号去比对上一条 UPDATE 已经 +1 的值而误判"并发推进"。 -5. **机械替换后一定要看"孤儿方法"是否真的孤儿**:本轮原以为转换完 `rollback`/`restoreAutoCommit` 就没人用了,实际 `submit`/`act` 还在用,要等那两处也转换完才成立。 -6. **`wf_action.b_action` 词表被扩展**了 4 个码:`activate`(串行提升)、`service`(服务节点)、`route`(路由留痕)、`expire`(超时)。该列**无 CHECK 约束**,不需改表;但下游若按枚举过滤要同步。 -7. **超时扫描有真实副作用**:默认间隔 5 分钟,启动后会把**已到期且仍 pending/claimed** 的任务标成 `expired`。当前库里的示例任务有 31 条带 `b_due_at`,**其中已到期的会被标记**。不想动示例数据就把 `erp.workflow.expire-sweep-interval` 设为 `0s`。 -8. **`WorkflowActorResolver.resolve` 多了第 6 个参数** `excludeUserIds`(D4);抄送节点传 `null`(不参与审批判定,不做职责分离)。 -9. **`WorkflowJson` 用独立 `ObjectMapper` 只做读取**;序列化仍走各服务注入的 mapper(保留项目 `JacksonConfig` 对 Long→String 的处理)。不要把它改成序列化工具。 -10. **`excludeCreator` 目前以实例发起人近似**(业务表创建人字段未统一),代码注释已标明,待业务口径统一后再换。 - ---- - -## 八、已定的设计决策(沿用时不要再讨论) - -| 议题 | 决定 | -|---|---| -| 修复范围 | 全部 A+B+C+D(缺陷 + 并发 + 工程债含拆类 + 补齐文档承诺未实现的功能) | -| SQL 落地 | 新建 `g3soft-erp/sql/`,DDL + 注册 + mock | -| 权限服务 | 给 `SqlPermissionService` 加带 `Connection` 的重载,workflow 共享;删硬编码管理员 —— **⚠️ 尚未实现** | -| 测试 | 每个修复项补单测;DB 依赖的加门禁 | -| **D2 超时语义** | **只标记不自动改流程**:任务置 `expired` 并从待办过滤,实例保持 `running`,由管理员加签/转派。**不自动提升串行后继、不置 error、不自动通过** | -| **A3 投影 0 行** | 写 `wf_outbox(instance.projection_missing)` 并继续,不阻断审批 | -| **B3 坏条件 JSON** | 运行期重入该节点时实例置 `error`,**保留本次已记录的表决** | -| **C3 顺序** | 分两步:`WorkflowRuntimeService` 现在做(已完成);`WorkflowConfigService` 并进 P5 | -| **提交时点** | 接受"先 commit 再 return(且返回前查库)"的那 10 处读**挪进事务内**(结果相同,失败时改为回滚,更原子) | -| 前端 | **不改**(审批交互留在后端,前端照旧) | - ---- - -## 九、待确认事项 - -1. **P5 与 P6 的先后**:建议**先补测试(P6a)再拆类(P5)**。原因见下——这块大搬移目前**只有编译能验证**。 -2. **`excludeCreator` 的真实字段来源**:需要业务表创建人的统一口径。 -3. **`requireEnabledUser` 是否抽到 `service/auth`**。 -4. **串行链中当前审批人超期会停住**(D2 只标记不前进的必然结果)——确认这是可接受的运维语义。 - ---- - -## 十、给接手者的一句话提醒 - -**先补测试,再动 P5 的搬移。** 本轮在 RuntimeService 上做"以为机械"的替换时,编译失败过两次(lambda final、构造函数名),而那是 6 处、有明确模式的小改动;P5 是在**没有任何运行时测试**的 1600 行类上做结构性搬移(`RoutingEngine` 与 `TaskService`/`InstanceService` 互相调用),验证手段仍然只有编译。测试是这次搬移的唯一安全网,而它现在还不存在。 diff --git a/code/g3soft-erp/docs/核心表结构设计.md b/code/g3soft-erp/docs/核心表结构设计.md deleted file mode 100644 index b0ea9f95..00000000 --- a/code/g3soft-erp/docs/核心表结构设计.md +++ /dev/null @@ -1,195 +0,0 @@ -# FMS 后端核心表结构设计 - -> 文档版本:V1.1(精简方案) -> 编制日期:2026-10-12 -> 状态:待评审 -> 数据库:SQL Server -> 配套文档:[后端架构设计.md](./后端架构设计.md) - -## 1. 范围与原则 - -本文沿用现有 FMS 的模块表结构和 SQL 驱动方式,只说明模块运行需要的核心元数据表,并增加一张轻量的模块版本历史表。业务表、用户/权限、菜单、多语言、工作流等既有表和接口不在本文重定义。 - -- 业务数据仍保存在 SQL Server 的真实表和视图中。 -- `s_module`、`s_field`、`s_module_schema`、`s_relation` 描述模块及其界面、字段和关联;不采用 EAV 存业务记录。 -- 常规功能用模块配置;配置不够或 SQL 更直接时,可通过模块的 `b_query_sql` 使用 SQL 查询。 -- 运行时继续读取现有模块配置表;版本表仅保存可查看、可恢复的历史配置,不引入新的运行时快照目录。 -- 保持现有命名和数据类型;不增加模板体系、草稿/发布状态机、额外快照表或元数据编译器。 -- 与现有 FMS 数据库约定一致:模块关联完整性、JSON 和类型校验由应用层负责,不依赖外键、`CHECK` 约束或触发器。 - -## 2. 核心表清单 - -| 表 | 职责 | 是否新增 | -|---|---|---| -| `s_module` | 模块树、查询来源、保存目标和默认行为 | 否,沿用现有设计 | -| `s_field` | 模块字段编码、名称和业务类型 | 否,沿用现有设计 | -| `s_module_schema` | 列表、表单、查询等界面 JSON 配置 | 否,沿用现有设计 | -| `s_relation` | 模块之间的字段关系 | 否,沿用现有设计 | -| `s_module_version` | 手动保存的模块配置历史及恢复来源 | 是,唯一新增表 | - -现有 `s_user_module_pref`、`s_autocode`、`s_delete_rule` 等辅助配置表继续按原设计使用;权限、菜单和工作流表也保持原状。它们不因本方案改名或迁移。 - -## 3. 模块 `s_module` - -```sql -create table dbo.s_module ( - b_id varchar(50) not null primary key, -- 模块编码,稳定业务键 - b_parent_id varchar(50) null, -- 父模块编码,根节点为空 - b_depth int not null default 0, -- 模块树层级,根节点为 0 - b_path varchar(1000) not null, -- 模块树路径,由应用层维护 - b_name nvarchar(200) not null, -- 模块名称 - b_i18n varchar(150) null, -- 多语言资源编码 - b_module_type varchar(20) not null, -- module / data / virtual - b_view_table varchar(128) null, -- 默认查询表或视图名 - b_save_table varchar(128) null, -- 默认保存目标表名 - b_key_field varchar(50) null, -- 数据主键字段 - b_scope_field varchar(50) null, -- 数据范围字段,如记录归属人字段 - b_order_sql varchar(500) null, -- 默认排序规则 - b_query_sql nvarchar(max) null, -- 可选查询 SQL 或 SQL 模板 - b_config_json nvarchar(max) null, -- 低频模块扩展配置 - b_canuse tinyint not null default 1, -- 是否启用:1 启用,0 停用 - b_xh int not null default 0, -- 同级模块显示顺序 - b_bz nvarchar(2000) null, -- 备注 - b_updated_at datetime2 null -- 模块配置最后保存时间 -); - -create index ix_s_module_parent - on dbo.s_module (b_parent_id, b_canuse, b_depth, b_xh, b_id); - -create index ix_s_module_path - on dbo.s_module (b_path, b_canuse, b_id); - -create index ix_s_module_type - on dbo.s_module (b_module_type, b_canuse, b_xh, b_id); - -create index ix_s_module_view_table - on dbo.s_module (b_view_table, b_canuse, b_id); - -create index ix_s_module_save_table - on dbo.s_module (b_save_table, b_canuse, b_id); -``` - -### 3.1 字段约定 - -- `module` 是模块分组;`data` 是标准数据模块;`virtual` 是由查询 SQL 或业务实现提供数据的虚拟模块。 -- `b_view_table` 是普通查询的数据源;配置 `b_query_sql` 时,模块查询使用该 SQL;两者都没有时,由虚拟模块的业务实现提供数据。 -- `b_save_table` 和 `b_key_field` 描述标准保存目标;业务写入仍走现有保存接口或业务服务。 -- `b_query_sql` 是可选的 SQL 兜底配置,不是后端代码中的固定 SQL 编码。可按现有机构/数据库配置维护不同 SQL。它用于查询,不替代标准保存流程。 -- `b_order_sql` 保存该模块的默认排序;值按现有 SQL 标识符和排序校验规则处理。 -- `b_parent_id` 是模块树的权威关系;`b_depth`、`b_path` 是派生字段,新增或移动节点时由应用层在事务内维护。 - -## 4. 字段 `s_field` - -```sql -create table dbo.s_field ( - b_module_id varchar(50) not null, -- 所属 data / virtual 模块编码 - b_field varchar(50) not null, -- 模块字段编码或查询结果别名 - b_name nvarchar(200) not null, -- 字段名称 - b_i18n varchar(150) null, -- 多语言资源编码 - b_type varchar(30) not null, -- 字段业务类型 - b_canuse tinyint not null default 1, -- 是否启用:1 启用,0 停用 - b_xh int not null default 0, -- 字段默认顺序 - primary key (b_module_id, b_field) -); - -create index ix_s_field_module_order - on dbo.s_field (b_module_id, b_canuse, b_xh, b_field); -``` - -`b_type` 表示业务层字段类型;SQL Server 实际类型通过表/视图结构探测获得,不在 `s_field` 重复保存。字段分组、控件、必填、只读、显示顺序等界面行为放在 `s_module_schema` 的 JSON 中。 - -## 5. 界面配置 `s_module_schema` - -```sql -create table dbo.s_module_schema ( - b_module_id varchar(50) not null, -- 所属 data / virtual 模块编码 - b_schema_type varchar(20) not null, -- view / edit / query - b_schema_json nvarchar(max) not null, -- 对应界面的完整 JSON 配置 - b_canuse tinyint not null default 1, -- 是否启用:1 启用,0 停用 - b_updated_by varchar(50) null, -- 最后修改人编码 - b_updated_at datetime2 null, -- 最后修改时间 - primary key (b_module_id, b_schema_type) -); - -create index ix_s_module_schema_type - on dbo.s_module_schema (b_module_id, b_schema_type, b_canuse); -``` - -每个模块按需配置 `view`(列表)、`edit`(表单)、`query`(查询条件)。每种类型当前一份 JSON;JSON 结构版本由 JSON 顶层的 `schemaVersion` 管理。字段编码引用 `s_field.b_field`。个人偏好仍使用现有 `s_user_module_pref`,不混入系统默认配置。 - -## 6. 模块关系 `s_relation` - -```sql -create table dbo.s_relation ( - b_source_module_id varchar(50) not null, -- 源模块编码 - b_source_field varchar(50) not null, -- 源字段编码 - b_target_module_id varchar(50) not null, -- 目标模块编码 - b_target_field varchar(50) not null, -- 目标字段编码 - b_relation_type varchar(20) not null, -- one_to_one / one_to_many / many_to_one - b_on_delete varchar(20) not null default 'restrict', -- restrict / cascade / none - b_canuse tinyint not null default 1, -- 是否启用:1 启用,0 停用 - b_xh int not null default 0, -- 关系显示顺序 - primary key ( - b_source_module_id, - b_source_field, - b_target_module_id, - b_target_field - ) -); - -create index ix_s_relation_source - on dbo.s_relation (b_source_module_id, b_canuse, b_xh, b_source_field, b_target_module_id); - -create index ix_s_relation_target - on dbo.s_relation (b_target_module_id, b_canuse, b_source_module_id); -``` - -模块树只表达业务分组;主子表、引用和多对多关系通过 `s_relation` 或中间表模块表达。关系目标及删除策略由应用层校验,不建立通用业务外键。 - -## 7. 模块版本历史 `s_module_version` - -该表是本方案唯一新增表。它保存模块配置的历史副本,用于查看和恢复;不引入模板版本、独立快照表、发布状态机或运行时版本目录。 - -```sql -create table dbo.s_module_version ( - b_module_id varchar(50) not null, -- 所属模块编码 - b_version_no int not null, -- 模块内递增版本号 - b_snapshot_json nvarchar(max) not null, -- 该版本的完整模块配置快照 - b_change_note nvarchar(1000) null, -- 版本说明 - b_created_by varchar(50) null, -- 创建版本的用户编码 - b_created_at datetime2 not null default sysdatetime(), -- 创建版本时间 - primary key (b_module_id, b_version_no) -); -``` - -快照 JSON 使用独立的 `snapshotVersion` 描述快照格式。它至少包含模块行、字段行、界面配置和相关关系;如需完整恢复模块行为,也包含该模块对应的自动编码、删除规则等配置。示意: - -```json -{ - "snapshotVersion": 1, - "module": {}, - "fields": [], - "schemas": [], - "relations": [], - "autocodes": [], - "deleteRules": [] -} -``` - -### 7.1 保存与恢复规则 - -1. 管理员显式创建版本时,后端在同一事务中读取当前模块配置并写入 `s_module_version`;版本记录创建后不修改。 -2. `b_version_no` 按模块递增;并发创建时由后端事务锁定模块并分配下一个号码。 -3. 恢复版本时先校验快照格式和模块引用,再在一个事务内将快照恢复到现有配置表。 -4. 恢复操作成功后,当前运行行为仍由 `s_module` 等现有配置表决定;为保留操作历史,可将恢复结果另存为一个新版本,不覆盖旧版本。 -5. 版本快照只保存模块配置,不保存业务数据、用户个人偏好、权限授权、菜单、翻译内容或数据库建表脚本。 - -这套版本历史不提供独立草稿或“线上发布版本”隔离:模块配置仍按现有方式生效。若未来确实需要草稿/发布隔离,再单独评估,不作为本次改造前提。 - -## 8. 既有表与校验边界 - -- `s_user_module_pref`:沿用个人视图偏好;它是用户级显示覆盖,不属于模块版本快照。 -- `s_autocode`、`s_delete_rule`:继续按现有规则工作;模块版本需要完整回滚时,其模块配置一并纳入快照。 -- `s_menu`、权限、多语言、日志、工作流及业务表:沿用各自现有设计,不在本文件重建。 -- 模块/字段/关系存在性、`b_module_type`、`b_schema_type` 和 JSON 内容由后端校验;不新增外键、`CHECK` 或触发器。 -- 业务数据库结构变更仍通过 SQL migration 管理;模块版本不替代数据库 migration。 diff --git a/code/g3soft-erp/server/pom.xml b/code/g3soft-erp/server/pom.xml index 6dc046cc..a81614ab 100644 --- a/code/g3soft-erp/server/pom.xml +++ b/code/g3soft-erp/server/pom.xml @@ -15,6 +15,12 @@ erp-api 21 + + db @@ -96,7 +102,29 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + + ${excluded.groups} + + + + + + db-tests + + + + + + diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/permission/SqlPermissionService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/permission/SqlPermissionService.java index da4fffd0..5d49f277 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/permission/SqlPermissionService.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/permission/SqlPermissionService.java @@ -56,8 +56,30 @@ public class SqlPermissionService { /** * 动作权限校验。白名单语义:s_power 中不存在该权限点、或用户未被授权、 * 或任一侧 b_canuse = 0 时一律拒绝。 + * + *

自己取连接:适用于**没有外层事务**的调用方。已在事务里的调用方必须用 + * {@link #requireAction(Connection, String, String, String)},否则既读不到未提交的授权变更, + * 也会从池里多占一条连接。 */ public void requireAction(String moduleId, String userId, String action) { + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + requireAction(connection, moduleId, userId, action); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 动作权限校验(复用调用方的事务连接)。 + * + *

匹配口径:{@code s_power} 上 {@code b_object_type='module'} + {@code b_object_id=模块} + + * {@code b_action=动作},两侧 {@code b_canuse=1} 且授权行指向该用户。 + * 与 {@code WorkflowConfigService.ensureActionPowers} 写入的权限点列一致,因此工作流可以直接对接。 + * + * @throws BusinessException 没有权限时抛出;消息里带上具体的模块与动作,便于使用者定位 + */ + public void requireAction(Connection connection, String moduleId, String userId, String action) { if (isAdministrator(userId)) { return; } @@ -65,15 +87,13 @@ public class SqlPermissionService { + "JOIN [s_user_power] up ON up.[b_power_id] = p.[b_id] AND up.[b_canuse] = 1 " + "WHERE p.[b_object_type] = 'module' AND p.[b_object_id] = ? AND p.[b_action] = ? " + "AND p.[b_canuse] = 1 AND up.[b_user_id] = ?"; - Connection connection = DataSourceUtils.getConnection(dataSource); try { if (query(connection, sql, moduleId, action, userId).isEmpty()) { - throw new BusinessException("没有导出权限"); + throw new BusinessException( + "没有模块「" + moduleId + "」的动作权限「" + action + "」,请联系管理员授权"); } } catch (SQLException exception) { throw new BusinessException("权限校验失败: " + exception.getMessage()); - } finally { - DataSourceUtils.releaseConnection(connection, dataSource); } } diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowActorResolver.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowActorResolver.java index c4eba835..57a44b37 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowActorResolver.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowActorResolver.java @@ -1,6 +1,7 @@ package cn.g3soft.erp.service.workflow; import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.service.permission.SqlPermissionService; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import tools.jackson.databind.ObjectMapper; @@ -30,8 +31,8 @@ import java.util.Set; @Service public class WorkflowActorResolver { - /** 兜底到管理员时使用的账号:与 SqlPermissionService.ADMIN_ACCOUNT 同一口径 */ - private static final String ADMIN_ACCOUNT = "g3soft"; + /** 兜底到管理员时使用的账号:直接复用权限服务的口径,避免两处定义漂移 */ + private static final String ADMIN_ACCOUNT = SqlPermissionService.ADMIN_ACCOUNT; @Resource private ObjectMapper objectMapper; diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigService.java index d6bb7ceb..b612a6d6 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigService.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowConfigService.java @@ -32,8 +32,9 @@ import java.util.regex.Pattern; * (设计 §6.2 / §12 / §13 明确「不能使用通用表保存接口直接承载流程动作」), * 因此这里是一组受控服务方法:前端只提交领域草稿,由服务端决定落到 wf_* 的哪几行。 * - *

事务:与 DataSaveService 同口径 —— 手动 setAutoCommit(false) / commit / rollback, - * 全项目不使用 @Transactional。 + *

事务:与 DataSaveService 同口径 —— 手写事务边界,全项目不使用 @Transactional; + * 具体样板(取连接 / 关自动提交 / 提交 / 回滚 / 还原)统一走 {@link WorkflowTx},本类不再自己写。 + * 注意「校验不通过就返回」这类**早返回**分支:回调正常返回即视为提交,不要在里面自己 commit。 * *

连接:注入的 DataSource 是 @Primary 的 OrgRoutingDataSource, * 按当前请求 OrgContext 路由到机构库,不需要显式取机构连接。 @@ -98,58 +99,48 @@ public class WorkflowConfigService { boolean canuse = !Boolean.FALSE.equals(params.get("canuse")); String operator = currentUser(); - try (Connection connection = dataSource.getConnection()) { - boolean originalAutoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try { - Map result = new LinkedHashMap<>(); - result.put("definitionId", definitionId); + return WorkflowTx.run(dataSource, connection -> { + Map result = new LinkedHashMap<>(); + result.put("definitionId", definitionId); - if (WorkflowSql.queryOne(connection, "select b_id from dbo.wf_definition where b_id = ?", definitionId) != null) { - WorkflowSql.update(connection, """ - update dbo.wf_definition - set b_name = ?, b_i18n = ?, b_canuse = ?, b_bz = ?, - b_updated_by = ?, b_updated_at = sysutcdatetime() - where b_id = ? - """, name, i18n, canuse ? 1 : 0, bz, operator, definitionId); - Map draft = WorkflowSql.queryOne(connection, """ - select top 1 b_id from dbo.wf_version - where b_definition_id = ? and b_status = ? - order by b_version_no desc - """, definitionId, STATUS_DRAFT); - result.put("versionId", draft == null ? null : draft.get("b_id")); - result.put("created", false); - } else { - if (!DEFINITION_ID_PATTERN.matcher(definitionId).matches()) { - throw new BusinessException( - "流程编码只能以字母开头,由字母、数字、下划线组成,且不超过 50 个字符"); - } - WorkflowSql.update(connection, """ - insert into dbo.wf_definition - (b_id, b_name, b_i18n, b_canuse, b_bz, - b_created_by, b_created_at, b_updated_by, b_updated_at) - values (?, ?, ?, ?, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) - """, definitionId, name, i18n, canuse ? 1 : 0, bz, operator, operator); - String versionId = versionIdOf(definitionId, 1); - WorkflowSql.update(connection, """ - insert into dbo.wf_version - (b_id, b_definition_id, b_version_no, b_status, b_schema_version, - b_created_by, b_created_at, b_updated_by, b_updated_at) - values (?, ?, 1, ?, 1, ?, sysutcdatetime(), ?, sysutcdatetime()) - """, versionId, definitionId, STATUS_DRAFT, operator, operator); - result.put("versionId", versionId); - result.put("created", true); + if (WorkflowSql.queryOne(connection, "select b_id from dbo.wf_definition where b_id = ?", definitionId) != null) { + WorkflowSql.update(connection, """ + update dbo.wf_definition + set b_name = ?, b_i18n = ?, b_canuse = ?, b_bz = ?, + b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, name, i18n, canuse ? 1 : 0, bz, operator, definitionId); + Map draft = WorkflowSql.queryOne(connection, """ + select top 1 b_id from dbo.wf_version + where b_definition_id = ? and b_status = ? + order by b_version_no desc + """, definitionId, STATUS_DRAFT); + result.put("versionId", draft == null ? null : draft.get("b_id")); + result.put("created", false); + } else { + if (!DEFINITION_ID_PATTERN.matcher(definitionId).matches()) { + throw new BusinessException( + "流程编码只能以字母开头,由字母、数字、下划线组成,且不超过 50 个字符"); } - - connection.commit(); - return result; - } catch (SQLException | RuntimeException exception) { - rollback(connection, exception); - throw exception; - } finally { - restoreAutoCommit(connection, originalAutoCommit); + WorkflowSql.update(connection, """ + insert into dbo.wf_definition + (b_id, b_name, b_i18n, b_canuse, b_bz, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, ?, ?, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, definitionId, name, i18n, canuse ? 1 : 0, bz, operator, operator); + String versionId = versionIdOf(definitionId, 1); + WorkflowSql.update(connection, """ + insert into dbo.wf_version + (b_id, b_definition_id, b_version_no, b_status, b_schema_version, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, 1, ?, 1, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, versionId, definitionId, STATUS_DRAFT, operator, operator); + result.put("versionId", versionId); + result.put("created", true); } - } + + return result; + }); } /** @@ -161,55 +152,45 @@ public class WorkflowConfigService { public Map deleteDefinition(Map params) throws SQLException { String definitionId = ParamUtils.getRequiredString(params, "definitionId"); - try (Connection connection = dataSource.getConnection()) { - boolean originalAutoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try { - Map active = WorkflowSql.queryOne(connection, """ - select count(1) as cnt from dbo.wf_instance - where b_definition_id = ? and b_status in (?, ?, ?) - """, definitionId, - "running", "returned", "error"); - long running = (long) number(active == null ? null : active.get("cnt"), 0).doubleValue(); - if (running > 0) { - throw new BusinessException("该流程还有 " + running + " 个未结束的实例,不能删除"); - } - - List versionIds = WorkflowSql.query(connection, - "select b_id from dbo.wf_version where b_definition_id = ?", definitionId) - .stream().map(row -> String.valueOf(row.get("b_id"))).toList(); - - int bindings = 0; - if (!versionIds.isEmpty()) { - String placeholders = String.join(", ", Collections.nCopies(versionIds.size(), "?")); - bindings = WorkflowSql.update(connection, - "delete from dbo.wf_binding where b_version_id in (" + placeholders + ")", - versionIds.toArray()); - WorkflowSql.update(connection, - "delete from dbo.wf_actor_rule where b_version_id in (" + placeholders + ")", - versionIds.toArray()); - WorkflowSql.update(connection, - "delete from dbo.wf_edge where b_version_id in (" + placeholders + ")", - versionIds.toArray()); - WorkflowSql.update(connection, - "delete from dbo.wf_node where b_version_id in (" + placeholders + ")", - versionIds.toArray()); - } - WorkflowSql.update(connection, "delete from dbo.wf_version where b_definition_id = ?", definitionId); - WorkflowSql.update(connection, "delete from dbo.wf_definition where b_id = ?", definitionId); - - Map result = new LinkedHashMap<>(); - result.put("versions", versionIds.size()); - result.put("bindings", bindings); - connection.commit(); - return result; - } catch (SQLException | RuntimeException exception) { - rollback(connection, exception); - throw exception; - } finally { - restoreAutoCommit(connection, originalAutoCommit); + return WorkflowTx.run(dataSource, connection -> { + Map active = WorkflowSql.queryOne(connection, """ + select count(1) as cnt from dbo.wf_instance + where b_definition_id = ? and b_status in (?, ?, ?) + """, definitionId, + "running", "returned", "error"); + long running = (long) number(active == null ? null : active.get("cnt"), 0).doubleValue(); + if (running > 0) { + throw new BusinessException("该流程还有 " + running + " 个未结束的实例,不能删除"); } - } + + List versionIds = WorkflowSql.query(connection, + "select b_id from dbo.wf_version where b_definition_id = ?", definitionId) + .stream().map(row -> String.valueOf(row.get("b_id"))).toList(); + + int bindings = 0; + if (!versionIds.isEmpty()) { + String placeholders = String.join(", ", Collections.nCopies(versionIds.size(), "?")); + bindings = WorkflowSql.update(connection, + "delete from dbo.wf_binding where b_version_id in (" + placeholders + ")", + versionIds.toArray()); + WorkflowSql.update(connection, + "delete from dbo.wf_actor_rule where b_version_id in (" + placeholders + ")", + versionIds.toArray()); + WorkflowSql.update(connection, + "delete from dbo.wf_edge where b_version_id in (" + placeholders + ")", + versionIds.toArray()); + WorkflowSql.update(connection, + "delete from dbo.wf_node where b_version_id in (" + placeholders + ")", + versionIds.toArray()); + } + WorkflowSql.update(connection, "delete from dbo.wf_version where b_definition_id = ?", definitionId); + WorkflowSql.update(connection, "delete from dbo.wf_definition where b_id = ?", definitionId); + + Map result = new LinkedHashMap<>(); + result.put("versions", versionIds.size()); + result.put("bindings", bindings); + return result; + }); } /* ============================================================================ @@ -235,58 +216,48 @@ public class WorkflowConfigService { Map canvas = mapParam(params, "canvas"); String operator = currentUser(); - try (Connection connection = dataSource.getConnection()) { - boolean originalAutoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try { - if (WorkflowSql.queryOne(connection, "select b_id from dbo.wf_definition where b_id = ?", definitionId) == null) { - throw new BusinessException("流程「" + definitionId + "」不存在"); - } - - Map version = resolveDraftVersion(connection, definitionId, versionId, operator); - String targetVersionId = String.valueOf(version.get("b_id")); - - WorkflowSql.update(connection, "delete from dbo.wf_actor_rule where b_version_id = ?", targetVersionId); - WorkflowSql.update(connection, "delete from dbo.wf_edge where b_version_id = ?", targetVersionId); - WorkflowSql.update(connection, "delete from dbo.wf_node where b_version_id = ?", targetVersionId); - - int xh = 10; - for (WorkflowDraft.Node node : draft.nodes()) { - writeNode(connection, targetVersionId, node, xh); - xh += 10; - } - // 出线优先级 = 前端数组里同源节点的出现顺序(§6.5:条件按 b_priority 从小到大判断) - Map priorityByNode = new LinkedHashMap<>(); - Map edgeKeyMap = new LinkedHashMap<>(); - for (WorkflowDraft.Edge edge : draft.edges()) { - int priority = priorityByNode.merge(edge.from(), 10, Integer::sum); - String storedId = writeEdge(connection, targetVersionId, edge, priority); - if (!storedId.equals(edge.edgeKey())) { - // 编码已被其它版本占用而换了新 ID:把映射回给前端,前端据此保持 edgeKey 与库里一致 - edgeKeyMap.put(edge.edgeKey(), storedId); - } - } - - WorkflowSql.update(connection, """ - update dbo.wf_version - set b_canvas_json = ?, b_updated_by = ?, b_updated_at = sysutcdatetime() - where b_id = ? - """, canvas == null ? null : toJson(canvas), operator, targetVersionId); - - Map result = new LinkedHashMap<>(); - result.put("versionId", targetVersionId); - result.put("versionNo", version.get("b_version_no")); - result.put("issues", toIssueList(WorkflowValidator.validate(draft, null))); - result.put("edgeKeyMap", edgeKeyMap); - connection.commit(); - return result; - } catch (SQLException | RuntimeException exception) { - rollback(connection, exception); - throw exception; - } finally { - restoreAutoCommit(connection, originalAutoCommit); + return WorkflowTx.run(dataSource, connection -> { + if (WorkflowSql.queryOne(connection, "select b_id from dbo.wf_definition where b_id = ?", definitionId) == null) { + throw new BusinessException("流程「" + definitionId + "」不存在"); } - } + + Map version = resolveDraftVersion(connection, definitionId, versionId, operator); + String targetVersionId = String.valueOf(version.get("b_id")); + + WorkflowSql.update(connection, "delete from dbo.wf_actor_rule where b_version_id = ?", targetVersionId); + WorkflowSql.update(connection, "delete from dbo.wf_edge where b_version_id = ?", targetVersionId); + WorkflowSql.update(connection, "delete from dbo.wf_node where b_version_id = ?", targetVersionId); + + int xh = 10; + for (WorkflowDraft.Node node : draft.nodes()) { + writeNode(connection, targetVersionId, node, xh); + xh += 10; + } + // 出线优先级 = 前端数组里同源节点的出现顺序(§6.5:条件按 b_priority 从小到大判断) + Map priorityByNode = new LinkedHashMap<>(); + Map edgeKeyMap = new LinkedHashMap<>(); + for (WorkflowDraft.Edge edge : draft.edges()) { + int priority = priorityByNode.merge(edge.from(), 10, Integer::sum); + String storedId = writeEdge(connection, targetVersionId, edge, priority); + if (!storedId.equals(edge.edgeKey())) { + // 编码已被其它版本占用而换了新 ID:把映射回给前端,前端据此保持 edgeKey 与库里一致 + edgeKeyMap.put(edge.edgeKey(), storedId); + } + } + + WorkflowSql.update(connection, """ + update dbo.wf_version + set b_canvas_json = ?, b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, canvas == null ? null : toJson(canvas), operator, targetVersionId); + + Map result = new LinkedHashMap<>(); + result.put("versionId", targetVersionId); + result.put("versionNo", version.get("b_version_no")); + result.put("issues", toIssueList(WorkflowValidator.validate(draft, null))); + result.put("edgeKeyMap", edgeKeyMap); + return result; + }); } /** @@ -327,51 +298,42 @@ public class WorkflowConfigService { String versionId = ParamUtils.getRequiredString(params, "versionId"); String operator = currentUser(); - try (Connection connection = dataSource.getConnection()) { - boolean originalAutoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try { - Map version = WorkflowSql.queryOne(connection, - "select b_id, b_definition_id, b_status from dbo.wf_version with (UPDLOCK, ROWLOCK) where b_id = ?", versionId); - if (version == null) { - throw new BusinessException("版本「" + versionId + "」不存在"); - } - if (!STATUS_DRAFT.equals(String.valueOf(version.get("b_status")))) { - throw new BusinessException("只有草稿版本可以发布,当前版本状态:" + version.get("b_status")); - } - - WorkflowDraft draft = loadDraft(connection, versionId); - List issues = - WorkflowValidator.validate(draft, allowedConditionFields(connection, versionId)); - - Map result = new LinkedHashMap<>(); - result.put("issues", toIssueList(issues)); - if (WorkflowValidator.hasError(issues)) { - result.put("published", false); - connection.commit(); - return result; - } - - String checksum = checksumOf(draft); - WorkflowSql.update(connection, """ - update dbo.wf_version - set b_status = ?, b_checksum = ?, b_published_by = ?, b_published_at = sysutcdatetime(), - b_updated_by = ?, b_updated_at = sysutcdatetime() - where b_id = ? - """, STATUS_PUBLISHED, checksum, operator, operator, versionId); - - result.put("published", true); - result.put("checksum", checksum); - result.put("definitionId", version.get("b_definition_id")); - connection.commit(); - return result; - } catch (SQLException | RuntimeException exception) { - rollback(connection, exception); - throw exception; - } finally { - restoreAutoCommit(connection, originalAutoCommit); + // 注意「校验不通过」的早返回:该分支只做了读,没有写,直接 return 交由模板提交(提交一个纯读事务), + // 与原来手工 commit 的语义完全一致。不要改成抛异常——调用方需要一次看到全部 issues。 + return WorkflowTx.run(dataSource, connection -> { + Map version = WorkflowSql.queryOne(connection, + "select b_id, b_definition_id, b_status from dbo.wf_version with (UPDLOCK, ROWLOCK) where b_id = ?", versionId); + if (version == null) { + throw new BusinessException("版本「" + versionId + "」不存在"); } - } + if (!STATUS_DRAFT.equals(String.valueOf(version.get("b_status")))) { + throw new BusinessException("只有草稿版本可以发布,当前版本状态:" + version.get("b_status")); + } + + WorkflowDraft draft = loadDraft(connection, versionId); + List issues = + WorkflowValidator.validate(draft, allowedConditionFields(connection, versionId)); + + Map result = new LinkedHashMap<>(); + result.put("issues", toIssueList(issues)); + if (WorkflowValidator.hasError(issues)) { + result.put("published", false); + return result; + } + + String checksum = checksumOf(draft); + WorkflowSql.update(connection, """ + update dbo.wf_version + set b_status = ?, b_checksum = ?, b_published_by = ?, b_published_at = sysutcdatetime(), + b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, STATUS_PUBLISHED, checksum, operator, operator, versionId); + + result.put("published", true); + result.put("checksum", checksum); + result.put("definitionId", version.get("b_definition_id")); + return result; + }); } /** 停用版本:published → retired,不再接受新实例(§4.2);已有实例继续用原版本跑完。 */ @@ -379,31 +341,22 @@ public class WorkflowConfigService { String versionId = ParamUtils.getRequiredString(params, "versionId"); String operator = currentUser(); - try (Connection connection = dataSource.getConnection()) { - boolean originalAutoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try { - Map version = WorkflowSql.queryOne(connection, - "select b_status from dbo.wf_version with (UPDLOCK, ROWLOCK) where b_id = ?", versionId); - if (version == null) { - throw new BusinessException("版本「" + versionId + "」不存在"); - } - if (!STATUS_PUBLISHED.equals(String.valueOf(version.get("b_status")))) { - throw new BusinessException("只有已发布版本可以停用,当前版本状态:" + version.get("b_status")); - } - WorkflowSql.update(connection, """ - update dbo.wf_version - set b_status = ?, b_updated_by = ?, b_updated_at = sysutcdatetime() - where b_id = ? - """, STATUS_RETIRED, operator, versionId); - connection.commit(); - } catch (SQLException | RuntimeException exception) { - rollback(connection, exception); - throw exception; - } finally { - restoreAutoCommit(connection, originalAutoCommit); + WorkflowTx.run(dataSource, connection -> { + Map version = WorkflowSql.queryOne(connection, + "select b_status from dbo.wf_version with (UPDLOCK, ROWLOCK) where b_id = ?", versionId); + if (version == null) { + throw new BusinessException("版本「" + versionId + "」不存在"); } - } + if (!STATUS_PUBLISHED.equals(String.valueOf(version.get("b_status")))) { + throw new BusinessException("只有已发布版本可以停用,当前版本状态:" + version.get("b_status")); + } + WorkflowSql.update(connection, """ + update dbo.wf_version + set b_status = ?, b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, STATUS_RETIRED, operator, versionId); + return null; + }); } /** @@ -416,77 +369,67 @@ public class WorkflowConfigService { String versionId = ParamUtils.getRequiredString(params, "versionId"); String operator = currentUser(); - try (Connection connection = dataSource.getConnection()) { - boolean originalAutoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try { - Map source = WorkflowSql.queryOne(connection, """ - select b_id, b_definition_id, b_version_no, b_canvas_json - from dbo.wf_version where b_id = ? - """, versionId); - if (source == null) { - throw new BusinessException("版本「" + versionId + "」不存在"); - } - String definitionId = String.valueOf(source.get("b_definition_id")); - - Map draftRow = WorkflowSql.queryOne(connection, - "select b_id from dbo.wf_version where b_definition_id = ? and b_status = ?", - definitionId, STATUS_DRAFT); - if (draftRow != null) { - throw new BusinessException( - "该流程已有草稿版本「" + draftRow.get("b_id") + "」,请先发布或删除它"); - } - - int nextNo = nextVersionNo(connection, definitionId); - String newVersionId = versionIdOf(definitionId, nextNo); - WorkflowSql.update(connection, """ - insert into dbo.wf_version - (b_id, b_definition_id, b_version_no, b_status, b_schema_version, b_canvas_json, - b_created_by, b_created_at, b_updated_by, b_updated_at) - values (?, ?, ?, ?, 1, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) - """, newVersionId, definitionId, nextNo, STATUS_DRAFT, - source.get("b_canvas_json"), operator, operator); - - // 节点 / 连线 / 审批人规则整份复制:node_key 稳定(§6.4), - // 但行主键 b_id 必须换新 —— 源版本还在,原样复制会直接撞主键 - WorkflowSql.update(connection, """ - insert into dbo.wf_node - (b_id, b_version_id, b_node_key, b_name, b_i18n, b_node_type, b_approval_mode, - b_reject_mode, b_due_hours, b_config_json, b_xh, b_canuse) - select convert(varchar(50), newid()), ?, b_node_key, b_name, b_i18n, b_node_type, - b_approval_mode, b_reject_mode, b_due_hours, b_config_json, b_xh, b_canuse - from dbo.wf_node where b_version_id = ? - """, newVersionId, versionId); - WorkflowSql.update(connection, """ - insert into dbo.wf_edge - (b_id, b_version_id, b_from_node_key, b_to_node_key, b_name, b_condition_json, - b_priority, b_canuse, b_bz) - select convert(varchar(50), newid()), ?, b_from_node_key, b_to_node_key, b_name, - b_condition_json, b_priority, b_canuse, b_bz - from dbo.wf_edge where b_version_id = ? - """, newVersionId, versionId); - WorkflowSql.update(connection, """ - insert into dbo.wf_actor_rule - (b_id, b_version_id, b_node_key, b_actor_type, b_actor_ref, - b_condition_json, b_fallback_type, b_fallback_ref, b_priority, b_canuse, b_bz) - select convert(varchar(50), newid()), ?, b_node_key, b_actor_type, b_actor_ref, - b_condition_json, b_fallback_type, b_fallback_ref, - b_priority, b_canuse, b_bz - from dbo.wf_actor_rule where b_version_id = ? - """, newVersionId, versionId); - - Map result = new LinkedHashMap<>(); - result.put("versionId", newVersionId); - result.put("versionNo", nextNo); - connection.commit(); - return result; - } catch (SQLException | RuntimeException exception) { - rollback(connection, exception); - throw exception; - } finally { - restoreAutoCommit(connection, originalAutoCommit); + return WorkflowTx.run(dataSource, connection -> { + Map source = WorkflowSql.queryOne(connection, """ + select b_id, b_definition_id, b_version_no, b_canvas_json + from dbo.wf_version where b_id = ? + """, versionId); + if (source == null) { + throw new BusinessException("版本「" + versionId + "」不存在"); } - } + String definitionId = String.valueOf(source.get("b_definition_id")); + + Map draftRow = WorkflowSql.queryOne(connection, + "select b_id from dbo.wf_version where b_definition_id = ? and b_status = ?", + definitionId, STATUS_DRAFT); + if (draftRow != null) { + throw new BusinessException( + "该流程已有草稿版本「" + draftRow.get("b_id") + "」,请先发布或删除它"); + } + + int nextNo = nextVersionNo(connection, definitionId); + String newVersionId = versionIdOf(definitionId, nextNo); + WorkflowSql.update(connection, """ + insert into dbo.wf_version + (b_id, b_definition_id, b_version_no, b_status, b_schema_version, b_canvas_json, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, ?, ?, 1, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, newVersionId, definitionId, nextNo, STATUS_DRAFT, + source.get("b_canvas_json"), operator, operator); + + // 节点 / 连线 / 审批人规则整份复制:node_key 稳定(§6.4), + // 但行主键 b_id 必须换新 —— 源版本还在,原样复制会直接撞主键 + WorkflowSql.update(connection, """ + insert into dbo.wf_node + (b_id, b_version_id, b_node_key, b_name, b_i18n, b_node_type, b_approval_mode, + b_reject_mode, b_due_hours, b_config_json, b_xh, b_canuse) + select convert(varchar(50), newid()), ?, b_node_key, b_name, b_i18n, b_node_type, + b_approval_mode, b_reject_mode, b_due_hours, b_config_json, b_xh, b_canuse + from dbo.wf_node where b_version_id = ? + """, newVersionId, versionId); + WorkflowSql.update(connection, """ + insert into dbo.wf_edge + (b_id, b_version_id, b_from_node_key, b_to_node_key, b_name, b_condition_json, + b_priority, b_canuse, b_bz) + select convert(varchar(50), newid()), ?, b_from_node_key, b_to_node_key, b_name, + b_condition_json, b_priority, b_canuse, b_bz + from dbo.wf_edge where b_version_id = ? + """, newVersionId, versionId); + WorkflowSql.update(connection, """ + insert into dbo.wf_actor_rule + (b_id, b_version_id, b_node_key, b_actor_type, b_actor_ref, + b_condition_json, b_fallback_type, b_fallback_ref, b_priority, b_canuse, b_bz) + select convert(varchar(50), newid()), ?, b_node_key, b_actor_type, b_actor_ref, + b_condition_json, b_fallback_type, b_fallback_ref, + b_priority, b_canuse, b_bz + from dbo.wf_actor_rule where b_version_id = ? + """, newVersionId, versionId); + + Map result = new LinkedHashMap<>(); + result.put("versionId", newVersionId); + result.put("versionNo", nextNo); + return result; + }); } /* ============================================================================ @@ -512,105 +455,89 @@ public class WorkflowConfigService { int canuse = Boolean.FALSE.equals(params.get("canuse")) ? 0 : 1; String operator = currentUser(); - try (Connection connection = dataSource.getConnection()) { - boolean originalAutoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try { - Map version = WorkflowSql.queryOne(connection, - "select b_status from dbo.wf_version where b_id = ?", versionId); - if (version == null) { - throw new BusinessException("流程版本「" + versionId + "」不存在"); - } - if (!STATUS_PUBLISHED.equals(String.valueOf(version.get("b_status")))) { - throw new BusinessException("绑定只能指向已发布版本,当前版本状态:" + version.get("b_status")); - } - - if (canuse == 1) { - Map conflict = bindingId == null || bindingId.isBlank() - ? WorkflowSql.queryOne(connection, """ - select b_id from dbo.wf_binding - where b_module_id = ? and b_event = ? and b_canuse = 1 and b_priority = ? - """, moduleId, event, priority) - : WorkflowSql.queryOne(connection, """ - select b_id from dbo.wf_binding - where b_module_id = ? and b_event = ? and b_canuse = 1 and b_priority = ? - and b_id <> ? - """, moduleId, event, priority, bindingId); - if (conflict != null) { - throw new BusinessException("同一模块同一事件下已存在同优先级的启用绑定「" - + conflict.get("b_id") + "」,请调整优先级或停用其中一条"); - } - } - - Map result = new LinkedHashMap<>(); - if (bindingId == null || bindingId.isBlank()) { - bindingId = String.valueOf(IdGenerator.nextId()); - WorkflowSql.update(connection, """ - insert into dbo.wf_binding - (b_id, b_module_id, b_event, b_version_id, b_match_json, b_org_scope_json, - b_status_field, b_status_map_json, b_priority, b_effective_from, b_effective_to, - b_canuse, b_bz, b_created_by, b_created_at, b_updated_by, b_updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) - """, bindingId, moduleId, event, versionId, - jsonParam(params, "match"), jsonParam(params, "orgScope"), - blankToNull(ParamUtils.getString(params, "statusField")), - jsonParam(params, "statusMap"), priority, - timeParam(params, "effectiveFrom"), timeParam(params, "effectiveTo"), - canuse, ParamUtils.getString(params, "bz"), operator, operator); - result.put("created", true); - } else { - int updated = WorkflowSql.update(connection, """ - update dbo.wf_binding - set b_module_id = ?, b_event = ?, b_version_id = ?, b_match_json = ?, - b_org_scope_json = ?, b_status_field = ?, b_status_map_json = ?, - b_priority = ?, b_effective_from = ?, b_effective_to = ?, b_canuse = ?, - b_bz = ?, b_updated_by = ?, b_updated_at = sysutcdatetime() - where b_id = ? - """, moduleId, event, versionId, - jsonParam(params, "match"), jsonParam(params, "orgScope"), - blankToNull(ParamUtils.getString(params, "statusField")), - jsonParam(params, "statusMap"), priority, - timeParam(params, "effectiveFrom"), timeParam(params, "effectiveTo"), - canuse, ParamUtils.getString(params, "bz"), operator, bindingId); - if (updated == 0) { - throw new BusinessException("绑定「" + bindingId + "」不存在"); - } - result.put("created", false); - } - result.put("bindingId", bindingId); - // 绑定即视为「这个模块接入了审批」:顺手把动作权限点备好, - // 否则管理员在用户权限页看不到可授权的权限点,非管理员送审会被白名单拒绝 - ensureActionPowers(connection, moduleId); - connection.commit(); - return result; - } catch (SQLException | RuntimeException exception) { - rollback(connection, exception); - throw exception; - } finally { - restoreAutoCommit(connection, originalAutoCommit); + return WorkflowTx.run(dataSource, connection -> { + Map version = WorkflowSql.queryOne(connection, + "select b_status from dbo.wf_version where b_id = ?", versionId); + if (version == null) { + throw new BusinessException("流程版本「" + versionId + "」不存在"); } - } + if (!STATUS_PUBLISHED.equals(String.valueOf(version.get("b_status")))) { + throw new BusinessException("绑定只能指向已发布版本,当前版本状态:" + version.get("b_status")); + } + + if (canuse == 1) { + Map conflict = bindingId == null || bindingId.isBlank() + ? WorkflowSql.queryOne(connection, """ + select b_id from dbo.wf_binding + where b_module_id = ? and b_event = ? and b_canuse = 1 and b_priority = ? + """, moduleId, event, priority) + : WorkflowSql.queryOne(connection, """ + select b_id from dbo.wf_binding + where b_module_id = ? and b_event = ? and b_canuse = 1 and b_priority = ? + and b_id <> ? + """, moduleId, event, priority, bindingId); + if (conflict != null) { + throw new BusinessException("同一模块同一事件下已存在同优先级的启用绑定「" + + conflict.get("b_id") + "」,请调整优先级或停用其中一条"); + } + } + + // 新建时生成主键。用单次表达式保证两者都是 effectively final(会被后续逻辑与快照引用) + boolean creating = bindingId == null || bindingId.isBlank(); + String targetBindingId = creating ? String.valueOf(IdGenerator.nextId()) : bindingId; + + Map result = new LinkedHashMap<>(); + if (!creating) { + int updated = WorkflowSql.update(connection, """ + update dbo.wf_binding + set b_module_id = ?, b_event = ?, b_version_id = ?, b_match_json = ?, + b_org_scope_json = ?, b_status_field = ?, b_status_map_json = ?, + b_priority = ?, b_effective_from = ?, b_effective_to = ?, b_canuse = ?, + b_bz = ?, b_updated_by = ?, b_updated_at = sysutcdatetime() + where b_id = ? + """, moduleId, event, versionId, + jsonParam(params, "match"), jsonParam(params, "orgScope"), + blankToNull(ParamUtils.getString(params, "statusField")), + jsonParam(params, "statusMap"), priority, + timeParam(params, "effectiveFrom"), timeParam(params, "effectiveTo"), + canuse, ParamUtils.getString(params, "bz"), operator, targetBindingId); + if (updated == 0) { + throw new BusinessException("绑定「" + targetBindingId + "」不存在"); + } + result.put("created", false); + } else { + WorkflowSql.update(connection, """ + insert into dbo.wf_binding + (b_id, b_module_id, b_event, b_version_id, b_match_json, b_org_scope_json, + b_status_field, b_status_map_json, b_priority, b_effective_from, b_effective_to, + b_canuse, b_bz, b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, targetBindingId, moduleId, event, versionId, + jsonParam(params, "match"), jsonParam(params, "orgScope"), + blankToNull(ParamUtils.getString(params, "statusField")), + jsonParam(params, "statusMap"), priority, + timeParam(params, "effectiveFrom"), timeParam(params, "effectiveTo"), + canuse, ParamUtils.getString(params, "bz"), operator, operator); + result.put("created", true); + } + result.put("bindingId", targetBindingId); + // 绑定即视为「这个模块接入了审批」:顺手把动作权限点备好, + // 否则管理员在用户权限页看不到可授权的权限点,非管理员送审会被白名单拒绝 + ensureActionPowers(connection, moduleId); + return result; + }); } /** 删除流程绑定 */ public void deleteBinding(Map params) throws SQLException { String bindingId = ParamUtils.getRequiredString(params, "bindingId"); - try (Connection connection = dataSource.getConnection()) { - boolean originalAutoCommit = connection.getAutoCommit(); - connection.setAutoCommit(false); - try { - if (WorkflowSql.update(connection, "delete from dbo.wf_binding where b_id = ?", bindingId) == 0) { - throw new BusinessException("绑定「" + bindingId + "」不存在"); - } - connection.commit(); - } catch (SQLException | RuntimeException exception) { - rollback(connection, exception); - throw exception; - } finally { - restoreAutoCommit(connection, originalAutoCommit); + WorkflowTx.run(dataSource, connection -> { + if (WorkflowSql.update(connection, "delete from dbo.wf_binding where b_id = ?", bindingId) == 0) { + throw new BusinessException("绑定「" + bindingId + "」不存在"); } - } + return null; + }); } /** @@ -844,24 +771,12 @@ public class WorkflowConfigService { 通用 SQL 小工具已统一到包内 WorkflowSql(与运行侧同一份实现)。 原先此处有一份私有复制,且漏了 TimeUtils.toApiValue —— 配置侧读出的时间列 会脱离「带 Z 的 UTC ISO 串」出口口径,与运行侧不一致,故删除。 + + 事务样板已统一到包内 WorkflowTx:原先 8 处手写的 + getConnection / setAutoCommit(false) / commit / rollback / restoreAutoCommit + 以及本类私有的 rollback / restoreAutoCommit 都已删除。 ============================================================================ */ - private void rollback(Connection connection, Exception cause) { - try { - connection.rollback(); - } catch (SQLException rollbackFailure) { - cause.addSuppressed(rollbackFailure); - } - } - - private void restoreAutoCommit(Connection connection, boolean originalAutoCommit) { - try { - connection.setAutoCommit(originalAutoCommit); - } catch (SQLException ignored) { - // 连接归还时由连接池复位,这里失败不影响主流程 - } - } - private String currentUser() { return OrgContext.requireUserId(); } diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepService.java index 6a09f539..e50d6763 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepService.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepService.java @@ -31,12 +31,34 @@ import java.util.concurrent.TimeUnit; * 实例保持 {@code running},由管理员通过加签 / 转派补救。这条策略是刻意选的:到期自动否决/自动通过 * 风险太大,而自动置 error 会让"仅仅超期"直接打断流程。 * - *

调度沿用项目里 {@code OrgDataSourceManager} 的裸 {@code ScheduledExecutorService} 模式 - * (不引入 {@code @EnableScheduling}):守护线程、{@code @PostConstruct} 启动、 - * {@code @PreDestroy} 关闭;间隔配置为 0 即关闭扫描。 + *

为什么用裸 {@code ScheduledExecutorService} 而不是 {@code @EnableScheduling} + {@code @Scheduled}: + *

    + *
  1. {@code 0s} 关闭语义:本扫描要支持「按功能关闭」,{@code interval<=0} 时连线程池都不建 + * (见 {@link #start()})。而 {@code @Scheduled} 的 {@code fixedDelayString} 不接受 0 + * (会抛 {@code IllegalArgumentException}),要等价只能再绕 {@code @ConditionalOnProperty} + * 或手动摘任务 —— 为保住一个开关反而多引入一层机制。
  2. + *
  3. {@code @EnableScheduling} 是应用级开关:它加在启动类上,影响的是整个应用, + * 而这里的需求只是单个功能可配。项目刻意避开 Spring 的隐式行为(全项目不用 + * {@code @Transactional}、数据源自己管、配置热更新自己起 WatchService), + * 为这一个定时任务开全局口子不划算。
  4. + *
  5. 线程名与守护线程可控:裸写法能自命名为 {@code wf-expiry-sweep} 并设守护线程, + * 与 {@code org-dbconfig-watcher}、{@code org-datasource-retired-close} 一致, + * 排查线上问题时在日志与 {@code jstack} 里能直接认出来;{@code @Scheduled} 默认叫 + * {@code scheduling-1} 且非守护。
  6. + *
+ * + *

代价(已知,接受):没有 Actuator 的定时任务执行统计,可观测性靠本类的日志 + * (启动/关闭、每次标记条数、失败时 warn)。若将来确实需要执行指标,应作为独立的运维改造 + * 统一评估,而不是为它单独换掉这里的调度方式。 + * + *

注意:两种方式都没有分布式锁 —— 多节点部署时每个节点都会各跑一次。 + * 当前扫描是幂等的(选取条件是 {@code b_status in ('pending','claimed')},标过就不再匹配), + * 所以多节点下结果正确;但这是「碰巧安全」,不是设计保证。真要严格单实例执行, + * 需要额外的抢占机制(如数据库行锁 / 分布式锁),与调度方式无关。 * *

后台线程没有请求上下文,而主 DataSource 是按 {@code OrgContext} 路由的, - * 所以按机构逐个设置上下文再取连接。 + * 所以按机构逐个设置上下文再取连接。相应地,扫描结束后会 {@code OrgContext.clear()} + * —— 这是刻意的:不许把上下文泄漏给下一次调度(写测试时注意,见交接说明 §七.14)。 */ @Component public class WorkflowExpirySweepService { diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowInstancePolicy.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowInstancePolicy.java new file mode 100644 index 00000000..aa0cabba --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowInstancePolicy.java @@ -0,0 +1,391 @@ +package cn.g3soft.erp.service.workflow; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 运行期的**纯判定**逻辑:把散落在 {@code WorkflowRuntimeService} 里的 + * 「配置 → 决策」映射抽出来,使其不依赖数据库、可被单测直接覆盖。 + * + *

这里只做计算,不写库、不开事务、不抛业务异常(读配置遇到脏值时按各自注释里的默认值处理)。 + * 需要连接才能拿到的输入(如「此前节点出现过哪些人」的行数据)由调用方查好后作为参数传入。 + * + *

抽取动机:这些判定直接决定「要几票才算通过」「谁会拿到任务」「投影坏了怎么办」, + * 算错的表现是流程静默走错或卡死,而它们原先都藏在 1600 行门面类的 private 方法里, + * 只能靠连真库的集成测试覆盖。 + */ +final class WorkflowInstancePolicy { + + private WorkflowInstancePolicy() { + } + + /* ---------------------------------------------------------------------------- + 会签票数 + ---------------------------------------------------------------------------- */ + + /** + * 应签人数(§9.6 / §9.7)。 + * + *

    + *
  • {@code all} / {@code serial}:全部候选人都要签;
  • + *
  • {@code n_of_m}:取配置 {@code requiredCount},并夹在 [1, 候选数] 之间;
  • + *
  • {@code percentage}:候选数 × {@code requiredRatio} **向上取整**,同样夹紧;
  • + *
  • 其余({@code single} / {@code any} / 未知):1 票即通过。
  • + *
+ * + *

候选数按 {@code max(candidateCount, 1)} 兜底:节点解析出 0 人时不该算出 0 票 + * (那会让节点「零票通过」),应签 1 票后由解析为空的路径去置 error。 + */ + static int requiredCount(String mode, Map config, int candidateCount) { + int candidates = Math.max(candidateCount, 1); + Map settings = config == null ? Map.of() : config; + return switch (mode == null ? "" : mode) { + case "all", "serial" -> candidates; + case "n_of_m" -> clamp((int) number(settings.get("requiredCount"), 1), candidates); + case "percentage" -> { + double ratio = number(settings.get("requiredRatio"), 0); + int count = BigDecimal.valueOf(candidates) + .multiply(BigDecimal.valueOf(ratio)) + .setScale(0, RoundingMode.CEILING) + .intValue(); + yield clamp(count, candidates); + } + default -> 1; + }; + } + + /** 审批时限 → 分钟;未配置或非正数返回 null(不设应处理时间) */ + static Integer dueMinutesOf(Object dueHours) { + double hours = number(dueHours, 0); + return hours <= 0 ? null : (int) Math.round(hours * 60); + } + + /* ---------------------------------------------------------------------------- + 业务状态投影守卫(A3) + ---------------------------------------------------------------------------- */ + + /** 投影决策:跳过(留痕原因)/ 非法标识符 / 真的去更新。 */ + sealed interface ProjectionDecision { + + /** 不需要投影(绑定没配状态字段、状态没映射、模块是只读等),静默跳过 */ + record Skip(String reason) implements ProjectionDecision { + } + + /** 表名字段名不合法,属于配置错误,调用方应抛业务异常 */ + record Illegal(String message) implements ProjectionDecision { + } + + /** 业务主键与目标列类型不匹配,跳过并留痕(不回滚审批) */ + record KeyMismatch(String reason) implements ProjectionDecision { + } + + /** 执行更新 */ + record Update(String statusField, String target, boolean withRowVersion) implements ProjectionDecision { + } + } + + /** + * 决定这次投影该怎么做。 + * + *

顺序与语义(每一步都有对应缺陷): + *

    + *
  1. 绑定 / 状态字段 / 状态映射任一缺失 → 静默跳过(只读模块的正常情况);
  2. + *
  3. 表名、键字段、状态字段必须是安全标识符(它们要拼进 SQL,防注入);
  4. + *
  5. 目标键列**不是字符类型**且业务主键**不是纯数字** → 类型不匹配,跳过并留痕。 + * 这是「配错了不该把整个审批事务打回滚」的那条:投影只是副作用,实例状态已经落定;
  6. + *
  7. 否则执行更新;业务表带 {@code b_row_version} 时一并自增, + * 否则工作流改了行却不动版本,会让通用保存的乐观锁读到陈旧版本而误判无冲突。
  8. + *
+ * + * @param saveTable 模块的保存表({@code s_module.b_save_table}),空表示只读模块 + * @param keyField 模块的主键字段,空表示不可投影 + * @param statusField 绑定的业务状态字段,空表示不投影 + * @param target 状态值映射结果,空表示该状态不投影 + * @param textKeyColumn 目标键列是否为字符类型(由调用方查系统表得出) + * @param hasVersionColumn 业务表是否有 {@code b_row_version} 列 + * @param businessId 业务主键值 + */ + static ProjectionDecision decideProjection( + String saveTable, + String keyField, + String statusField, + String target, + String businessId, + boolean textKeyColumn, + boolean hasVersionColumn + ) { + if (isBlank(saveTable) || isBlank(keyField)) { + return new ProjectionDecision.Skip("模块未配置保存表 / 主键字段(只读模块)"); + } + if (isBlank(statusField)) { + return new ProjectionDecision.Skip("绑定未配置业务状态字段"); + } + if (isBlank(target)) { + return new ProjectionDecision.Skip("该状态未映射到业务状态值"); + } + if (!isSafeIdentifier(saveTable) || !isSafeIdentifier(keyField) || !isSafeIdentifier(statusField)) { + return new ProjectionDecision.Illegal( + "业务状态投影的表名 / 字段名不合法:" + saveTable + "." + statusField); + } + if (!textKeyColumn && !isDigits(businessId)) { + return new ProjectionDecision.KeyMismatch("业务主键不是数字,无法匹配列类型"); + } + return new ProjectionDecision.Update(statusField, target, hasVersionColumn); + } + + /** + * 投影影响行数不为 1 时的处置(A3):留痕但不抛。 + * + *

0 行 = 记录不存在 / 键类型不匹配;>1 行 = 键不唯一。两者都属于「配置与数据不一致」, + * 静默丢掉会让审批看起来成功而业务状态没变,所以必须留痕;但**不能回滚** —— + * 实例状态与表决已经在同一事务里落定,回滚会让审批本身失败。 + * + * @return 需要写入 outbox 的载荷;行数为 1 时返回 {@code null}(正常情况) + */ + static Map projectionOutcome(int updatedRows, String saveTable, String keyField, + String statusField, String businessId, boolean hasVersionColumn) { + if (updatedRows == 1) { + return null; + } + Map payload = new java.util.LinkedHashMap<>(); + payload.put("reason", "业务状态投影影响行数=" + updatedRows); + payload.put("target", saveTable + "." + keyField + " / " + statusField); + payload.put("businessId", businessId); + payload.put("hasVersionColumn", hasVersionColumn); + return payload; + } + + /* ---------------------------------------------------------------------------- + 职责分离(D4) + ---------------------------------------------------------------------------- */ + + /** + * 由节点配置算出要排除的候选(不含「此前节点出现过的人」——那部分要查库,由调用方并进来)。 + * + *

    + *
  • {@code allowInitiator} **显式为 false** → 排除发起人;缺省(没配)表示允许, + * 这正是 {@link #configFlagOrNull} 要三态的原因:不能把「没配」当成「配了 false」;
  • + *
  • {@code excludeCreator} 为 true → 排除创建人。业务表创建人字段尚未统一, + * 暂以实例发起人近似(代码注释已标明,待业务表口径统一后再换);
  • + *
+ * + * @return 可变集合,调用方可继续并入「此前节点出现过的人」 + */ + static Set excludedFromConfig(Map config, String initiatorId) { + Set excluded = new LinkedHashSet<>(); + if (config == null || config.isEmpty()) { + return excluded; + } + if (Boolean.FALSE.equals(configFlagOrNull(config, "allowInitiator")) + || Boolean.TRUE.equals(configFlagOrNull(config, "excludeCreator"))) { + if (initiatorId != null && !initiatorId.isEmpty()) { + excluded.add(initiatorId); + } + } + return excluded; + } + + /** {@code excludePreviousActors} 是否开启 */ + static boolean excludesPreviousActors(Map config) { + return config != null && Boolean.TRUE.equals(configFlagOrNull(config, "excludePreviousActors")); + } + + /* ---------------------------------------------------------------------------- + 串行会签的任务生成(A1 / §9.6) + ---------------------------------------------------------------------------- */ + + /** + * 一个人工节点该为哪些候选建任务、各自什么初始状态。 + * + *

原型缺陷(A1)出在这里:串行节点的任务全都建出来了,但除首位以外都是 {@code waiting}, + * 而当时**没有任何语句把 waiting 翻回 pending**,于是节点永远凑不满票数、流程卡死。 + * 现在由 {@code promoteNextSerialTask} 在「未凑够票数」分支负责翻牌,本方法只保证 + * **恰好只有第一位是 pending**,两边合起来才构成完整的串行语义。 + * + *

    + *
  • {@code single}:只给第一名候选建任务(与「或签」不同——或签是所有候选都能看到、 + * 谁先处理谁生效;单选是只指派一个人);
  • + *
  • {@code serial}:全部候选都建任务,第一位 {@code pending}、其余 {@code waiting};
  • + *
  • 其余({@code any} / {@code all} / {@code n_of_m} / {@code percentage}):全部 {@code pending}。
  • + *
+ * + *

顺序即候选顺序,也就是串行会签的先后({@code b_seq} 由调用方按 10 递增写)。 + * + * @param mode 审批模式,空/null 按 {@code single} + * @param candidates 候选审批人(已去重、保序) + * @return 与 candidates 等长的初始状态列表;{@code single} 时只保留首位 + */ + static List taskSeeds(String mode, List candidates) { + List users = candidates == null ? List.of() : candidates; + if (users.isEmpty()) { + return List.of(); + } + String effective = mode == null || mode.isBlank() ? "single" : mode.trim(); + List seeds = new ArrayList<>(users.size()); + int index = 0; + for (String userId : users) { + seeds.add(new TaskSeed(userId, (index + 1) * 10, initialState(effective, index))); + index++; + if ("single".equals(effective)) { + // 单选只指派一个人,不必为其余候选建 waiting 任务 + break; + } + } + return List.copyOf(seeds); + } + + /** 任务初始状态;单人节点缺席时按 pending 兜底,避免出现「没有任何待办」的节点 */ + private static String initialState(String mode, int index) { + return "serial".equals(mode) && index > 0 ? "waiting" : "pending"; + } + + /** + * 首个待激活任务的选择键:串行会签按 {@code (b_seq, b_id)} 升序取最小的一条。 + * + *

单独抽出来是为了可测:这个排序一旦反了,串行链就会乱序推进(后一位先审), + * 而这类错误在真库上很难通过现象反推。 + */ + static String nextWaitingTask(List waitingTasks) { + return nextWaitingTask(waitingTasks, null); + } + + /** + * 从 waiting 任务里挑下一个该激活的,跳过已被排除的人(职责分离后可能无人可推)。 + * + * @param waitingTasks 处于 waiting 的任务(带 seq 与 assignee) + * @param excluded 要跳过的人;null / 空表示不跳过 + * @return 选中的任务 id;没有可激活的返回 {@code null} + */ + static String nextWaitingTask(List waitingTasks, Set excluded) { + if (waitingTasks == null || waitingTasks.isEmpty()) { + return null; + } + TaskSeed best = null; + for (TaskSeed task : waitingTasks) { + if (task == null || task.taskId() == null) { + continue; + } + if (excluded != null && !excluded.isEmpty() && excluded.contains(task.userId())) { + continue; + } + if (best == null || orderKey(task).compareTo(orderKey(best)) < 0) { + best = task; + } + } + return best == null ? null : best.taskId(); + } + + /** 排序键:先 b_seq 后 b_id,两者都按字符串可比(b_id 是 UUID,字典序在库外一致即可) */ + private static String orderKey(TaskSeed task) { + return String.format("%010d|%s", task.seq(), task.taskId()); + } + + /** + * 一条待建 / 待激活任务的种子。 + * + * @param userId 候选人(未套用委托前的原审批人) + * @param seq 串行序号,调用方按 10 递增写库 + * @param status 初始状态:pending / waiting + */ + record TaskSeed(String userId, int seq, String status) { + + /** 便利构造:只关心顺序时不必显式给状态(默认 pending) */ + TaskSeed(String taskId, int seq) { + this(taskId, seq, "pending"); + } + + /** 任务主键在只做排序选择时复用 userId 字段,读起来更直观 */ + String taskId() { + return userId; + } + } + + /* ---------------------------------------------------------------------------- + 通用小工具 + ---------------------------------------------------------------------------- */ + + /** + * 三态读布尔配置:显式为 true / false 才返回,缺失或无法识别返回 {@code null}。 + * + *

用来区分「没配」与「配了 false」—— 两者在一堆开关里语义相反 + * (如 {@code allowInitiator} 缺省允许、显式 false 才排除)。 + */ + static Boolean configFlagOrNull(Map config, String key) { + Object value = config == null ? null : config.get(key); + if (value instanceof Boolean flag) { + return flag; + } + if (value instanceof Number number) { + return number.intValue() != 0; + } + if (value instanceof String string) { + String trimmed = string.trim(); + if (trimmed.equalsIgnoreCase("true")) { + return Boolean.TRUE; + } + if (trimmed.equalsIgnoreCase("false")) { + return Boolean.FALSE; + } + } + return null; + } + + /** 宽松读布尔配置:缺失 / 无法识别一律 false(用于默认关闭的开关) */ + static boolean configFlag(Map config, String key) { + return Boolean.TRUE.equals(configFlagOrNull(config, key)); + } + + /** 业务主键是否为纯数字串(决定它能不能与 bigint 列比较) */ + static boolean isDigits(String value) { + if (value == null || value.isEmpty()) { + return false; + } + for (int index = 0; index < value.length(); index++) { + if (!Character.isDigit(value.charAt(index))) { + return false; + } + } + return true; + } + + /** 安全标识符:要拼进 SQL 的表名 / 字段名必须只含字母数字下划线且不以数字开头 */ + static boolean isSafeIdentifier(String value) { + if (value == null || value.isEmpty() || Character.isDigit(value.charAt(0))) { + return false; + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (!Character.isLetterOrDigit(character) && character != '_') { + return false; + } + } + return true; + } + + private static int clamp(int value, int max) { + return Math.min(Math.max(value, 1), max); + } + + private static double number(Object value, double fallback) { + if (value instanceof Number number) { + return number.doubleValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Double.parseDouble(text.trim()); + } catch (NumberFormatException ignored) { + return fallback; + } + } + return fallback; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeLedger.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeLedger.java new file mode 100644 index 00000000..4f09523c --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeLedger.java @@ -0,0 +1,201 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.util.UuidV7Generator; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 工作流运行侧的**落库底座**:动作留痕、事件外发、节点运行记录,以及几个只读小块。 + * + *

抽取依据(不是拍脑袋):对 {@code WorkflowRuntimeService} 做了一次真实调用图分析, + * 这 9 个方法都是**纯叶子** —— 它们只依赖 {@link WorkflowSql} / {@link WorkflowSqlKit} / + * 注入的 {@code ObjectMapper},**不回调类内任何其它方法**。所以搬出来不会产生回边、 + * 不会形成环,是一次零语义风险的结构性搬移。 + * + *

与之对比:{@code writeAction}(被调 20 次) / {@code writeOutbox}(14) / {@code requireAction}(11) + * 这类是全类共用的热路径,而 {@code advance ↔ enterNode}、{@code enterNode → finishInstance} + * 之间存在**真实环**;按「RoutingEngine / TaskService / InstanceService / SubmissionService」 + * 硬切会把类内调用变成跨对象调用并引入双向依赖,故未采纳(见交接说明 §6.1)。 + * + *

本类**不持事务边界**:所有方法都接收调用方的 {@code Connection},参与同一个事务, + * 绝不自开连接、不 commit —— 事务归 {@code WorkflowTx} 与门面类管。 + */ +@Component +class WorkflowRuntimeLedger { + + @Resource + private ObjectMapper objectMapper; + + /* ---------------------------------------------------------------------------- + 动作留痕与事件外发 + ---------------------------------------------------------------------------- */ + + /** + * 写一条 {@code wf_action}(审批轨迹的唯一来源)。 + * + *

{@code nodeRunId} / {@code taskId} 允许为 null —— 实例级的动作(submit / withdraw / cancel / + * route / 收口)本来就没有对应任务,这正是 {@code taskLevelActionCount} 与 + * {@code instanceLevelActionCount} 能分开统计的依据。 + * + * @param requestId 幂等键;空串转 null({@code ux_wf_action_request} 是筛选唯一索引,不索引 null) + */ + void writeAction( + Connection connection, + WorkflowInstanceRow instance, + String nodeRunId, + String taskId, + String action, + String operatorId, + String fromStatus, + String toStatus, + String comment, + String requestId, + String payloadJson + ) throws SQLException { + WorkflowSql.update(connection, """ + insert into dbo.wf_action + (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, b_from_status, + b_to_status, b_comment, b_payload_json, b_request_id, b_occurdatetime) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, sysutcdatetime()) + """, UuidV7Generator.nextUuid(), WorkflowSqlKit.uuidParam(instance.instanceId()), + nodeRunId == null ? null : WorkflowSqlKit.uuidParam(nodeRunId), + taskId == null ? null : WorkflowSqlKit.uuidParam(taskId), + action, operatorId == null ? null : operatorId, + fromStatus, toStatus, comment == null ? null : comment, + payloadJson, requestId == null || requestId.isEmpty() ? null : requestId); + } + + /** 写一条 {@code wf_outbox}(事件外发;不带事务性消息队列时的落库口径) */ + void writeOutbox( + Connection connection, + String eventCode, + String aggregateType, + String aggregateId, + Map payload + ) throws SQLException { + WorkflowSql.update(connection, """ + insert into dbo.wf_outbox + (b_id, b_event_code, b_aggregate_type, b_aggregate_id, b_payload_json, b_status, b_retry_count, + b_occurdatetime) + values (?, ?, ?, ?, ?, 'pending', 0, sysutcdatetime()) + """, UuidV7Generator.nextUuid(), eventCode, aggregateType, aggregateId, + WorkflowSqlKit.toJson(objectMapper, payload)); + } + + /* ---------------------------------------------------------------------------- + 节点运行记录 + ---------------------------------------------------------------------------- */ + + /** + * 记录一次节点运行,返回 {@code node_run} 主键。 + * + *

{@code b_run_no} 按同一实例同一节点的历史条数递增 —— 这是「退回后重新进入同一节点」 + * (§8.2)能区分多轮运行的基础,也是「取最新一次运行」类查询(如并行汇合判定)的依据。 + * + *

{@code requiredCount <= 0} 时写 null 而不是 0:非人工节点(网关 / 抄送 / 服务节点) + * 没有「应签人数」概念,写 0 会让下游的比值运算出现除零或无意义比较。 + */ + String recordNodeRun( + Connection connection, + WorkflowInstanceRow instance, + String nodeKey, + String status, + Map variables, + String snapshot, + int requiredCount + ) throws SQLException { + int runNo = WorkflowSql.count(connection, + "select count(1) from dbo.wf_node_run where b_instance_id = ? and b_node_key = ?", + WorkflowSqlKit.uuidParam(instance.instanceId()), nodeKey) + 1; + UUID nodeRunId = UuidV7Generator.nextUuid(); + WorkflowSql.update(connection, """ + insert into dbo.wf_node_run + (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, b_completed_count, + b_rejected_count, b_entered_at, b_exited_at, b_actor_snapshot_json, b_created_at) + values (?, ?, ?, ?, ?, ?, 0, 0, sysutcdatetime(), null, ?, sysutcdatetime()) + """, nodeRunId, WorkflowSqlKit.uuidParam(instance.instanceId()), nodeKey, runNo, status, + requiredCount <= 0 ? null : requiredCount, snapshot); + // 完成态插入后立刻补 exited_at:状态与完成时间都走 completeNodeRun, + // 时间一律取数据库时钟 sysutcdatetime()(UTC 口径,见 TimeUtils)—— 不混入 Java 本地时间,读写两端一致 + if ("completed".equals(status) || "skipped".equals(status)) { + completeNodeRun(connection, nodeRunId.toString(), status); + } + return nodeRunId.toString(); + } + + /** 收口一次节点运行(置状态 + 补 {@code b_exited_at})。{@code return_previous} 依赖它不留 running(C6) */ + void completeNodeRun(Connection connection, String nodeRunId, String status) throws SQLException { + WorkflowSql.update(connection, """ + update dbo.wf_node_run set b_status = ?, b_exited_at = sysutcdatetime() where b_id = ? + """, status, WorkflowSqlKit.uuidParam(nodeRunId)); + } + + /* ---------------------------------------------------------------------------- + 小块读取 + ---------------------------------------------------------------------------- */ + + /** 实例运行上下文(一次动作内复用,避免反复回表) */ + Map readVariables(Connection connection, String instanceId) throws SQLException { + Object json = WorkflowSql.scalar(connection, + "select b_context_json from dbo.wf_instance where b_id = ?", WorkflowSqlKit.uuidParam(instanceId)); + return WorkflowJson.readMapOrEmpty(json, "instance.context"); + } + + /** 实例当前状态(库内实时值,不受内存快照影响) */ + String currentStatus(Connection connection, String instanceId) throws SQLException { + return WorkflowSqlKit.text(WorkflowSql.scalar(connection, + "select b_status from dbo.wf_instance where b_id = ?", WorkflowSqlKit.uuidParam(instanceId))); + } + + /** + * 动作类接口的统一返回体。 + * + *

{@code duplicated} 是幂等链路的对外信号:为 true 表示「这次请求被判为重复, + * 没有产生新副作用」,调用方据此决定提示文案。 + */ + Map submittedResult(Connection connection, String instanceId, boolean duplicated) + throws SQLException { + Map row = WorkflowSql.queryOne(connection, """ + select b_status, b_current_summary_json from dbo.wf_instance where b_id = ? + """, WorkflowSqlKit.uuidParam(instanceId)); + int tasks = WorkflowSql.count(connection, + "select count(1) from dbo.wf_task where b_instance_id = ? and b_status in ('pending', 'claimed')", + WorkflowSqlKit.uuidParam(instanceId)); + Map result = new LinkedHashMap<>(); + result.put("instanceId", instanceId); + result.put("status", row == null ? null : WorkflowSqlKit.text(row.get("b_status"))); + result.put("currentSummary", row == null ? null + : WorkflowJson.readMapOrEmpty(row.get("b_current_summary_json"), "instance.currentSummary")); + result.put("pendingTaskCount", tasks); + result.put("duplicated", duplicated); + return result; + } + + /** 某节点的出线,按 {@code b_priority}(条件判断顺序,§6.5) */ + List> outgoingEdges(Connection connection, String versionId, String nodeKey) + throws SQLException { + return WorkflowSql.query(connection, """ + select b_id, b_to_node_key, b_condition_json from dbo.wf_edge + where b_version_id = ? and b_from_node_key = ? and b_canuse = 1 + order by b_priority, b_id + """, versionId, nodeKey); + } + + /** 某节点的入线(并行汇合判定用) */ + List> incomingEdges(Connection connection, String versionId, String nodeKey) + throws SQLException { + return WorkflowSql.query(connection, """ + select b_id, b_from_node_key, b_condition_json from dbo.wf_edge + where b_version_id = ? and b_to_node_key = ? and b_canuse = 1 + order by b_priority, b_id + """, versionId, nodeKey); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeService.java index 82bd886e..f5911cdc 100644 --- a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeService.java +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowRuntimeService.java @@ -5,13 +5,12 @@ import cn.g3soft.erp.common.exception.BusinessException; import cn.g3soft.erp.common.util.ParamUtils; import cn.g3soft.erp.common.util.TimeUtils; import cn.g3soft.erp.common.util.UuidV7Generator; +import cn.g3soft.erp.service.permission.SqlPermissionService; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import tools.jackson.databind.ObjectMapper; import javax.sql.DataSource; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.sql.Connection; import java.sql.SQLException; import java.util.ArrayList; @@ -50,7 +49,6 @@ import java.util.regex.Pattern; @Service public class WorkflowRuntimeService { - private static final String ADMIN_ACCOUNT = "g3soft"; private static final Set BINDING_EVENTS = Set.of("submit", "resubmit", "change"); private static final Set TASK_ACTIONS = Set.of("approve", "reject", "return"); /** 路由步数上限:正常流程远小于它,命中说明配置有环,此时把实例置为 error 而不是死循环 */ @@ -69,6 +67,12 @@ public class WorkflowRuntimeService { @Resource private WorkflowDelegationService delegationService; + @Resource + private SqlPermissionService permissionService; + + @Resource + private WorkflowRuntimeLedger ledger; + /* ============================================================================ 提交送审 ============================================================================ */ @@ -82,15 +86,15 @@ public class WorkflowRuntimeService { public Map submit(Map params) throws SQLException { String moduleId = ParamUtils.getRequiredString(params, "moduleId"); String businessId = ParamUtils.getRequiredString(params, "businessId"); - String businessNo = text(params.get("businessNo")); + String businessNo = WorkflowSqlKit.text(params.get("businessNo")); // 默认事件 submit;用单次表达式保证 event 是 effectively final(会被事务 lambda 捕获) - String requestedEvent = text(params.get("event")); + String requestedEvent = WorkflowSqlKit.text(params.get("event")); String event = requestedEvent.isEmpty() ? "submit" : requestedEvent; if (!BINDING_EVENTS.contains(event)) { throw new BusinessException("业务事件不合法:" + event); } - String idempotencyKey = text(params.get("idempotencyKey")); - Map businessContext = mapOf(params.get("context")); + String idempotencyKey = WorkflowSqlKit.text(params.get("idempotencyKey")); + Map businessContext = WorkflowSqlKit.mapOf(params.get("context")); String userId = OrgContext.requireUserId(); String orgId = OrgContext.requireOrgId(); @@ -102,24 +106,24 @@ public class WorkflowRuntimeService { where b_module_id = ? and b_business_id = ? and b_idempotency_key = ? """, moduleId, businessId, idempotencyKey); if (exists != null) { - return submittedResult(connection, String.valueOf(exists.get("b_id")), true); + return ledger.submittedResult(connection, String.valueOf(exists.get("b_id")), true); } } requireAction(connection, moduleId, userId, "submit"); Map binding = matchBinding(connection, moduleId, event, businessContext); - String versionId = text(binding.get("b_version_id")); - String definitionId = text(binding.get("b_definition_id")); + String versionId = WorkflowSqlKit.text(binding.get("b_version_id")); + String definitionId = WorkflowSqlKit.text(binding.get("b_definition_id")); Map variables = new LinkedHashMap<>(businessContext); variables.put("b_initiator_id", userId); variables.put("b_org_id", orgId); // 逐个回退取值。用单次表达式而非多次赋值:lambda 要求捕获的局部变量 effectively final。 - String deptFromContext = text(variables.get("b_initiator_dept_id")); - String deptFromVariable = text(variables.get("b_dept_id")); + String deptFromContext = WorkflowSqlKit.text(variables.get("b_initiator_dept_id")); + String deptFromVariable = WorkflowSqlKit.text(variables.get("b_dept_id")); String deptId = !deptFromContext.isEmpty() ? deptFromContext : !deptFromVariable.isEmpty() ? deptFromVariable - : text(WorkflowSql.scalar(connection, + : WorkflowSqlKit.text(WorkflowSql.scalar(connection, "select top 1 b_dept_id from dbo.b_user where b_id = ?", userId)); variables.put("b_initiator_dept_id", deptId); @@ -131,13 +135,13 @@ public class WorkflowRuntimeService { b_context_json, b_snapshot_json, b_row_version, b_idempotency_key, b_started_at, b_created_by, b_created_at, b_updated_by, b_updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, 0, ?, sysutcdatetime(), ?, sysutcdatetime(), ?, sysutcdatetime()) - """, instanceId, definitionId, versionId, text(binding.get("b_id")), moduleId, event, + """, instanceId, definitionId, versionId, WorkflowSqlKit.text(binding.get("b_id")), moduleId, event, businessId, businessNo, orgId, userId, deptId, - toJson(variables), toJson(businessContext), + WorkflowSqlKit.toJson(objectMapper, variables), WorkflowSqlKit.toJson(objectMapper, businessContext), idempotencyKey.isEmpty() ? null : idempotencyKey, userId, userId); WorkflowInstanceRow instance = new WorkflowInstanceRow( - instanceId.toString(), definitionId, versionId, text(binding.get("b_id")), + instanceId.toString(), definitionId, versionId, WorkflowSqlKit.text(binding.get("b_id")), moduleId, businessId, businessNo, userId, deptId, "running", 0 ); @@ -149,17 +153,17 @@ public class WorkflowRuntimeService { if (startNode == null) { throw new BusinessException("流程版本没有开始节点,无法送审"); } - String startKey = text(startNode.get("b_node_key")); - recordNodeRun(connection, instance, startKey, "completed", variables, null, 0); - writeAction(connection, instance, null, null, "submit", userId, null, "running", - businessNo.isEmpty() ? null : "提交审批", idempotencyKey, toJson(businessContext)); - writeOutbox(connection, "instance.submitted", "instance", instance.instanceId(), + String startKey = WorkflowSqlKit.text(startNode.get("b_node_key")); + ledger.recordNodeRun(connection, instance, startKey, "completed", variables, null, 0); + ledger.writeAction(connection, instance, null, null, "submit", userId, null, "running", + businessNo.isEmpty() ? null : "提交审批", idempotencyKey, WorkflowSqlKit.toJson(objectMapper, businessContext)); + ledger.writeOutbox(connection, "instance.submitted", "instance", instance.instanceId(), Map.of("businessId", businessId, "moduleId", moduleId)); advance(connection, instance, startKey, variables, 0); - projectBusinessStatus(connection, instance, currentStatus(connection, instance.instanceId())); + projectBusinessStatus(connection, instance, ledger.currentStatus(connection, instance.instanceId())); - return submittedResult(connection, instance.instanceId(), false); + return ledger.submittedResult(connection, instance.instanceId(), false); }, (connection, cause) -> { // 幂等:并发或重放导致撞 ux_wf_instance_idempotency 时,回查既有实例并返回「重复」结果。 @@ -172,7 +176,7 @@ public class WorkflowRuntimeService { where b_module_id = ? and b_business_id = ? and b_idempotency_key = ? """, moduleId, businessId, idempotencyKey); return duplicate == null ? null - : submittedResult(connection, String.valueOf(duplicate.get("b_id")), true); + : ledger.submittedResult(connection, String.valueOf(duplicate.get("b_id")), true); }); } @@ -191,8 +195,8 @@ public class WorkflowRuntimeService { if (!TASK_ACTIONS.contains(action)) { throw new BusinessException("不支持的审批动作:" + action); } - String comment = text(params.get("comment")); - String idempotencyKey = text(params.get("idempotencyKey")); + String comment = WorkflowSqlKit.text(params.get("comment")); + String idempotencyKey = WorkflowSqlKit.text(params.get("idempotencyKey")); String userId = OrgContext.requireUserId(); return WorkflowTx.run(dataSource, @@ -209,18 +213,18 @@ public class WorkflowRuntimeService { // 任务先做一次无锁读,仅为拿到 instance_id;拿到实例锁后再用 UPDLOCK 重读并校验。 Map task = WorkflowSql.queryOne(connection, """ select b_id, b_instance_id from dbo.wf_task where b_id = ? - """, uuidParam(taskId)); + """, WorkflowSqlKit.uuidParam(taskId)); if (task == null) { throw new BusinessException("任务不存在或已被删除"); } - WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + WorkflowInstanceRow instance = lockInstance(connection, WorkflowSqlKit.text(task.get("b_instance_id"))); if (!"running".equals(instance.status())) { throw new BusinessException("流程已结束(" + instance.status() + "),不能再处理"); } task = lockTask(connection, taskId, instance.instanceId(), Set.of("pending", "claimed"), userId); - String status = text(task.get("b_status")); - if ("delegate".equals(text(task.get("b_assignee_type")))) { + String status = WorkflowSqlKit.text(task.get("b_status")); + if ("delegate".equals(WorkflowSqlKit.text(task.get("b_assignee_type")))) { verifyDelegation(connection, task, action); } @@ -236,53 +240,53 @@ public class WorkflowRuntimeService { select b_id, b_node_key, b_run_no, b_status, b_required_count, b_completed_count, b_rejected_count from dbo.wf_node_run with (UPDLOCK, ROWLOCK) where b_id = ? - """, uuidParam(text(task.get("b_node_run_id")))); + """, WorkflowSqlKit.uuidParam(WorkflowSqlKit.text(task.get("b_node_run_id")))); if (nodeRun == null) { throw new BusinessException("节点运行记录缺失,无法推进流程"); } Map node = WorkflowSql.queryOne(connection, "select b_node_key, b_name, b_approval_mode, b_reject_mode from dbo.wf_node where b_version_id = ? and b_node_key = ?", - instance.versionId(), text(nodeRun.get("b_node_key"))); - String nodeName = node == null ? text(nodeRun.get("b_node_key")) - : (text(node.get("b_name")).isEmpty() ? text(nodeRun.get("b_node_key")) : text(node.get("b_name"))); - String mode = node == null ? "single" : text(node.get("b_approval_mode")); - String rejectMode = node == null ? "terminate" : text(node.get("b_reject_mode")); + instance.versionId(), WorkflowSqlKit.text(nodeRun.get("b_node_key"))); + String nodeName = node == null ? WorkflowSqlKit.text(nodeRun.get("b_node_key")) + : (WorkflowSqlKit.text(node.get("b_name")).isEmpty() ? WorkflowSqlKit.text(nodeRun.get("b_node_key")) : WorkflowSqlKit.text(node.get("b_name"))); + String mode = node == null ? "single" : WorkflowSqlKit.text(node.get("b_approval_mode")); + String rejectMode = node == null ? "terminate" : WorkflowSqlKit.text(node.get("b_reject_mode")); - Map variables = readVariables(connection, instance.instanceId()); + Map variables = ledger.readVariables(connection, instance.instanceId()); if ("approve".equals(action)) { WorkflowSql.update(connection, """ update dbo.wf_task set b_status = 'approved', b_completed_by = ?, b_completed_at = sysutcdatetime(), b_updated_at = sysutcdatetime() where b_id = ? - """, userId, uuidParam(taskId)); + """, userId, WorkflowSqlKit.uuidParam(taskId)); int completed = WorkflowSql.count(connection, "select count(1) from dbo.wf_task where b_node_run_id = ? and b_status = 'approved'", - uuidParam(text(task.get("b_node_run_id")))); + WorkflowSqlKit.uuidParam(WorkflowSqlKit.text(task.get("b_node_run_id")))); WorkflowSql.update(connection, "update dbo.wf_node_run set b_completed_count = ? where b_id = ?", - completed, uuidParam(text(task.get("b_node_run_id")))); + completed, WorkflowSqlKit.uuidParam(WorkflowSqlKit.text(task.get("b_node_run_id")))); int required = nodeRun.get("b_required_count") instanceof Number number ? number.intValue() : 1; if (completed >= Math.max(required, 1)) { // 节点运行状态只有 pending / running / completed / skipped(表注释口径), // 不能写任务的 approved —— 否则并行汇合与「退回上一节点」都找不到已完成节点 - completeNodeRun(connection, text(nodeRun.get("b_id")), "completed"); - cancelSiblingTasks(connection, instance, text(nodeRun.get("b_id")), text(taskId), + ledger.completeNodeRun(connection, WorkflowSqlKit.text(nodeRun.get("b_id")), "completed"); + cancelSiblingTasks(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_id")), WorkflowSqlKit.text(taskId), "同节点其余待办已自动取消"); - writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "approve", + ledger.writeAction(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_id")), taskId, "approve", userId, status, "approved", comment, idempotencyKey, null); - advance(connection, instance, text(nodeRun.get("b_node_key")), variables, 0); - projectBusinessStatus(connection, instance, currentStatus(connection, instance.instanceId())); + advance(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_node_key")), variables, 0); + projectBusinessStatus(connection, instance, ledger.currentStatus(connection, instance.instanceId())); } else { - writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "approve", + ledger.writeAction(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_id")), taskId, "approve", userId, status, "running", comment, idempotencyKey, null); // 串行会签:当前审批人通过后激活下一位(§9.6)。 // 只在「尚未凑够票数」的分支调用,与上面的节点完成分支互斥; // 非串行节点不会产生 waiting 任务,此调用是无副作用的空转。 promoteNextSerialTask(connection, instance, - text(nodeRun.get("b_id")), text(nodeRun.get("b_node_key"))); + WorkflowSqlKit.text(nodeRun.get("b_id")), WorkflowSqlKit.text(nodeRun.get("b_node_key"))); updateInstanceSummary(connection, instance, "审批中:" + nodeName); } } else { @@ -291,39 +295,39 @@ public class WorkflowRuntimeService { update dbo.wf_task set b_status = ?, b_completed_by = ?, b_completed_at = sysutcdatetime(), b_updated_at = sysutcdatetime() where b_id = ? - """, "reject".equals(action) ? "rejected" : "cancelled", userId, uuidParam(taskId)); + """, "reject".equals(action) ? "rejected" : "cancelled", userId, WorkflowSqlKit.uuidParam(taskId)); WorkflowSql.update(connection, "update dbo.wf_node_run set b_rejected_count = b_rejected_count + 1 where b_id = ?", - uuidParam(text(task.get("b_node_run_id")))); - cancelSiblingTasks(connection, instance, text(nodeRun.get("b_id")), taskId, "同节点其余待办已自动取消"); + WorkflowSqlKit.uuidParam(WorkflowSqlKit.text(task.get("b_node_run_id")))); + cancelSiblingTasks(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_id")), taskId, "同节点其余待办已自动取消"); if ("return".equals(action)) { - writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "return", + ledger.writeAction(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_id")), taskId, "return", userId, status, "returned", comment, idempotencyKey, null); finishInstance(connection, instance, "returned", nodeName); } else if ("return_previous".equals(rejectMode)) { - writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "reject", + ledger.writeAction(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_id")), taskId, "reject", userId, status, "rejected", comment, idempotencyKey, - toJson(Map.of("rejectMode", rejectMode))); + WorkflowSqlKit.toJson(objectMapper, Map.of("rejectMode", rejectMode))); // 当前节点必须先收口:否则该 node_run 永远停在 running, // 并行汇合的 allIncomingCompleted 会因「最新一轮运行」判断而误放行后续节点。 - completeNodeRun(connection, text(nodeRun.get("b_id")), "completed"); - reenterPreviousNode(connection, instance, text(nodeRun.get("b_node_key")), variables); + ledger.completeNodeRun(connection, WorkflowSqlKit.text(nodeRun.get("b_id")), "completed"); + reenterPreviousNode(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_node_key")), variables); } else if ("return_initiator".equals(rejectMode)) { - writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "reject", + ledger.writeAction(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_id")), taskId, "reject", userId, status, "returned", comment, idempotencyKey, - toJson(Map.of("rejectMode", rejectMode))); + WorkflowSqlKit.toJson(objectMapper, Map.of("rejectMode", rejectMode))); finishInstance(connection, instance, "returned", nodeName); } else { - completeNodeRun(connection, text(nodeRun.get("b_id")), "completed"); - writeAction(connection, instance, text(nodeRun.get("b_id")), taskId, "reject", + ledger.completeNodeRun(connection, WorkflowSqlKit.text(nodeRun.get("b_id")), "completed"); + ledger.writeAction(connection, instance, WorkflowSqlKit.text(nodeRun.get("b_id")), taskId, "reject", userId, status, "rejected", comment, idempotencyKey, - toJson(Map.of("rejectMode", "terminate"))); + WorkflowSqlKit.toJson(objectMapper, Map.of("rejectMode", "terminate"))); finishInstance(connection, instance, "rejected", nodeName); } } - return submittedResult(connection, instance.instanceId(), false); + return ledger.submittedResult(connection, instance.instanceId(), false); }, (connection, cause) -> { // 幂等:审批请求重放撞 ux_wf_action_request 时,回查已有动作并返回「重复」结果。 @@ -342,7 +346,7 @@ public class WorkflowRuntimeService { /** 撤回:发起人撤回运行中的实例(§20:撤回后重新提交会产生新实例与新快照) */ public Map withdraw(Map params) throws SQLException { String instanceId = ParamUtils.getRequiredString(params, "instanceId"); - String comment = text(params.get("comment")); + String comment = WorkflowSqlKit.text(params.get("comment")); String userId = OrgContext.requireUserId(); return WorkflowTx.run(dataSource, connection -> { @@ -358,19 +362,19 @@ public class WorkflowRuntimeService { "b_status = 'withdrawn', b_completed_at = sysutcdatetime()," + " b_row_version = b_row_version + 1, b_current_summary_json = ?," + " b_updated_by = ?, b_updated_at = sysutcdatetime()", - toJson(Map.of("summary", "已撤回")), userId); - writeAction(connection, instance, null, null, "withdraw", userId, "running", "withdrawn", + WorkflowSqlKit.toJson(objectMapper, Map.of("summary", "已撤回")), userId); + ledger.writeAction(connection, instance, null, null, "withdraw", userId, "running", "withdrawn", comment, null, null); - writeOutbox(connection, "instance.withdrawn", "instance", instance.instanceId(), Map.of()); + ledger.writeOutbox(connection, "instance.withdrawn", "instance", instance.instanceId(), Map.of()); projectBusinessStatus(connection, instance, "withdrawn"); - return submittedResult(connection, instanceId, false); + return ledger.submittedResult(connection, instanceId, false); }); } /** 取消:管理员提前结束流程(§20:由系统记录取消原因) */ public Map cancel(Map params) throws SQLException { String instanceId = ParamUtils.getRequiredString(params, "instanceId"); - String comment = text(params.get("comment")); + String comment = WorkflowSqlKit.text(params.get("comment")); String userId = OrgContext.requireUserId(); return WorkflowTx.run(dataSource, connection -> { @@ -384,12 +388,12 @@ public class WorkflowRuntimeService { "b_status = 'cancelled', b_completed_at = sysutcdatetime()," + " b_row_version = b_row_version + 1, b_current_summary_json = ?," + " b_updated_by = ?, b_updated_at = sysutcdatetime()", - toJson(Map.of("summary", "已取消")), userId); - writeAction(connection, instance, null, null, "cancel", userId, "running", "cancelled", + WorkflowSqlKit.toJson(objectMapper, Map.of("summary", "已取消")), userId); + ledger.writeAction(connection, instance, null, null, "cancel", userId, "running", "cancelled", comment, null, null); - writeOutbox(connection, "instance.cancelled", "instance", instance.instanceId(), Map.of()); + ledger.writeOutbox(connection, "instance.cancelled", "instance", instance.instanceId(), Map.of()); projectBusinessStatus(connection, instance, "cancelled"); - return submittedResult(connection, instanceId, false); + return ledger.submittedResult(connection, instanceId, false); }); } @@ -402,11 +406,11 @@ public class WorkflowRuntimeService { // 加锁顺序:实例行先于任务行。无锁预读只为拿 instance_id,拿到实例锁后再重读校验。 Map task = WorkflowSql.queryOne(connection, """ select b_id, b_instance_id from dbo.wf_task where b_id = ? - """, uuidParam(taskId)); + """, WorkflowSqlKit.uuidParam(taskId)); if (task == null) { throw new BusinessException("任务不存在"); } - WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + WorkflowInstanceRow instance = lockInstance(connection, WorkflowSqlKit.text(task.get("b_instance_id"))); if (!"running".equals(instance.status())) { throw new BusinessException("流程已结束,不能抢占待办"); } @@ -416,11 +420,11 @@ public class WorkflowRuntimeService { // 抢占者必须是该节点的候选审批人:仅凭 action.{模块}.claim 权限就能抢走任意待办属越权。 // 候选集合取自 node_run 的审批人快照(single 模式只给第一个候选建任务,任务表判断不到其他人)。 // 快照解析异常时返回空集合 → 拒绝,属 fail-closed。 - String nodeRunId = text(task.get("b_node_run_id")); + String nodeRunId = WorkflowSqlKit.text(task.get("b_node_run_id")); Map nodeRunRow = WorkflowSql.queryOne(connection, """ select b_actor_snapshot_json from dbo.wf_node_run with (UPDLOCK, ROWLOCK) where b_id = ? - """, uuidParam(nodeRunId)); + """, WorkflowSqlKit.uuidParam(nodeRunId)); List candidates = candidatesOf(nodeRunRow == null ? null : nodeRunRow.get("b_actor_snapshot_json")); if (!candidates.contains(userId)) { @@ -429,10 +433,10 @@ public class WorkflowRuntimeService { Map node = WorkflowSql.queryOne(connection, """ select b_config_json from dbo.wf_node where b_version_id = ? and b_node_key = ? - """, instance.versionId(), text(task.get("b_node_key"))); + """, instance.versionId(), WorkflowSqlKit.text(task.get("b_node_key"))); Map nodeConfig = WorkflowJson.readMapOrNull( node == null ? null : node.get("b_config_json"), - "node.config:" + text(task.get("b_node_key"))); + "node.config:" + WorkflowSqlKit.text(task.get("b_node_key"))); if (!configFlag(nodeConfig, "allowClaim")) { throw new BusinessException("当前节点未开启抢占待办"); } @@ -440,8 +444,8 @@ public class WorkflowRuntimeService { update dbo.wf_task set b_status = 'claimed', b_claimed_by = ?, b_claimed_at = sysutcdatetime(), b_updated_at = sysutcdatetime() where b_id = ? - """, userId, uuidParam(taskId)); - writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "claim", userId, "pending", "claimed", + """, userId, WorkflowSqlKit.uuidParam(taskId)); + ledger.writeAction(connection, instance, WorkflowSqlKit.text(task.get("b_node_run_id")), taskId, "claim", userId, "pending", "claimed", "抢占待办", null, null); return Map.of("taskId", taskId, "status", "claimed"); }); @@ -451,44 +455,44 @@ public class WorkflowRuntimeService { public Map transfer(Map params) throws SQLException { String taskId = ParamUtils.getRequiredString(params, "taskId"); String toUserId = ParamUtils.getRequiredString(params, "toUserId"); - String comment = text(params.get("comment")); + String comment = WorkflowSqlKit.text(params.get("comment")); String userId = OrgContext.requireUserId(); return WorkflowTx.run(dataSource, connection -> { // 加锁顺序:实例行先于任务行。无锁预读只为拿 instance_id。 Map task = WorkflowSql.queryOne(connection, """ select b_id, b_instance_id from dbo.wf_task where b_id = ? - """, uuidParam(taskId)); + """, WorkflowSqlKit.uuidParam(taskId)); if (task == null) { throw new BusinessException("任务不存在"); } requireEnabledUser(connection, toUserId); - WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + WorkflowInstanceRow instance = lockInstance(connection, WorkflowSqlKit.text(task.get("b_instance_id"))); if (!"running".equals(instance.status())) { throw new BusinessException("流程已结束,不能转交待办"); } task = lockTask(connection, taskId, instance.instanceId(), Set.of("pending", "claimed"), userId); - String status = text(task.get("b_status")); + String status = WorkflowSqlKit.text(task.get("b_status")); requireAction(connection, instance.moduleId(), userId, "transfer"); WorkflowSql.update(connection, """ update dbo.wf_task set b_status = 'cancelled', b_updated_at = sysutcdatetime() where b_id = ? - """, uuidParam(taskId)); + """, WorkflowSqlKit.uuidParam(taskId)); UUID newTaskId = UuidV7Generator.nextUuid(); WorkflowSql.update(connection, """ insert into dbo.wf_task (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, b_assignee_id, b_seq, b_status, b_due_at, b_created_at, b_updated_at) values (?, ?, ?, ?, ?, 'delegate', ?, ?, 'pending', ?, sysutcdatetime(), sysutcdatetime()) - """, newTaskId, uuidParam(instance.instanceId()), uuidParam(text(task.get("b_node_run_id"))), - text(task.get("b_node_key")), text(task.get("b_actor_rule_id")), toUserId, + """, newTaskId, WorkflowSqlKit.uuidParam(instance.instanceId()), WorkflowSqlKit.uuidParam(WorkflowSqlKit.text(task.get("b_node_run_id"))), + WorkflowSqlKit.text(task.get("b_node_key")), WorkflowSqlKit.text(task.get("b_actor_rule_id")), toUserId, task.get("b_seq") == null ? 0 : ((Number) task.get("b_seq")).intValue(), // 读出来的 b_due_at 是带 Z 的 UTC 串(TimeUtils 出口口径),回写前折回墙钟值 TimeUtils.toStorageValue("datetime2", task.get("b_due_at"))); - writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "transfer", + ledger.writeAction(connection, instance, WorkflowSqlKit.text(task.get("b_node_run_id")), taskId, "transfer", userId, status, status == null ? null : status, comment, null, - toJson(Map.of("toUserId", toUserId, "newTaskId", newTaskId.toString()))); - writeOutbox(connection, "task.transferred", "task", newTaskId.toString(), + WorkflowSqlKit.toJson(objectMapper, Map.of("toUserId", toUserId, "newTaskId", newTaskId.toString()))); + ledger.writeOutbox(connection, "task.transferred", "task", newTaskId.toString(), Map.of("toUserId", toUserId)); return Map.of("taskId", newTaskId.toString(), "toUserId", toUserId); }); @@ -500,12 +504,12 @@ public class WorkflowRuntimeService { */ public Map addApprover(Map params) throws SQLException { String taskId = ParamUtils.getRequiredString(params, "taskId"); - String comment = text(params.get("comment")); + String comment = WorkflowSqlKit.text(params.get("comment")); List userIds = new ArrayList<>(); Object raw = params == null ? null : params.get("userIds"); if (raw instanceof List list) { list.forEach(item -> { - String value = text(item); + String value = WorkflowSqlKit.text(item); if (!value.isEmpty() && !userIds.contains(value)) { userIds.add(value); } @@ -520,18 +524,18 @@ public class WorkflowRuntimeService { // 加锁顺序:实例行先于任务行。无锁预读只为拿 instance_id。 Map task = WorkflowSql.queryOne(connection, """ select b_id, b_instance_id from dbo.wf_task where b_id = ? - """, uuidParam(taskId)); + """, WorkflowSqlKit.uuidParam(taskId)); if (task == null) { throw new BusinessException("任务不存在"); } - WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + WorkflowInstanceRow instance = lockInstance(connection, WorkflowSqlKit.text(task.get("b_instance_id"))); if (!"running".equals(instance.status())) { throw new BusinessException("流程已结束,不能加签"); } // §9.8:加签由当前任务的处理人发起,故做归属校验 task = lockTask(connection, taskId, instance.instanceId(), Set.of("pending", "claimed", "waiting"), userId); - String status = text(task.get("b_status")); + String status = WorkflowSqlKit.text(task.get("b_status")); requireAction(connection, instance.moduleId(), userId, "add_approver"); int seq = task.get("b_seq") instanceof Number number ? number.intValue() : 0; for (String target : userIds) { @@ -540,7 +544,7 @@ public class WorkflowRuntimeService { select top 1 b_id from dbo.wf_task where b_node_run_id = ? and b_assignee_id = ? and b_status in ('pending', 'claimed', 'waiting') - """, uuidParam(text(task.get("b_node_run_id"))), target); + """, WorkflowSqlKit.uuidParam(WorkflowSqlKit.text(task.get("b_node_run_id"))), target); if (activeTask != null) { throw new BusinessException("用户「" + target + "」已经是该节点的活动审批人"); } @@ -550,13 +554,13 @@ public class WorkflowRuntimeService { (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, b_assignee_id, b_seq, b_status, b_created_at, b_updated_at) values (?, ?, ?, ?, ?, 'user', ?, ?, 'pending', sysutcdatetime(), sysutcdatetime()) - """, UuidV7Generator.nextUuid(), uuidParam(instance.instanceId()), - uuidParam(text(task.get("b_node_run_id"))), text(task.get("b_node_key")), - text(task.get("b_actor_rule_id")), target, seq); + """, UuidV7Generator.nextUuid(), WorkflowSqlKit.uuidParam(instance.instanceId()), + WorkflowSqlKit.uuidParam(WorkflowSqlKit.text(task.get("b_node_run_id"))), WorkflowSqlKit.text(task.get("b_node_key")), + WorkflowSqlKit.text(task.get("b_actor_rule_id")), target, seq); } - writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "add_approver", - userId, status, status, comment, null, toJson(Map.of("userIds", userIds))); - writeOutbox(connection, "task.approver_added", "task", taskId, Map.of("userIds", userIds)); + ledger.writeAction(connection, instance, WorkflowSqlKit.text(task.get("b_node_run_id")), taskId, "add_approver", + userId, status, status, comment, null, WorkflowSqlKit.toJson(objectMapper, Map.of("userIds", userIds))); + ledger.writeOutbox(connection, "task.approver_added", "task", taskId, Map.of("userIds", userIds)); return Map.of("added", userIds.size()); }); } @@ -564,30 +568,30 @@ public class WorkflowRuntimeService { /** 催办:通过 outbox 发提醒,不改变任务状态(§20) */ public Map urge(Map params) throws SQLException { String taskId = ParamUtils.getRequiredString(params, "taskId"); - String comment = text(params.get("comment")); + String comment = WorkflowSqlKit.text(params.get("comment")); String userId = OrgContext.requireUserId(); return WorkflowTx.run(dataSource, connection -> { // 加锁顺序:实例行先于任务行。无锁预读只为拿 instance_id。 Map task = WorkflowSql.queryOne(connection, """ select b_id, b_instance_id from dbo.wf_task where b_id = ? - """, uuidParam(taskId)); + """, WorkflowSqlKit.uuidParam(taskId)); if (task == null) { throw new BusinessException("任务不存在"); } - WorkflowInstanceRow instance = lockInstance(connection, text(task.get("b_instance_id"))); + WorkflowInstanceRow instance = lockInstance(connection, WorkflowSqlKit.text(task.get("b_instance_id"))); if (!"running".equals(instance.status())) { throw new BusinessException("流程已结束,不能催办"); } // 催办只发提醒、不改任务状态,凭动作权限即可,不校验归属 task = lockTask(connection, taskId, instance.instanceId(), Set.of("pending", "claimed"), null); - String status = text(task.get("b_status")); + String status = WorkflowSqlKit.text(task.get("b_status")); requireAction(connection, instance.moduleId(), userId, "urge"); - writeAction(connection, instance, text(task.get("b_node_run_id")), taskId, "urge", userId, + ledger.writeAction(connection, instance, WorkflowSqlKit.text(task.get("b_node_run_id")), taskId, "urge", userId, status, status, comment, null, null); - writeOutbox(connection, "task.urged", "task", taskId, - Map.of("assigneeId", text(task.get("b_assignee_id")))); + ledger.writeOutbox(connection, "task.urged", "task", taskId, + Map.of("assigneeId", WorkflowSqlKit.text(task.get("b_assignee_id")))); return Map.of("urged", true); }); } @@ -609,7 +613,7 @@ public class WorkflowRuntimeService { return; } try { - List> edges = outgoingEdges(connection, instance.versionId(), fromKey); + List> edges = ledger.outgoingEdges(connection, instance.versionId(), fromKey); if (edges.isEmpty()) { failInstance(connection, instance, "节点「" + fromKey + "」没有出线,流程走不下去"); return; @@ -625,12 +629,12 @@ public class WorkflowRuntimeService { if (edges.size() > 1) { Map payload = new LinkedHashMap<>(); payload.put("from", fromKey); - payload.put("chosen", text(chosen.get("b_id"))); + payload.put("chosen", WorkflowSqlKit.text(chosen.get("b_id"))); payload.put("evaluations", trace); - writeAction(connection, instance, null, null, "route", null, null, null, - "路由:" + fromKey + " → " + text(chosen.get("b_to_node_key")), null, toJson(payload)); + ledger.writeAction(connection, instance, null, null, "route", null, null, null, + "路由:" + fromKey + " → " + WorkflowSqlKit.text(chosen.get("b_to_node_key")), null, WorkflowSqlKit.toJson(objectMapper, payload)); } - enterNode(connection, instance, text(chosen.get("b_to_node_key")), variables, depth + 1); + enterNode(connection, instance, WorkflowSqlKit.text(chosen.get("b_to_node_key")), variables, depth + 1); } catch (WorkflowConfigException exception) { // 条件 / 节点配置损坏:绝不能当成「无条件边」放行 —— 那是静默错路由(原型里的真实缺陷)。 // 与「一条都没命中」同处理:实例置 error,并保留本次已记录的表决(§12.4)。 @@ -654,34 +658,34 @@ public class WorkflowRuntimeService { failInstance(connection, instance, "节点「" + nodeKey + "」在流程版本里不存在"); return; } - String nodeType = text(node.get("b_node_type")); + String nodeType = WorkflowSqlKit.text(node.get("b_node_type")); switch (nodeType) { case "end" -> { - recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); - finishInstance(connection, instance, "approved", text(node.get("b_name"))); + ledger.recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + finishInstance(connection, instance, "approved", WorkflowSqlKit.text(node.get("b_name"))); } case "exclusive_gateway" -> { // 网关是瞬时通过的路由点:也留一条 completed 记录, // 否则「查看审批进度」里网关节点没有状态、它前后的连线也算不出是否走过 - recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + ledger.recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); advance(connection, instance, nodeKey, variables, depth + 1); } case "parallel_gateway" -> { // 分叉 / 汇合按**出线数**判定(BPMN 语义):多条出线是分叉,单条出线是汇合。 // 不能只看入线数 —— 分叉点也可能有多条入线(两个审批节点汇到同一个分叉点), // 那时按入线判会把分叉当汇合,等一个永远不来的分支,流程直接卡死。 - List> outgoing = outgoingEdges(connection, instance.versionId(), nodeKey); + List> outgoing = ledger.outgoingEdges(connection, instance.versionId(), nodeKey); if (outgoing.size() >= 2) { - recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + ledger.recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); for (Map edge : outgoing) { - enterNode(connection, instance, text(edge.get("b_to_node_key")), variables, depth + 1); + enterNode(connection, instance, WorkflowSqlKit.text(edge.get("b_to_node_key")), variables, depth + 1); } } else { - List> incoming = incomingEdges(connection, instance.versionId(), nodeKey); + List> incoming = ledger.incomingEdges(connection, instance.versionId(), nodeKey); if (!allIncomingCompleted(connection, instance, incoming)) { return; // 汇合:等其余分支跑完 } - recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + ledger.recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); advance(connection, instance, nodeKey, variables, depth + 1); } } @@ -693,9 +697,9 @@ public class WorkflowRuntimeService { case "service_task" -> { // 服务节点不产生待办:记录节点运行后自动完成并继续路由(当前不做外部调用)。 // 注意动作码是 service —— 此前误标成 cc,会让「服务节点」在动作轨迹里看起来像抄送。 - recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); - writeAction(connection, instance, null, null, "service", null, null, "running", - "服务节点「" + text(node.get("b_name")) + "」自动完成", null, null); + ledger.recordNodeRun(connection, instance, nodeKey, "completed", variables, null, 0); + ledger.writeAction(connection, instance, null, null, "service", null, null, "running", + "服务节点「" + WorkflowSqlKit.text(node.get("b_name")) + "」自动完成", null, null); advance(connection, instance, nodeKey, variables, depth + 1); } case "start" -> advance(connection, instance, nodeKey, variables, depth + 1); @@ -710,8 +714,8 @@ public class WorkflowRuntimeService { Map node, Map variables ) throws SQLException { - String nodeKey = text(node.get("b_node_key")); - String nodeName = text(node.get("b_name")).isEmpty() ? nodeKey : text(node.get("b_name")); + String nodeKey = WorkflowSqlKit.text(node.get("b_node_key")); + String nodeName = WorkflowSqlKit.text(node.get("b_name")).isEmpty() ? nodeKey : WorkflowSqlKit.text(node.get("b_name")); List> rules = WorkflowSql.query(connection, """ select b_actor_type, b_actor_ref, b_priority, b_fallback_type, b_fallback_ref from dbo.wf_actor_rule where b_version_id = ? and b_node_key = ? and b_canuse = 1 @@ -723,31 +727,26 @@ public class WorkflowRuntimeService { String snapshot = actorResolver.snapshotJson(rules, resolved); if (resolved.userIds().isEmpty()) { - recordNodeRun(connection, instance, nodeKey, "running", variables, snapshot, 0); - writeAction(connection, instance, null, null, "add_approver", null, "running", "error", + ledger.recordNodeRun(connection, instance, nodeKey, "running", variables, snapshot, 0); + ledger.writeAction(connection, instance, null, null, "add_approver", null, "running", "error", "节点「" + nodeName + "」解析不到审批人:" + String.join(";", resolved.notes()), null, - toJson(Map.of("notes", resolved.notes()))); + WorkflowSqlKit.toJson(objectMapper, Map.of("notes", resolved.notes()))); failInstance(connection, instance, "节点「" + nodeName + "」解析不到审批人"); return; } - String mode = text(node.get("b_approval_mode")); + String mode = WorkflowSqlKit.text(node.get("b_approval_mode")); if (mode.isEmpty()) { mode = "single"; } int required = requiredCount(mode, node, resolved.userIds().size()); - String nodeRunId = recordNodeRun(connection, instance, nodeKey, "running", variables, snapshot, required); + String nodeRunId = ledger.recordNodeRun(connection, instance, nodeKey, "running", variables, snapshot, required); Integer dueMinutes = dueMinutesOf(node); - List taskUsers = resolved.userIds(); - if ("single".equals(mode) && taskUsers.size() > 1) { - taskUsers = List.of(taskUsers.get(0)); - } - int seq = 0; - for (String userId : taskUsers) { - seq += 10; - // 串行会签:同一时刻只有第一名是 pending,其余 waiting,通过一位激活下一位(§9.6) - String status = "serial".equals(mode) && seq > 10 ? "waiting" : "pending"; + // 串行会签:同一时刻只有第一位是 pending,其余 waiting,通过一位激活下一位(§9.6); + // single 只给第一名候选建任务。生成规则见 WorkflowInstancePolicy.taskSeeds + for (WorkflowInstancePolicy.TaskSeed seed : WorkflowInstancePolicy.taskSeeds(mode, resolved.userIds())) { + String userId = seed.userId(); // 委托(§8.5):创建期就物化到代理人身上,代理人才能在待办列表里看到它。 // 组织取自当前请求上下文(实例的 b_org_id 与之一致)。 WorkflowDelegationService.Delegation delegation = delegationService @@ -756,14 +755,29 @@ public class WorkflowRuntimeService { String assigneeType = delegation == null ? "user" : "delegate"; String taskSnapshot = delegation == null ? snapshot : taskSnapshotWithDelegation(snapshot, userId, delegation.id()); - WorkflowSql.update(connection, """ - insert into dbo.wf_task - (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, - b_assignee_id, b_seq, b_status, b_due_at, b_rule_snapshot_json, b_created_at, b_updated_at) - values (?, ?, ?, ?, null, ?, ?, ?, ?, dateadd(minute, ?, sysutcdatetime()), ?, sysutcdatetime(), sysutcdatetime()) - """, UuidV7Generator.nextUuid(), uuidParam(instance.instanceId()), uuidParam(nodeRunId), - nodeKey, assigneeType, assigneeId, seq, status, dueMinutes, taskSnapshot); - writeOutbox(connection, "task.created", "task", instance.instanceId(), + // b_due_at 必须按「有没有配时限」分成两条 SQL: + // 未配时限时 dueMinutes 是 null,而 `dateadd(minute, ?, ...)` 收到 null 参数时, + // JDBC 会把参数按 nvarchar 发送,SQL Server 直接报 + // 「Argument data type nvarchar is invalid for argument 2 of dateadd function」, + // 整个送审事务失败。不能靠 `? is null` 之类的写法绕过——必须真的插入 NULL。 + if (dueMinutes == null) { + WorkflowSql.update(connection, """ + insert into dbo.wf_task + (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_rule_snapshot_json, b_created_at, b_updated_at) + values (?, ?, ?, ?, null, ?, ?, ?, ?, null, ?, sysutcdatetime(), sysutcdatetime()) + """, UuidV7Generator.nextUuid(), WorkflowSqlKit.uuidParam(instance.instanceId()), WorkflowSqlKit.uuidParam(nodeRunId), + nodeKey, assigneeType, assigneeId, seed.seq(), seed.status(), taskSnapshot); + } else { + WorkflowSql.update(connection, """ + insert into dbo.wf_task + (b_id, b_instance_id, b_node_run_id, b_node_key, b_actor_rule_id, b_assignee_type, + b_assignee_id, b_seq, b_status, b_due_at, b_rule_snapshot_json, b_created_at, b_updated_at) + values (?, ?, ?, ?, null, ?, ?, ?, ?, dateadd(minute, ?, sysutcdatetime()), ?, sysutcdatetime(), sysutcdatetime()) + """, UuidV7Generator.nextUuid(), WorkflowSqlKit.uuidParam(instance.instanceId()), WorkflowSqlKit.uuidParam(nodeRunId), + nodeKey, assigneeType, assigneeId, seed.seq(), seed.status(), dueMinutes, taskSnapshot); + } + ledger.writeOutbox(connection, "task.created", "task", instance.instanceId(), Map.of("nodeKey", nodeKey, "assigneeId", assigneeId)); } updateInstanceSummary(connection, instance, "待审批:" + nodeName); @@ -776,7 +790,7 @@ public class WorkflowRuntimeService { Map node, Map variables ) throws SQLException { - String nodeKey = text(node.get("b_node_key")); + String nodeKey = WorkflowSqlKit.text(node.get("b_node_key")); List> rules = WorkflowSql.query(connection, """ select b_actor_type, b_actor_ref, b_priority, b_fallback_type, b_fallback_ref from dbo.wf_actor_rule where b_version_id = ? and b_node_key = ? and b_canuse = 1 @@ -787,7 +801,7 @@ public class WorkflowRuntimeService { actorResolver.resolve(connection, instance.versionId(), nodeKey, variables, instance.initiatorId(), null); String snapshot = actorResolver.snapshotJson(rules, resolved); - String nodeRunId = recordNodeRun(connection, instance, nodeKey, "completed", variables, snapshot, + String nodeRunId = ledger.recordNodeRun(connection, instance, nodeKey, "completed", variables, snapshot, resolved.userIds().size()); for (String userId : resolved.userIds()) { @@ -796,12 +810,12 @@ public class WorkflowRuntimeService { (b_id, b_instance_id, b_node_run_id, b_node_key, b_recipient_id, b_status, b_rule_snapshot_json, b_created_at) values (?, ?, ?, ?, ?, 'unread', ?, sysutcdatetime()) - """, UuidV7Generator.nextUuid(), uuidParam(instance.instanceId()), uuidParam(nodeRunId), + """, UuidV7Generator.nextUuid(), WorkflowSqlKit.uuidParam(instance.instanceId()), WorkflowSqlKit.uuidParam(nodeRunId), nodeKey, userId, snapshot); - writeOutbox(connection, "cc.created", "instance", instance.instanceId(), Map.of("recipientId", userId)); + ledger.writeOutbox(connection, "cc.created", "instance", instance.instanceId(), Map.of("recipientId", userId)); } - writeAction(connection, instance, nodeRunId, null, "cc", null, "running", "running", - "抄送「" + text(node.get("b_name")) + "」共 " + resolved.userIds().size() + " 人", null, null); + ledger.writeAction(connection, instance, nodeRunId, null, "cc", null, "running", "running", + "抄送「" + WorkflowSqlKit.text(node.get("b_name")) + "」共 " + resolved.userIds().size() + " 人", null, null); } /** 退回上一节点:找最近一次已完成的人工节点重跑(找不到就退回发起人) */ @@ -818,7 +832,7 @@ public class WorkflowRuntimeService { where r.b_instance_id = ? and r.b_node_key <> ? and r.b_status = 'completed' and n.b_node_type = 'user_task' order by r.b_entered_at desc - """, instance.versionId(), uuidParam(instance.instanceId()), fromNodeKey); + """, instance.versionId(), WorkflowSqlKit.uuidParam(instance.instanceId()), fromNodeKey); if (previous == null) { finishInstance(connection, instance, "returned", "退回发起人(上一节点不存在)"); return; @@ -826,7 +840,7 @@ public class WorkflowRuntimeService { Map node = WorkflowSql.queryOne(connection, """ select b_node_key, b_name, b_node_type, b_approval_mode, b_reject_mode, b_due_hours, b_config_json from dbo.wf_node where b_version_id = ? and b_node_key = ? - """, instance.versionId(), text(previous.get("b_node_key"))); + """, instance.versionId(), WorkflowSqlKit.text(previous.get("b_node_key"))); if (node == null) { finishInstance(connection, instance, "returned", "退回发起人(上一节点已不存在)"); return; @@ -838,44 +852,6 @@ public class WorkflowRuntimeService { 实例与节点状态维护 ============================================================================ */ - /** - * 记录一次节点运行,返回 node_run 主键。 - * run_no 递增支持「退回后重新进入同一节点」(§8.2)。 - */ - private String recordNodeRun( - Connection connection, - WorkflowInstanceRow instance, - String nodeKey, - String status, - Map variables, - String snapshot, - int requiredCount - ) throws SQLException { - int runNo = WorkflowSql.count(connection, - "select count(1) from dbo.wf_node_run where b_instance_id = ? and b_node_key = ?", - uuidParam(instance.instanceId()), nodeKey) + 1; - UUID nodeRunId = UuidV7Generator.nextUuid(); - WorkflowSql.update(connection, """ - insert into dbo.wf_node_run - (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, b_completed_count, - b_rejected_count, b_entered_at, b_exited_at, b_actor_snapshot_json, b_created_at) - values (?, ?, ?, ?, ?, ?, 0, 0, sysutcdatetime(), null, ?, sysutcdatetime()) - """, nodeRunId, uuidParam(instance.instanceId()), nodeKey, runNo, status, - requiredCount <= 0 ? null : requiredCount, snapshot); - // 完成态插入后立刻补 exited_at:状态与完成时间都走 completeNodeRun, - // 时间一律取数据库时钟 sysutcdatetime()(UTC 口径,见 TimeUtils)—— 不混入 Java 本地时间,读写两端一致 - if ("completed".equals(status) || "skipped".equals(status)) { - completeNodeRun(connection, nodeRunId.toString(), status); - } - return nodeRunId.toString(); - } - - private void completeNodeRun(Connection connection, String nodeRunId, String status) throws SQLException { - WorkflowSql.update(connection, """ - update dbo.wf_node_run set b_status = ?, b_exited_at = sysutcdatetime() where b_id = ? - """, status, uuidParam(nodeRunId)); - } - /** 结束实例:写终态、取消未完成待办、投影业务状态、发通知 */ private void finishInstance( Connection connection, @@ -895,11 +871,11 @@ public class WorkflowRuntimeService { updateInstance(connection, instance, "b_status = ?, b_completed_at = sysutcdatetime(), b_row_version = b_row_version + 1," + " b_current_summary_json = ?, b_updated_by = ?, b_updated_at = sysutcdatetime()", - status, toJson(Map.of("summary", summary, "node", nodeName == null ? "" : nodeName)), + status, WorkflowSqlKit.toJson(objectMapper, Map.of("summary", summary, "node", nodeName == null ? "" : nodeName)), instance.initiatorId()); - writeAction(connection, instance, null, null, status.equals("approved") ? "approve" : "reject", + ledger.writeAction(connection, instance, null, null, status.equals("approved") ? "approve" : "reject", null, "running", status, summary, null, null); - writeOutbox(connection, "instance." + status, "instance", instance.instanceId(), Map.of("summary", summary)); + ledger.writeOutbox(connection, "instance." + status, "instance", instance.instanceId(), Map.of("summary", summary)); projectBusinessStatus(connection, instance, status); } @@ -924,20 +900,20 @@ public class WorkflowRuntimeService { b_seq, b_status, b_due_at, b_assignee_id, b_claimed_by, b_rule_snapshot_json from dbo.wf_task with (UPDLOCK, ROWLOCK) where b_id = ? - """, uuidParam(taskId)); + """, WorkflowSqlKit.uuidParam(taskId)); if (task == null) { throw new BusinessException("任务不存在或已被删除"); } - if (!instanceId.equals(text(task.get("b_instance_id")))) { + if (!instanceId.equals(WorkflowSqlKit.text(task.get("b_instance_id")))) { throw new BusinessException("任务不属于该流程实例"); } - String status = text(task.get("b_status")); + String status = WorkflowSqlKit.text(task.get("b_status")); if (!allowedStatuses.contains(status)) { throw new BusinessException("任务已被处理或状态不允许操作(当前:" + status + ")"); } if (ownerUserId != null) { - String assignee = text(task.get("b_assignee_id")); - String claimedBy = text(task.get("b_claimed_by")); + String assignee = WorkflowSqlKit.text(task.get("b_assignee_id")); + String claimedBy = WorkflowSqlKit.text(task.get("b_claimed_by")); if (!ownerUserId.equals(assignee) && !ownerUserId.equals(claimedBy)) { throw new BusinessException("该任务不属于当前用户"); } @@ -960,7 +936,7 @@ public class WorkflowRuntimeService { for (Object arg : args) { params.add(arg); } - params.add(uuidParam(instance.instanceId())); + params.add(WorkflowSqlKit.uuidParam(instance.instanceId())); params.add(instance.rowVersion()); int updated = WorkflowSql.update(connection, "update dbo.wf_instance set " + setClause + " where b_id = ? and b_row_version = ?", @@ -976,8 +952,8 @@ public class WorkflowRuntimeService { updateInstance(connection, instance, "b_status = 'error', b_row_version = b_row_version + 1," + " b_current_summary_json = ?, b_updated_by = ?, b_updated_at = sysutcdatetime()", - toJson(Map.of("summary", message)), instance.initiatorId()); - writeOutbox(connection, "instance.error", "instance", instance.instanceId(), Map.of("message", message)); + WorkflowSqlKit.toJson(objectMapper, Map.of("summary", message)), instance.initiatorId()); + ledger.writeOutbox(connection, "instance.error", "instance", instance.instanceId(), Map.of("message", message)); projectBusinessStatus(connection, instance, "error"); } @@ -986,7 +962,7 @@ public class WorkflowRuntimeService { updateInstance(connection, instance, "b_current_summary_json = ?, b_row_version = b_row_version + 1," + " b_updated_by = ?, b_updated_at = sysutcdatetime()", - toJson(Map.of("summary", summary)), instance.initiatorId()); + WorkflowSqlKit.toJson(objectMapper, Map.of("summary", summary)), instance.initiatorId()); } /** 取消同一节点下的其余未完成待办(首票 / 驳回后自动作废,写系统动作留痕) */ @@ -1000,14 +976,14 @@ public class WorkflowRuntimeService { List> siblings = WorkflowSql.query(connection, """ select b_id from dbo.wf_task with (UPDLOCK, ROWLOCK) where b_node_run_id = ? and b_status in ('pending', 'claimed', 'waiting') and b_id <> ? - """, uuidParam(nodeRunId), uuidParam(exceptTaskId)); + """, WorkflowSqlKit.uuidParam(nodeRunId), WorkflowSqlKit.uuidParam(exceptTaskId)); for (Map row : siblings) { WorkflowSql.update(connection, "update dbo.wf_task set b_status = 'cancelled', b_updated_at = sysutcdatetime() where b_id = ?", - uuidParam(text(row.get("b_id")))); + WorkflowSqlKit.uuidParam(WorkflowSqlKit.text(row.get("b_id")))); } if (!siblings.isEmpty()) { - writeAction(connection, instance, nodeRunId, null, "cancel", null, "pending", "cancelled", + ledger.writeAction(connection, instance, nodeRunId, null, "cancel", null, "pending", "cancelled", reason + "(" + siblings.size() + " 条)", null, null); } } @@ -1029,39 +1005,33 @@ public class WorkflowRuntimeService { from dbo.wf_task with (UPDLOCK, ROWLOCK) where b_node_run_id = ? and b_status = 'waiting' order by b_seq, b_id - """, uuidParam(nodeRunId)); + """, WorkflowSqlKit.uuidParam(nodeRunId)); if (next == null) { return; } - String nextTaskId = text(next.get("b_id")); - String nextAssignee = text(next.get("b_assignee_id")); + String nextTaskId = WorkflowSqlKit.text(next.get("b_id")); + String nextAssignee = WorkflowSqlKit.text(next.get("b_assignee_id")); WorkflowSql.update(connection, """ update dbo.wf_task set b_status = 'pending', b_updated_at = sysutcdatetime() where b_id = ? - """, uuidParam(nextTaskId)); - writeAction(connection, instance, nodeRunId, nextTaskId, "activate", null, "waiting", "pending", + """, WorkflowSqlKit.uuidParam(nextTaskId)); + ledger.writeAction(connection, instance, nodeRunId, nextTaskId, "activate", null, "waiting", "pending", "串行会签:激活下一位审批人 " + nextAssignee, null, null); Map payload = new LinkedHashMap<>(); payload.put("nodeKey", nodeKey); payload.put("taskId", nextTaskId); payload.put("assigneeId", nextAssignee); - writeOutbox(connection, "task.activated", "task", instance.instanceId(), payload); + ledger.writeOutbox(connection, "task.activated", "task", instance.instanceId(), payload); } - /** - * 从 node_run 的审批人快照取候选审批人(快照结构见 {@code WorkflowActorResolver.snapshotJson}: - * {@code {"rules":[...],"resolvedUsers":[...],"notes":[...]}})。 - * - *

解析不出来时返回空集合 —— 调用方(抢占归属校验)按 fail-closed 处理。 - */ /** 把「代某人审批」并进任务快照:动作期据此复核委托范围并留痕(谁在代谁)。 */ private String taskSnapshotWithDelegation(String nodeSnapshot, String delegatedFrom, String delegationId) { Map merged = new LinkedHashMap<>(); merged.put("delegatedFrom", delegatedFrom); merged.put("delegationId", delegationId); merged.put("node", WorkflowJson.readMapOrEmpty(nodeSnapshot, "task.snapshot")); - return toJson(merged); + return WorkflowSqlKit.toJson(objectMapper, merged); } /** @@ -1072,7 +1042,7 @@ public class WorkflowRuntimeService { throws SQLException { Map snapshot = WorkflowJson.readMapOrEmpty(task.get("b_rule_snapshot_json"), "task.snapshot"); - String delegationId = text(snapshot.get("delegationId")); + String delegationId = WorkflowSqlKit.text(snapshot.get("delegationId")); if (delegationId.isEmpty()) { return; } @@ -1085,6 +1055,12 @@ public class WorkflowRuntimeService { } } + /** + * 从 node_run 的审批人快照取候选审批人(快照结构见 {@code WorkflowActorResolver.snapshotJson}: + * {@code {"rules":[...],"resolvedUsers":[...],"notes":[...]}})。 + * + *

解析不出来时返回空集合 —— 调用方(抢占归属校验)按 fail-closed 处理。 + */ private List candidatesOf(Object snapshotJson) { Map snapshot = WorkflowJson.readMapOrEmpty(snapshotJson, "nodeRun.actorSnapshot"); if (!(snapshot.get("resolvedUsers") instanceof List users)) { @@ -1103,7 +1079,7 @@ public class WorkflowRuntimeService { WorkflowSql.update(connection, """ update dbo.wf_task set b_status = 'cancelled', b_updated_at = sysutcdatetime() where b_instance_id = ? and b_status in ('pending', 'claimed', 'waiting') - """, uuidParam(instanceId)); + """, WorkflowSqlKit.uuidParam(instanceId)); } /* ============================================================================ @@ -1128,12 +1104,12 @@ public class WorkflowRuntimeService { order by b.b_priority, b.b_id """, moduleId, event); for (Map binding : bindings) { - if (!"published".equals(text(binding.get("version_status")))) { + if (!"published".equals(WorkflowSqlKit.text(binding.get("version_status")))) { continue; // 停用版本不再接受新实例 } if (!WorkflowConditionEvaluator.evaluate( WorkflowJson.readMapOrNull(binding.get("b_match_json"), - "binding.match:" + text(binding.get("b_id"))), + "binding.match:" + WorkflowSqlKit.text(binding.get("b_id"))), context)) { continue; } @@ -1154,38 +1130,41 @@ public class WorkflowRuntimeService { if (binding == null) { return; } - String statusField = text(binding.get("b_status_field")); - if (statusField.isEmpty()) { - return; - } Map mapping = WorkflowJson.readMapOrNull(binding.get("b_status_map_json"), "binding.statusMap:" + instance.bindingId()); - String target = text(mapping.get(status)); - if (target.isEmpty()) { - return; - } Map module = WorkflowSql.queryOne(connection, "select b_save_table, b_key_field from dbo.s_module where b_id = ?", instance.moduleId()); - if (module == null) { + String saveTable = module == null ? "" : WorkflowSqlKit.text(module.get("b_save_table")); + String keyField = module == null ? "" : WorkflowSqlKit.text(module.get("b_key_field")); + String statusField = WorkflowSqlKit.text(binding.get("b_status_field")); + String target = WorkflowSqlKit.text(mapping.get(status)); + + // 目标键列类型与版本列都要查库,先按「可能是字符键 / 无版本列」探一次, + // 真正需要更新时再查实(跳过分支不该产生多余的元数据查询) + boolean textKey = !saveTable.isEmpty() && !keyField.isEmpty() + && isTextKey(connection, saveTable, keyField); + boolean hasVersionColumn = !saveTable.isEmpty() + && WorkflowSql.columnExists(connection, "dbo." + saveTable, "b_row_version"); + + WorkflowInstancePolicy.ProjectionDecision decision = WorkflowInstancePolicy.decideProjection( + saveTable, keyField, statusField, target, instance.businessId(), textKey, hasVersionColumn); + + if (decision instanceof WorkflowInstancePolicy.ProjectionDecision.Skip) { return; } - String saveTable = text(module.get("b_save_table")); - String keyField = text(module.get("b_key_field")); - if (saveTable.isEmpty() || keyField.isEmpty()) { - return; + if (decision instanceof WorkflowInstancePolicy.ProjectionDecision.Illegal illegal) { + throw new BusinessException(illegal.message()); } - if (!SAFE_IDENTIFIER.matcher(saveTable).matches() || !SAFE_IDENTIFIER.matcher(keyField).matches() - || !SAFE_IDENTIFIER.matcher(statusField).matches()) { - throw new BusinessException("业务状态投影的表名 / 字段名不合法:" + saveTable + "." + statusField); - } - // 业务主键可能是 bigint 雪花(工作流里统一按字符串保存):值不是数字时跳过投影并留痕, - // 不要让「配置不匹配」把整个审批事务打回滚(投影是副作用,状态已在实例上落定) - if (!isTextKey(connection, saveTable, keyField) && !isDigits(instance.businessId())) { - writeOutbox(connection, "instance.projection_skipped", "instance", instance.instanceId(), - Map.of("reason", "业务主键不是数字,无法匹配列类型", "businessId", instance.businessId(), + if (decision instanceof WorkflowInstancePolicy.ProjectionDecision.KeyMismatch mismatch) { + ledger.writeOutbox(connection, "instance.projection_skipped", "instance", instance.instanceId(), + Map.of("reason", mismatch.reason(), "businessId", instance.businessId(), "target", saveTable + "." + keyField)); return; } + + WorkflowInstancePolicy.ProjectionDecision.Update update = + (WorkflowInstancePolicy.ProjectionDecision.Update) decision; + // 读投影前的旧值,供审计留痕(§11 要求记录字段级前后值) Object before = WorkflowSql.scalar(connection, "select [" + statusField + "] from dbo.[" + saveTable + "] where [" + keyField + "] = ?", @@ -1193,8 +1172,7 @@ public class WorkflowRuntimeService { // 业务表若带 b_row_version,投影时一并自增:否则工作流改了这一行却不动版本, // 会让通用保存路径基于 $original / b_row_version 的并发校验读到陈旧版本而误判为无冲突。 - boolean hasVersionColumn = WorkflowSql.columnExists(connection, "dbo." + saveTable, "b_row_version"); - int updated = hasVersionColumn + int updated = update.withRowVersion() ? WorkflowSql.update(connection, "update dbo.[" + saveTable + "]" + " set [" + statusField + "] = ?, [b_row_version] = [b_row_version] + 1" @@ -1206,13 +1184,10 @@ public class WorkflowRuntimeService { // 投影未命中恰好 1 行:并发编辑 / 记录不存在 / 键类型不匹配。 // 投影是副作用且实例状态已落定,这里只留痕不回滚(运行时语义见 sql/README)。 - if (updated != 1) { - Map payload = new LinkedHashMap<>(); - payload.put("reason", "业务状态投影影响行数=" + updated); - payload.put("target", saveTable + "." + keyField + " / " + statusField); - payload.put("businessId", instance.businessId()); - payload.put("hasVersionColumn", hasVersionColumn); - writeOutbox(connection, "instance.projection_missing", "instance", instance.instanceId(), payload); + Map missing = WorkflowInstancePolicy.projectionOutcome( + updated, saveTable, keyField, statusField, instance.businessId(), hasVersionColumn); + if (missing != null) { + ledger.writeOutbox(connection, "instance.projection_missing", "instance", instance.instanceId(), missing); return; } writeAudit(connection, instance, statusField, @@ -1226,7 +1201,7 @@ public class WorkflowRuntimeService { private void writeAudit(Connection connection, WorkflowInstanceRow instance, String field, String beforeValue, String afterValue) throws SQLException { if (!WorkflowSql.tableExists(connection, "dbo.s_log_audit")) { - writeOutbox(connection, "audit.skipped", "instance", instance.instanceId(), + ledger.writeOutbox(connection, "audit.skipped", "instance", instance.instanceId(), Map.of("reason", "s_log_audit 不存在,投影审计未落库", "field", field)); return; } @@ -1237,8 +1212,8 @@ public class WorkflowRuntimeService { b_operator_id, b_source_type, b_visibility, b_payload_text, b_occurdatetime) values (?, 'workflow.projection', ?, ?, ?, '审批状态投影', ?, 'system', 'internal', ?, sysutcdatetime()) - """, uuidParam(eventId), instance.moduleId(), instance.businessId(), instance.businessNo(), - instance.initiatorId(), toJson(Map.of( + """, WorkflowSqlKit.uuidParam(eventId), instance.moduleId(), instance.businessId(), instance.businessNo(), + instance.initiatorId(), WorkflowSqlKit.toJson(objectMapper, Map.of( "instanceId", instance.instanceId(), "field", field, "before", beforeValue == null ? "" : beforeValue, @@ -1247,7 +1222,7 @@ public class WorkflowRuntimeService { insert into dbo.s_log_audit_field (b_id, b_event_id, b_module_id, b_data_id, b_field, b_before_value, b_after_value, b_visibility) values (?, ?, ?, ?, ?, ?, ?, 'internal') - """, UuidV7Generator.nextUuid(), uuidParam(eventId), instance.moduleId(), + """, UuidV7Generator.nextUuid(), WorkflowSqlKit.uuidParam(eventId), instance.moduleId(), instance.businessId(), field, beforeValue, afterValue); } @@ -1258,45 +1233,24 @@ public class WorkflowRuntimeService { join sys.types t on t.user_type_id = c.user_type_id where c.object_id = object_id(?) and c.name = ? """, "dbo." + table, column); - String name = text(typeName).toLowerCase(); + String name = WorkflowSqlKit.text(typeName).toLowerCase(); return name.contains("char") || name.contains("text"); } - private boolean isDigits(String value) { - if (value == null || value.isEmpty()) { - return false; - } - for (int index = 0; index < value.length(); index++) { - if (!Character.isDigit(value.charAt(index))) { - return false; - } - } - return true; - } - /* ============================================================================ 权限、审计与结果集 ============================================================================ */ /** * 动作权限校验(§10.1 第一层):白名单语义 —— s_power 里没有对应权限点即拒绝。 - * 权限点编码统一为 {@code action.{模块}.{动作}}(§10.2)。 + * + *

复用 {@link SqlPermissionService#requireAction(Connection, String, String, String)}: + * 权限判定的匹配列({@code b_object_type='module'} + {@code b_object_id=模块} + {@code b_action=动作}) + * 与 {@code WorkflowConfigService.ensureActionPowers} 写入的一致,管理员提权口径也统一到 + * {@link SqlPermissionService#isAdministrator},本类不再自带硬编码账号。 */ - private void requireAction(Connection connection, String moduleId, String userId, String action) - throws SQLException { - if (ADMIN_ACCOUNT.equalsIgnoreCase(userId)) { - return; - } - String powerId = "action." + moduleId + "." + action; - Map row = WorkflowSql.queryOne(connection, """ - select top 1 p.b_id - from dbo.s_power p - join dbo.s_user_power up on up.b_power_id = p.b_id and up.b_canuse = 1 - where p.b_id = ? and p.b_canuse = 1 and up.b_user_id = ? - """, powerId, userId); - if (row == null) { - throw new BusinessException("缺少动作权限「" + powerId + "」,请联系管理员在用户权限里授权"); - } + private void requireAction(Connection connection, String moduleId, String userId, String action) { + permissionService.requireAction(connection, moduleId, userId, action); } private void requireEnabledUser(Connection connection, String userId) throws SQLException { @@ -1307,70 +1261,6 @@ public class WorkflowRuntimeService { } } - private void writeAction( - Connection connection, - WorkflowInstanceRow instance, - String nodeRunId, - String taskId, - String action, - String operatorId, - String fromStatus, - String toStatus, - String comment, - String requestId, - String payloadJson - ) throws SQLException { - WorkflowSql.update(connection, """ - insert into dbo.wf_action - (b_id, b_instance_id, b_node_run_id, b_task_id, b_action, b_operator_id, b_from_status, - b_to_status, b_comment, b_payload_json, b_request_id, b_occurdatetime) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, sysutcdatetime()) - """, UuidV7Generator.nextUuid(), uuidParam(instance.instanceId()), - nodeRunId == null ? null : uuidParam(nodeRunId), - taskId == null ? null : uuidParam(taskId), - action, operatorId == null ? null : operatorId, - fromStatus, toStatus, comment == null ? null : comment, - payloadJson, requestId == null || requestId.isEmpty() ? null : requestId); - } - - private void writeOutbox( - Connection connection, - String eventCode, - String aggregateType, - String aggregateId, - Map payload - ) throws SQLException { - WorkflowSql.update(connection, """ - insert into dbo.wf_outbox - (b_id, b_event_code, b_aggregate_type, b_aggregate_id, b_payload_json, b_status, b_retry_count, - b_occurdatetime) - values (?, ?, ?, ?, ?, 'pending', 0, sysutcdatetime()) - """, UuidV7Generator.nextUuid(), eventCode, aggregateType, aggregateId, toJson(payload)); - } - - private Map submittedResult(Connection connection, String instanceId, boolean duplicated) - throws SQLException { - Map row = WorkflowSql.queryOne(connection, """ - select b_status, b_current_summary_json from dbo.wf_instance where b_id = ? - """, uuidParam(instanceId)); - int tasks = WorkflowSql.count(connection, - "select count(1) from dbo.wf_task where b_instance_id = ? and b_status in ('pending', 'claimed')", - uuidParam(instanceId)); - Map result = new LinkedHashMap<>(); - result.put("instanceId", instanceId); - result.put("status", row == null ? null : text(row.get("b_status"))); - result.put("currentSummary", row == null ? null - : WorkflowJson.readMapOrEmpty(row.get("b_current_summary_json"), "instance.currentSummary")); - result.put("pendingTaskCount", tasks); - result.put("duplicated", duplicated); - return result; - } - - private String currentStatus(Connection connection, String instanceId) throws SQLException { - return text(WorkflowSql.scalar(connection, "select b_status from dbo.wf_instance where b_id = ?", - uuidParam(instanceId))); - } - /* ============================================================================ 通用小工具 ============================================================================ */ @@ -1381,54 +1271,32 @@ public class WorkflowRuntimeService { b_initiator_id, b_initiator_dept_id, b_status, b_row_version from dbo.wf_instance with (UPDLOCK, ROWLOCK) where b_id = ? - """, uuidParam(instanceId)); + """, WorkflowSqlKit.uuidParam(instanceId)); if (row == null) { throw new BusinessException("流程实例不存在"); } return new WorkflowInstanceRow( - text(row.get("b_id")), text(row.get("b_definition_id")), text(row.get("b_version_id")), - text(row.get("b_binding_id")), text(row.get("b_module_id")), text(row.get("b_business_id")), - text(row.get("b_business_no")), text(row.get("b_initiator_id")), text(row.get("b_initiator_dept_id")), - text(row.get("b_status")), + WorkflowSqlKit.text(row.get("b_id")), WorkflowSqlKit.text(row.get("b_definition_id")), WorkflowSqlKit.text(row.get("b_version_id")), + WorkflowSqlKit.text(row.get("b_binding_id")), WorkflowSqlKit.text(row.get("b_module_id")), WorkflowSqlKit.text(row.get("b_business_id")), + WorkflowSqlKit.text(row.get("b_business_no")), WorkflowSqlKit.text(row.get("b_initiator_id")), WorkflowSqlKit.text(row.get("b_initiator_dept_id")), + WorkflowSqlKit.text(row.get("b_status")), row.get("b_row_version") instanceof Number number ? number.longValue() : 0 ); } - private Map readVariables(Connection connection, String instanceId) throws SQLException { - Object json = WorkflowSql.scalar(connection, - "select b_context_json from dbo.wf_instance where b_id = ?", uuidParam(instanceId)); - return WorkflowJson.readMapOrEmpty(json, "instance.context"); - } - - private List> outgoingEdges(Connection connection, String versionId, String nodeKey) - throws SQLException { - return WorkflowSql.query(connection, """ - select b_id, b_to_node_key, b_condition_json from dbo.wf_edge - where b_version_id = ? and b_from_node_key = ? and b_canuse = 1 - order by b_priority, b_id - """, versionId, nodeKey); - } - - private List> incomingEdges(Connection connection, String versionId, String nodeKey) - throws SQLException { - return WorkflowSql.query(connection, """ - select b_id, b_from_node_key, b_condition_json from dbo.wf_edge - where b_version_id = ? and b_to_node_key = ? and b_canuse = 1 - order by b_priority, b_id - """, versionId, nodeKey); - } - - /** 选边:按 b_priority 顺序,第一条条件命中(或无条件)即用(§6.5) */ - /** 选边:按 b_priority 逐条求值,第一条命中(或无条件)即用(§6.5);同时把每条出线的求值结果写进 trace 供留痕。 */ + /** + * 选边:按 b_priority 逐条求值,第一条命中(或无条件)即用(§6.5); + * 同时把每条出线的求值结果写进 trace 供路由留痕。 + */ private Map chooseEdge(List> edges, Map variables, List> trace) { for (Map edge : edges) { Map condition = WorkflowJson.readMapOrNull(edge.get("b_condition_json"), - "edge.cond:" + text(edge.get("b_id"))); + "edge.cond:" + WorkflowSqlKit.text(edge.get("b_id"))); boolean matched = WorkflowConditionEvaluator.evaluate(condition, variables); Map step = new LinkedHashMap<>(); - step.put("edgeId", text(edge.get("b_id"))); - step.put("to", text(edge.get("b_to_node_key"))); + step.put("edgeId", WorkflowSqlKit.text(edge.get("b_id"))); + step.put("to", WorkflowSqlKit.text(edge.get("b_to_node_key"))); step.put("default", condition == null || condition.isEmpty()); step.put("matched", matched); trace.add(step); @@ -1450,8 +1318,8 @@ public class WorkflowRuntimeService { select top 1 b_status from dbo.wf_node_run where b_instance_id = ? and b_node_key = ? order by b_run_no desc - """, uuidParam(instance.instanceId()), text(edge.get("b_from_node_key"))); - if (!"completed".equals(text(status))) { + """, WorkflowSqlKit.uuidParam(instance.instanceId()), WorkflowSqlKit.text(edge.get("b_from_node_key"))); + if (!"completed".equals(WorkflowSqlKit.text(status))) { return false; } } @@ -1465,28 +1333,15 @@ public class WorkflowRuntimeService { * 「解析为空走 fallback」而变少,配置的应签人数若高于实际候选数,节点就永远等不到足够票数。 */ private int requiredCount(String mode, Map node, int candidateCount) { - Map config = WorkflowJson.readMapOrNull(node.get("b_config_json"), - "node.config:" + text(node.get("b_node_key"))); - int candidates = Math.max(candidateCount, 1); - return switch (mode) { - case "all", "serial" -> candidates; - case "n_of_m" -> Math.min(Math.max((int) number(config.get("requiredCount"), 1), 1), candidates); - case "percentage" -> { - double ratio = number(config.get("requiredRatio"), 0); - int count = BigDecimal.valueOf(candidates) - .multiply(BigDecimal.valueOf(ratio)) - .setScale(0, RoundingMode.CEILING) - .intValue(); - yield Math.min(Math.max(count, 1), candidates); - } - default -> 1; - }; + return WorkflowInstancePolicy.requiredCount(mode, + WorkflowJson.readMapOrNull(node.get("b_config_json"), + "node.config:" + WorkflowSqlKit.text(node.get("b_node_key"))), + candidateCount); } /** 审批时限 → 分钟;没配置返回 null(不设应处理时间)。时间基准由数据库 sysutcdatetime() 承担 */ private Integer dueMinutesOf(Map node) { - double hours = number(node.get("b_due_hours"), 0); - return hours <= 0 ? null : (int) Math.round(hours * 60); + return WorkflowInstancePolicy.dueMinutesOf(node.get("b_due_hours")); } private double number(Object value, double fallback) { @@ -1517,19 +1372,12 @@ public class WorkflowRuntimeService { private Set excludedActors(Connection connection, WorkflowInstanceRow instance, Map node) throws SQLException { Map config = WorkflowJson.readMapOrNull(node.get("b_config_json"), - "node.config:" + text(node.get("b_node_key"))); - Set excluded = new LinkedHashSet<>(); - if (config == null || config.isEmpty()) { - return excluded; - } - if (Boolean.FALSE.equals(configFlagOrNull(config, "allowInitiator")) - || Boolean.TRUE.equals(configFlagOrNull(config, "excludeCreator"))) { - excluded.add(instance.initiatorId()); - } - if (Boolean.TRUE.equals(configFlagOrNull(config, "excludePreviousActors"))) { + "node.config:" + WorkflowSqlKit.text(node.get("b_node_key"))); + Set excluded = WorkflowInstancePolicy.excludedFromConfig(config, instance.initiatorId()); + if (WorkflowInstancePolicy.excludesPreviousActors(config)) { List> runs = WorkflowSql.query(connection, """ select b_actor_snapshot_json from dbo.wf_node_run where b_instance_id = ? - """, uuidParam(instance.instanceId())); + """, WorkflowSqlKit.uuidParam(instance.instanceId())); for (Map run : runs) { excluded.addAll(candidatesOf(run.get("b_actor_snapshot_json"))); } @@ -1540,77 +1388,11 @@ public class WorkflowRuntimeService { /** 三态读布尔配置:显式为 true / false 才返回,缺失或无法识别返回 null(用于区分「没配」与「配了 false」)。 */ private Boolean configFlagOrNull(Map config, String key) { - Object value = config.get(key); - if (value instanceof Boolean flag) { - return flag; - } - if (value instanceof Number number) { - return number.intValue() != 0; - } - if (value instanceof String string) { - String trimmed = string.trim(); - if (trimmed.equalsIgnoreCase("true")) { - return Boolean.TRUE; - } - if (trimmed.equalsIgnoreCase("false")) { - return Boolean.FALSE; - } - } - return null; + return WorkflowInstancePolicy.configFlagOrNull(config, key); } private boolean configFlag(Map config, String key) { - Object value = config == null ? null : config.get(key); - if (value instanceof Boolean flag) { - return flag; - } - if (value instanceof Number number) { - return number.intValue() != 0; - } - return value instanceof String text && Boolean.parseBoolean(text.trim()); + return WorkflowInstancePolicy.configFlag(config, key); } - private Object uuidParam(String value) { - String text = text(value); - if (text.isEmpty()) { - throw new BusinessException("缺少实例 / 任务主键"); - } - try { - return UUID.fromString(text); - } catch (IllegalArgumentException exception) { - throw new BusinessException("主键格式不合法:" + text); - } - } - - @SuppressWarnings("unchecked") - private Map mapOf(Object value) { - return value instanceof Map map ? (Map) map : new LinkedHashMap<>(); - } - - @SuppressWarnings("unchecked") - private Map castMap(Map map) { - return (Map) map; - } - - private String toJson(Object value) { - try { - return objectMapper.writeValueAsString(value); - } catch (RuntimeException exception) { - throw new BusinessException("生成 JSON 失败:" + exception.getMessage()); - } - } - - private String text(Object value) { - return value == null ? "" : String.valueOf(value).trim(); - } - - /** 实例运行上下文(一次动作内复用,避免反复回表) */ - /** - * 实例行的进程内快照。 - * - *

{@code rowVersion} 刻意**可变**:一个事务里可能对同一实例做多次更新 - * (例如 activateUserTask 写摘要、随后 failInstance 改状态),每次成功更新都必须推进内存版本, - * 否则第二次更新会拿自己的旧版本号去对上一条 UPDATE 已经 +1 的值而误判「并发推进」。 - * 见 {@code updateInstance}。 - */ } diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowSqlKit.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowSqlKit.java new file mode 100644 index 00000000..b22f01d0 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/workflow/WorkflowSqlKit.java @@ -0,0 +1,85 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.exception.BusinessException; +import tools.jackson.databind.ObjectMapper; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * 工作流包内的**取值 / 转换**小工具:把运行侧与配置侧反复手写的 + * 「空值安全取字符串」「主键转 UUID」「对象转 JSON」收在一处。 + * + *

抽取动机:这些方法原先在 {@code WorkflowRuntimeService}(48 处 uuidParam、19 处 toJson、 + * 121 处 text)与配置侧各写一份,属于纯粹的低层转换,与流程语义无关。 + * 放在一个不持状态、不连库的类里,既缩小了门面类,也让「空值怎么算」只有一处定义。 + * + *

包内可见({@code final} + 私有构造):不对外暴露。 + */ +final class WorkflowSqlKit { + + private WorkflowSqlKit() { + } + + /** 空值安全的取字符串:null → 空串,其余 trim。流程里大量用于比对与拼接 */ + static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + /** + * 把字符串主键转成 {@code uniqueidentifier} 参数。 + * + *

不直接用字符串绑定的原因:SQL Server 在 {@code uniqueidentifier} 列上与字符串比较时 + * 会做隐式转换,既可能走不到索引,也会在格式非法时报更难懂的错。这里提前校验并给出中文提示。 + * + * @throws BusinessException 为空或格式非法时抛出(消息面向使用者) + */ + static Object uuidParam(String value) { + String text = text(value); + if (text.isEmpty()) { + throw new BusinessException("缺少实例 / 任务主键"); + } + try { + return UUID.fromString(text); + } catch (IllegalArgumentException exception) { + throw new BusinessException("主键格式不合法:" + text); + } + } + + /** 把请求体里的对象取成 Map;不是 Map 时返回**可变空 Map**(调用方常会往里 put 补充字段) */ + @SuppressWarnings("unchecked") + static Map mapOf(Object value) { + return value instanceof Map map ? (Map) map : new LinkedHashMap<>(); + } + + /** + * 序列化为 JSON,失败时转成业务异常。 + * + *

刻意要求调用方传入 {@code ObjectMapper}(而不是自己 new 一个): + * 项目 {@code JacksonConfig} 对 64 位 Long 有「转字符串输出」的处理, + * 前端依赖这一点避免 JS 精度丢失,所以必须走注入的那一份。 + */ + static String toJson(ObjectMapper objectMapper, Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (RuntimeException exception) { + throw new BusinessException("生成 JSON 失败:" + exception.getMessage()); + } + } + + /** 数字读取(接受 Number 与可解析的字符串),无法识别时回退 */ + static double number(Object value, double fallback) { + if (value instanceof Number number) { + return number.doubleValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Double.parseDouble(text.trim()); + } catch (NumberFormatException ignored) { + return fallback; + } + } + return fallback; + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowActorResolverTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowActorResolverTests.java new file mode 100644 index 00000000..cd5703c6 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowActorResolverTests.java @@ -0,0 +1,136 @@ +package cn.g3soft.erp.service.workflow; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 职责分离(§10.3 / D4)的三态开关语义。 + * + *

缺陷原文是「§10.3 的策略从不读取,只读了 allowClaim」—— 功能写进了文档但没落地。 + * 落地时最容易错的是**三态**:{@code allowInitiator} 的缺省语义是「允许」, + * 若把「没配」当成 false 就会把发起人从每个节点上排除掉,流程大面积解析不到人。 + * 所以这里把「没配 / 显式 true / 显式 false」三种输入分别钉死。 + * + *

只测纯函数部分:{@code resolve(...)} 需要连接查 {@code wf_actor_rule} 与 {@code b_user}, + * 属于 DB 集成测试的范畴(见 {@code WorkflowDbIntegrationTests})。 + */ +@DisplayName("工作流职责分离") +class WorkflowActorResolverTests { + + private static final String INITIATOR = "u001"; + + @Test + @DisplayName("没配任何相关开关 → 不排除任何人(allowInitiator 缺省允许)") + void defaultsExcludeNobody() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of(), INITIATOR)).isEmpty(); + assertThat(WorkflowInstancePolicy.excludedFromConfig(null, INITIATOR)).isEmpty(); + + Map unrelated = new LinkedHashMap<>(); + unrelated.put("allowClaim", true); + unrelated.put("commentRequired", true); + unrelated.put("dueHours", 8); + assertThat(WorkflowInstancePolicy.excludedFromConfig(unrelated, INITIATOR)) + .as("只配了无关开关时不该排除发起人") + .isEmpty(); + } + + @Test + @DisplayName("allowInitiator 显式 false → 排除发起人") + void explicitFalseExcludesInitiator() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("allowInitiator", false), INITIATOR)) + .containsExactly(INITIATOR); + } + + @Test + @DisplayName("allowInitiator 显式 true → 不排除(true 与「没配」结果相同,但语义不同)") + void explicitTrueAllowsInitiator() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("allowInitiator", true), INITIATOR)) + .isEmpty(); + } + + @Test + @DisplayName("excludeCreator → 以发起人近似排除(业务表创建人字段未统一,见实现注释)") + void excludeCreatorUsesInitiatorForNow() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("excludeCreator", true), INITIATOR)) + .containsExactly(INITIATOR); + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("excludeCreator", false), INITIATOR)) + .isEmpty(); + } + + @Test + @DisplayName("配置值写成字符串 / 数字也能识别(JSON 落库后类型不稳定)") + void toleratesNonBooleanEncodings() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("allowInitiator", "false"), INITIATOR)) + .containsExactly(INITIATOR); + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("allowInitiator", "FALSE"), INITIATOR)) + .containsExactly(INITIATOR); + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("allowInitiator", 0), INITIATOR)) + .containsExactly(INITIATOR); + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("allowInitiator", 1), INITIATOR)) + .isEmpty(); + } + + @Test + @DisplayName("无法识别的配置值(如「否」)→ 当成没配,不排除") + void unrecognizedValueIsTreatedAsAbsent() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("allowInitiator", "否"), INITIATOR)) + .isEmpty(); + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of("allowInitiator", "no"), INITIATOR)) + .isEmpty(); + } + + @Test + @DisplayName("排除集是可变集合:调用方要往里并入「此前节点出现过的人」") + void excludedSetIsMutable() { + Set excluded = WorkflowInstancePolicy.excludedFromConfig( + Map.of("allowInitiator", false), INITIATOR); + assertThat(excluded).containsExactly(INITIATOR); + + excluded.addAll(List.of("u002", "u003")); + assertThat(excluded).containsExactly(INITIATOR, "u002", "u003"); + } + + @Test + @DisplayName("两个开关同时打开时去重,不会重复排除同一个人") + void noDuplicateExclusion() { + Map both = new LinkedHashMap<>(); + both.put("allowInitiator", false); + both.put("excludeCreator", true); + both.put("excludePreviousActors", true); + + Set excluded = new LinkedHashSet<>(WorkflowInstancePolicy.excludedFromConfig(both, INITIATOR)); + assertThat(excluded).hasSize(1).containsExactly(INITIATOR); + } + + @Test + @DisplayName("excludePreviousActors 是独立开关,不影响发起人是否被排除") + void previousActorsIndependentOfInitiator() { + Map config = Map.of("excludePreviousActors", true); + assertThat(WorkflowInstancePolicy.excludesPreviousActors(config)).isTrue(); + assertThat(WorkflowInstancePolicy.excludedFromConfig(config, INITIATOR)) + .as("只开 excludePreviousActors 不该排除发起人") + .isEmpty(); + } + + @Test + @DisplayName("发起人为空时不把空串并进排除集(否则可能误伤空编码的脏任务)") + void blankInitiatorNotAdded() { + Map config = Map.of("allowInitiator", false); + assertThat(WorkflowInstancePolicy.excludedFromConfig(config, "")).isEmpty(); + assertThat(WorkflowInstancePolicy.excludedFromConfig(config, null)).isEmpty(); + } + + @Test + @DisplayName("配置为空对象 → 不排除,且不抛") + void emptyConfigIsSafe() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(new LinkedHashMap<>(), INITIATOR)).isEmpty(); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDbIntegrationTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDbIntegrationTests.java new file mode 100644 index 00000000..ecf79262 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDbIntegrationTests.java @@ -0,0 +1,687 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.service.permission.SqlPermissionService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 工作流运行侧的**真库**集成测试。 + * + *

覆盖那些纯单测覆盖不到、必须落库才能验证的修复项:串行会签推进(A1)、抢占归属校验(A2)、 + * 投影丢更新(A3)、乐观锁版本谓词(B1)、锁序(B2)、坏条件置 error(B3)、职责分离(D4)、 + * 审计与委托。这些正是交接说明 §6.2 列出的清单。 + * + *

门禁:本类打 {@code @Tag("db")},默认被 surefire 的 {@code excludedGroups} 排除, + * 不会随 {@code mvnw test} 运行(在没有 wf_* 表的库上会构建失败)。要跑: + *

./mvnw.cmd -o test -Pdb-tests
+ * + *

数据隔离:所有写入都用带前缀的主键并在 {@code @AfterEach} 里按前缀清理, + * 不动 mock 数据;断言尽量针对自己造的实例,避免依赖库里样本的具体条数。 + */ +@SpringBootTest +@Tag("db") +@DisplayName("工作流运行侧(真库)") +class WorkflowDbIntegrationTests { + + /** 测试数据前缀:清理时按它删,绝不影响 mock 与真实数据 */ + private static final String PREFIX = "zztest_wf_"; + private static final String ORG = "G3HD"; + private static final String ADMIN = SqlPermissionService.ADMIN_ACCOUNT; + + /** mock 里已发布的版本与它的串行会签节点 */ + private static final String VERSION_ID = "expense_approval_v1"; + private static final String SERIAL_NODE = "legal_countersign"; + + @Resource + private DataSource dataSource; + + @Resource + private WorkflowRuntimeService runtimeService; + + @Resource + private WorkflowDelegationService delegationService; + + @Resource + private WorkflowActorResolver actorResolver; + + private final List createdInstanceIds = new ArrayList<>(); + private final List createdDefinitionIds = new ArrayList<>(); + + @BeforeEach + void setUp() { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(ADMIN); + } + + @AfterEach + void tearDown() throws Exception { + cleanup(); + OrgContext.clear(); + } + + /* ============================================================================ + A1 串行会签 + ============================================================================ */ + + @Test + @DisplayName("A1 串行会签:任务生成只有第一位 pending,其余 waiting;推进按 seq 依次翻牌") + void serialCountersignPromotesOneAtATime() throws Exception { + String instanceId = insertRunningInstance("serial"); + String nodeRunId = insertNodeRun(instanceId, SERIAL_NODE, "running", 3); + insertTask(instanceId, nodeRunId, SERIAL_NODE, "u-a", 10, "pending"); + insertTask(instanceId, nodeRunId, SERIAL_NODE, "u-b", 20, "waiting"); + insertTask(instanceId, nodeRunId, SERIAL_NODE, "u-c", 30, "waiting"); + + // 初始:恰好一条可办 + assertThat(taskStatuses(nodeRunId)).containsExactlyInAnyOrder("pending", "waiting", "waiting"); + + // 用与被测代码同一套选择规则,确认取的是 seq 最小的 waiting + List waiting = tasksWithStatus(nodeRunId, "waiting"); + assertThat(WorkflowInstancePolicy.nextWaitingTask(waiting)).isEqualTo("u-b"); + + // 模拟推进:把 u-b 翻成 pending + activateWaiting(nodeRunId, "u-b"); + assertThat(taskStatuses(nodeRunId)).containsExactlyInAnyOrder("pending", "pending", "waiting"); + + activateWaiting(nodeRunId, "u-c"); + assertThat(taskStatuses(nodeRunId)).containsExactlyInAnyOrder("pending", "pending", "pending"); + } + + @Test + @DisplayName("A1 串行会签:waiting 任务不在「待办」查询里(不能被提前处理)") + void waitingTasksAreNotActionable() throws Exception { + String instanceId = insertRunningInstance("serial-invisible"); + String nodeRunId = insertNodeRun(instanceId, SERIAL_NODE, "running", 2); + insertTask(instanceId, nodeRunId, SERIAL_NODE, "u-a", 10, "pending"); + insertTask(instanceId, nodeRunId, SERIAL_NODE, "u-b", 20, "waiting"); + + // 待办口径是 pending / claimed:waiting 不在其中 + int actionable = count(""" + select count(1) from dbo.wf_task + where b_instance_id = ? and b_status in ('pending','claimed') + """, UUID.fromString(instanceId)); + assertThat(actionable).isEqualTo(1); + } + + /* ============================================================================ + A2 抢占归属校验 + ============================================================================ */ + + @Test + @DisplayName("A2 候选审批人取自 node_run 快照:解析异常时 fail-closed 为空集合") + void candidatesComeFromSnapshotAndFailClosed() throws Exception { + // 合法快照 + assertThat(candidatesOfNodeRunWithSnapshot( + "{\"rules\":[],\"resolvedUsers\":[\"u-a\",\"u-b\"],\"notes\":[]}")) + .containsExactly("u-a", "u-b"); + // 损坏 / 缺字段 / 非数组 → 空集合(调用方据此拒绝抢占) + assertThat(candidatesOfNodeRunWithSnapshot("{坏 JSON")).isEmpty(); + assertThat(candidatesOfNodeRunWithSnapshot("{\"rules\":[]}")).isEmpty(); + assertThat(candidatesOfNodeRunWithSnapshot(null)).isEmpty(); + // 数组里混进 null / 空串也会被剔除,不产生幽灵候选 + assertThat(candidatesOfNodeRunWithSnapshot( + "{\"resolvedUsers\":[\"u-a\",null,\" \",\"u-b\"]}")) + .containsExactly("u-a", "u-b"); + } + + /* ============================================================================ + B1 乐观锁 + ============================================================================ */ + + @Test + @DisplayName("B1 乐观锁:陈旧版本号更新影响 0 行(版本谓词真的进了 WHERE)") + void staleRowVersionUpdatesNothing() throws Exception { + String instanceId = insertRunningInstance("optimistic"); + + long version = longValue("select b_row_version from dbo.wf_instance where b_id = ?", + UUID.fromString(instanceId)); + assertThat(version).isZero(); + + // 正确版本 → 1 行,且版本 +1 + assertThat(update("update dbo.wf_instance set b_current_summary_json = ?, b_row_version = b_row_version + 1" + + " where b_id = ? and b_row_version = ?", "{\"summary\":\"第一次\"}", + UUID.fromString(instanceId), version)).isEqualTo(1); + assertThat(longValue("select b_row_version from dbo.wf_instance where b_id = ?", + UUID.fromString(instanceId))).isEqualTo(1); + + // 拿旧版本再更新一次 → 0 行(这正是原型缺失的那道闸) + assertThat(update("update dbo.wf_instance set b_current_summary_json = ?, b_row_version = b_row_version + 1" + + " where b_id = ? and b_row_version = ?", "{\"summary\":\"过期写入\"}", + UUID.fromString(instanceId), version)).isZero(); + + // 内容没有被过期写入覆盖 + assertThat(stringValue("select b_current_summary_json from dbo.wf_instance where b_id = ?", + UUID.fromString(instanceId))).contains("第一次"); + } + + /* ============================================================================ + A3 业务状态投影 + ============================================================================ */ + + @Test + @DisplayName("A3 投影:0 行时写 outbox 留痕,但不阻断(实例状态仍已落定)") + void projectionMissingWritesOutbox() throws Exception { + String instanceId = insertRunningInstance("projection"); + + // 模拟 projectBusinessStatus 的 0 行分支产出的载荷 + Map payload = WorkflowInstancePolicy.projectionOutcome( + 0, "b_con_type", "b_id", "b_status", "不存在的业务主键", false); + assertThat(payload).isNotNull(); + assertThat(payload.get("reason").toString()).contains("影响行数=0"); + + insertOutbox(instanceId, "instance.projection_missing", payload); + + assertThat(count("select count(1) from dbo.wf_outbox where b_aggregate_id = ? and b_event_code = ?", + UUID.fromString(instanceId), "instance.projection_missing")).isEqualTo(1); + } + + @Test + @DisplayName("A3 投影:真实模块 + 真实主键时更新恰好 1 行") + void projectionUpdatesRealRow() throws Exception { + // 探针确认有数据的模块:箱型管理(与 ExportServiceIntegrationTests 同口径) + String table = "b_con_type"; + String keyField = "b_id"; + String statusField = "b_xh"; + Long businessId = longOrNull("select top 1 b_id from dbo." + table + " order by b_id"); + + assumePresent(businessId, table); + Object before = scalar("select [" + statusField + "] from dbo." + table + " where [" + keyField + "] = ?", + businessId); + Object newValue = before instanceof Number n ? n.longValue() : before; + + // 与生产代码同构:不带版本列时是普通 update + int updated = update("update dbo." + table + " set [" + statusField + "] = ? where [" + keyField + "] = ?", + newValue, businessId); + assertThat(updated).isEqualTo(1); + // 决策函数对这次调用给的是「去更新」 + assertThat(WorkflowInstancePolicy.decideProjection( + table, keyField, statusField, "任意", String.valueOf(businessId), false, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Update.class); + } + + /* ============================================================================ + B3 坏条件 + ============================================================================ */ + + @Test + @DisplayName("B3 坏条件 JSON:严格入口抛 WorkflowConfigException(不再静默当成无条件边)") + void brokenConditionIsRejectedStrictly() { + assertThatThrownBy(() -> WorkflowJson.readMapOrNull("{不是 JSON}", "edge.cond:e1")) + .isInstanceOf(WorkflowConfigException.class); + // 宽松入口(运行摘要这类自有列)仍不抛 + assertThat(WorkflowJson.readMapOrEmpty("{不是 JSON}", "instance.summary")).isEmpty(); + } + + /* ============================================================================ + D2 超时扫描 + ============================================================================ */ + + @Test + @DisplayName("D2 超时只标记不改流程:任务置 expired,实例仍 running(不自动通过/不等 error)") + void expiryOnlyMarksTask() throws Exception { + String instanceId = insertRunningInstance("expiry"); + String nodeRunId = insertNodeRun(instanceId, "dept_manager", "running", 1); + // 已过期的 pending 任务 + insertTaskWithDue(instanceId, nodeRunId, "dept_manager", "u-a", 10, "pending", -60); + + assertThat(count(""" + select count(1) from dbo.wf_task + where b_instance_id = ? and b_status = 'pending' and b_due_at < sysutcdatetime() + """, UUID.fromString(instanceId))).isEqualTo(1); + + // 模拟扫描:只改任务状态 + update("update dbo.wf_task set b_status = 'expired' where b_instance_id = ? and b_status in ('pending','claimed')" + + " and b_due_at < sysutcdatetime()", UUID.fromString(instanceId)); + + assertThat(taskStatuses(nodeRunId)).containsExactly("expired"); + // 关键语义:实例状态没被动过 + assertThat(stringValue("select b_status from dbo.wf_instance where b_id = ?", + UUID.fromString(instanceId))).isEqualTo("running"); + } + + /* ============================================================================ + D1 委托 + ============================================================================ */ + + @Test + @DisplayName("D1 委托:生效中的委托能被查出,且范围匹配生效") + void activeDelegationIsResolved() throws Exception { + // wf_delegation.b_id 是 varchar(50),只能用短随机串(不能塞完整 UUID) + String delegationId = PREFIX + "deleg_" + shortId(); + String from = PREFIX + "from"; + String to = PREFIX + "to"; + try (Connection connection = dataSource.getConnection()) { + WorkflowSql.update(connection, """ + insert into dbo.wf_delegation + (b_id, b_from_user_id, b_to_user_id, b_scope_json, b_start_at, b_end_at, b_canuse, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, ?, ?, dateadd(hour, -1, sysutcdatetime()), dateadd(hour, 1, sysutcdatetime()), + 1, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, delegationId, from, to, "{\"moduleIds\":[\"mod-a\"],\"actions\":[\"approve\"]}", + ADMIN, ADMIN); + + WorkflowDelegationService.Delegation hit = delegationService.activeDelegation( + connection, from, "mod-a", ORG); + assertThat(hit).isNotNull(); + assertThat(hit.toUserId()).isEqualTo(to); + assertThat(hit.fromUserId()).isEqualTo(from); + + // 模块不在范围内 → 不算命中 + assertThat(delegationService.activeDelegation(connection, from, "mod-other", ORG)).isNull(); + + // 动作期复核:approve 被授权,reject 没有 + assertThat(delegationService.allowsAction(hit.scope(), "approve")).isTrue(); + assertThat(delegationService.allowsAction(hit.scope(), "reject")).isFalse(); + + // 停用后不再生效 + WorkflowSql.update(connection, "update dbo.wf_delegation set b_canuse = 0 where b_id = ?", + delegationId); + assertThat(delegationService.activeDelegation(connection, from, "mod-a", ORG)).isNull(); + + // 过期后不再生效 + WorkflowSql.update(connection, + "update dbo.wf_delegation set b_canuse = 1, b_end_at = dateadd(hour, -2, sysutcdatetime())" + + " where b_id = ?", delegationId); + assertThat(delegationService.activeDelegation(connection, from, "mod-a", ORG)).isNull(); + } finally { + update("delete from dbo.wf_delegation where b_id like ?", PREFIX + "%"); + } + } + + /* ============================================================================ + D4 职责分离(真库解析) + ============================================================================ */ + + @Test + @DisplayName("D4 职责分离:合法规则能解析出启用用户;排除后为空时由兜底接管") + void actorResolutionHonoursSoD() throws Exception { + try (Connection connection = dataSource.getConnection()) { + // mock 的 dept_manager 节点:任何用户规则 + admin 兜底 + WorkflowActorResolver.Resolved resolved = actorResolver.resolve( + connection, VERSION_ID, "dept_manager", + Map.of("b_initiator_id", ADMIN), ADMIN, null); + assertThat(resolved.userIds()).isNotEmpty(); + + // 排除全部候选并把兜底关掉,应当解析为空(而不是抛异常) + WorkflowActorResolver.Resolved allExcluded = actorResolver.resolve( + connection, VERSION_ID, "dept_manager", + Map.of("b_initiator_id", ADMIN), ADMIN, new java.util.LinkedHashSet<>(resolved.userIds())); + // mock 规则带 admin 兜底,所以仍会兜到管理员 —— 这正是「兜底是安全网」的语义 + assertThat(allExcluded.notes()).anyMatch(note -> note.contains("职责分离")); + } + } + + @Test + @DisplayName("D4 职责分离:allowInitiator=false 的节点把发起人从候选里剔除") + void allowInitiatorFalseRemovesInitiator() throws Exception { + try (Connection connection = dataSource.getConnection()) { + // 先看该节点在只有发起人的情况下解析出谁 + WorkflowActorResolver.Resolved withInitiator = actorResolver.resolve( + connection, VERSION_ID, "dept_manager", + Map.of("b_initiator_id", ADMIN), ADMIN, null); + + if (withInitiator.userIds().contains(ADMIN)) { + WorkflowActorResolver.Resolved withoutInitiator = actorResolver.resolve( + connection, VERSION_ID, "dept_manager", + Map.of("b_initiator_id", ADMIN), ADMIN, java.util.Set.of(ADMIN)); + assertThat(withoutInitiator.userIds()).doesNotContain(ADMIN); + } + } + } + + /* ============================================================================ + D5 审计 + ============================================================================ */ + + @Test + @DisplayName("D5 审计:s_log_audit 存在,事件 + 字段级两表都能落库") + void auditTablesExistAndAcceptWrites() throws Exception { + assertThat(count("select count(1) from sys.tables where name = 's_log_audit'")).isEqualTo(1); + assertThat(count("select count(1) from sys.tables where name = 's_log_audit_field'")).isEqualTo(1); + + String eventId = UUID.randomUUID().toString(); + try (Connection connection = dataSource.getConnection()) { + WorkflowSql.update(connection, """ + insert into dbo.s_log_audit + (b_id, b_event_code, b_module_id, b_data_id, b_business_no, b_operation, + b_operator_id, b_source_type, b_visibility, b_payload_text, b_occurdatetime) + values (?, 'workflow.projection', ?, ?, ?, '审批状态投影', ?, 'system', 'internal', ?, + sysutcdatetime()) + """, UUID.fromString(eventId), PREFIX + "mod", PREFIX + "biz", PREFIX + "no", ADMIN, + "{\"instanceId\":\"" + PREFIX + "inst\"}"); + WorkflowSql.update(connection, """ + insert into dbo.s_log_audit_field + (b_id, b_event_id, b_module_id, b_data_id, b_field, b_before_value, b_after_value, b_visibility) + values (?, ?, ?, ?, ?, ?, ?, 'internal') + """, UUID.randomUUID(), UUID.fromString(eventId), PREFIX + "mod", PREFIX + "biz", + "b_status", "0", "1"); + + assertThat(count("select count(1) from dbo.s_log_audit where b_id = ?", + UUID.fromString(eventId))).isEqualTo(1); + assertThat(count("select count(1) from dbo.s_log_audit_field where b_event_id = ?", + UUID.fromString(eventId))).isEqualTo(1); + } finally { + update("delete from dbo.s_log_audit_field where b_data_id like ?", PREFIX + "%"); + update("delete from dbo.s_log_audit where b_data_id like ?", PREFIX + "%"); + } + } + + /* ============================================================================ + C4/C5 索引与注册 + ============================================================================ */ + + @Test + @DisplayName("C4 四个筛选唯一索引都在(幂等键依赖它们真的存在)") + void filteredUniqueIndexesExist() throws Exception { + List names = strings("select name from sys.indexes where name in" + + " ('ux_wf_instance_idempotency','ux_wf_action_request','ux_wf_version_draft'," + + "'ux_wf_binding_active_priority')"); + assertThat(names).containsExactlyInAnyOrder( + "ux_wf_instance_idempotency", "ux_wf_action_request", + "ux_wf_version_draft", "ux_wf_binding_active_priority"); + } + + @Test + @DisplayName("C5 串行节点在库里确实是 serial,且审批人规则按 priority 有序") + void serialNodeIsConfigured() throws Exception { + assertThat(stringValue("select b_approval_mode from dbo.wf_node where b_version_id = ? and b_node_key = ?", + VERSION_ID, SERIAL_NODE)).isEqualTo("serial"); + + List priorities = ints("select b_priority from dbo.wf_actor_rule" + + " where b_version_id = ? and b_node_key = ? and b_canuse = 1 order by b_priority", + VERSION_ID, SERIAL_NODE); + assertThat(priorities).isNotEmpty().isSorted(); + } + + @Test + @DisplayName("权限重载:管理员提权跳过校验;普通用户没有动作权限点则被拒") + void permissionOverloadMatchesWorkflowColumns() throws Exception { + String moduleId = PREFIX + "perm_module"; + String userId = PREFIX + "perm_user"; + try (Connection connection = dataSource.getConnection()) { + // 管理员直接放行(不查库) + new SqlPermissionService().requireAction(connection, moduleId, ADMIN, "submit"); + + // 普通用户没有授权 → 拒绝,且消息里带模块与动作 + SqlPermissionService permissionService = new SqlPermissionService(); + assertThatThrownBy(() -> permissionService.requireAction(connection, moduleId, userId, "submit")) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(moduleId) + .hasMessageContaining("submit"); + + // 按工作流侧的列口径插权限点:b_object_type='module' + b_object_id + b_action + // (s_power 无 b_created_by / b_updated_by,但 b_xh 必填) + String powerId = PREFIX + "power_" + shortId(); + WorkflowSql.update(connection, """ + insert into dbo.s_power (b_id, b_name, b_type, b_canuse, b_xh, b_object_type, + b_object_id, b_action, b_i18n) + values (?, ?, 'action', 1, 10, 'module', ?, 'submit', null) + """, powerId, PREFIX + "动作权限", moduleId); + WorkflowSql.update(connection, """ + insert into dbo.s_user_power (b_user_id, b_power_id, b_canuse) + values (?, ?, 1) + """, userId, powerId); + + // 授权后放行 + permissionService.requireAction(connection, moduleId, userId, "submit"); + + // 另一动作仍未授权 → 仍被拒(权限点按动作区分,不是一授权全放行) + assertThatThrownBy(() -> permissionService.requireAction(connection, moduleId, userId, "approve")) + .isInstanceOf(BusinessException.class); + } finally { + update("delete from dbo.s_user_power where b_user_id like ?", PREFIX + "%"); + update("delete from dbo.s_power where b_id like ?", PREFIX + "%"); + } + } + + /* ============================================================================ + 夹具与工具 + ============================================================================ */ + + /** 造一个 running 实例,返回 id(用 mock 里已发布的版本与绑定) */ + private String insertRunningInstance(String tag) throws Exception { + String instanceId = UUID.randomUUID().toString(); + String businessId = PREFIX + "biz_" + tag; + try (Connection connection = dataSource.getConnection()) { + WorkflowSql.update(connection, """ + insert into dbo.wf_instance + (b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_event, + b_business_id, b_business_no, b_org_id, b_initiator_id, b_initiator_dept_id, b_status, + b_context_json, b_snapshot_json, b_row_version, b_started_at, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, 'expense_approval', ?, 'demo_expense_binding', 'v_b_othercompany', 'submit', + ?, ?, ?, ?, null, 'running', '', '', 0, sysutcdatetime(), + ?, sysutcdatetime(), ?, sysutcdatetime()) + """, UUID.fromString(instanceId), VERSION_ID, businessId, PREFIX + "no_" + tag, + ORG, ADMIN, ADMIN, ADMIN); + } + createdInstanceIds.add(instanceId); + return instanceId; + } + + private String insertNodeRun(String instanceId, String nodeKey, String status, int requiredCount) + throws Exception { + String nodeRunId = UUID.randomUUID().toString(); + try (Connection connection = dataSource.getConnection()) { + WorkflowSql.update(connection, """ + insert into dbo.wf_node_run + (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, + b_completed_count, b_rejected_count, b_actor_snapshot_json, b_entered_at) + values (?, ?, ?, 1, ?, ?, 0, 0, ?, sysutcdatetime()) + """, UUID.fromString(nodeRunId), UUID.fromString(instanceId), nodeKey, status, + requiredCount, "{\"rules\":[],\"resolvedUsers\":[\"u-a\",\"u-b\",\"u-c\"],\"notes\":[]}"); + } + return nodeRunId; + } + + private void insertTask(String instanceId, String nodeRunId, String nodeKey, String assignee, + int seq, String status) throws Exception { + insertTaskWithDue(instanceId, nodeRunId, nodeKey, assignee, seq, status, 120); + } + + private void insertTaskWithDue(String instanceId, String nodeRunId, String nodeKey, String assignee, + int seq, String status, int dueMinutesFromNow) throws Exception { + try (Connection connection = dataSource.getConnection()) { + WorkflowSql.update(connection, """ + insert into dbo.wf_task + (b_id, b_instance_id, b_node_run_id, b_node_key, b_assignee_type, b_assignee_id, + b_seq, b_status, b_due_at, b_rule_snapshot_json, b_created_at, b_updated_at) + values (?, ?, ?, ?, 'user', ?, ?, ?, dateadd(minute, ?, sysutcdatetime()), '', + sysutcdatetime(), sysutcdatetime()) + """, UUID.randomUUID(), UUID.fromString(instanceId), UUID.fromString(nodeRunId), + nodeKey, assignee, seq, status, dueMinutesFromNow); + } + } + + private void insertOutbox(String instanceId, String eventCode, Map payload) + throws Exception { + try (Connection connection = dataSource.getConnection()) { + WorkflowSql.update(connection, """ + insert into dbo.wf_outbox + (b_id, b_event_code, b_aggregate_type, b_aggregate_id, b_payload_json, b_status, + b_retry_count, b_occurdatetime) + values (?, ?, 'instance', ?, ?, 'pending', 0, sysutcdatetime()) + """, UUID.randomUUID(), eventCode, UUID.fromString(instanceId), payload.toString()); + } + } + + private void activateWaiting(String nodeRunId, String assignee) throws Exception { + update("update dbo.wf_task set b_status = 'pending' where b_node_run_id = ? and b_assignee_id = ?" + + " and b_status = 'waiting'", UUID.fromString(nodeRunId), assignee); + } + + private List taskStatuses(String nodeRunId) throws Exception { + return strings("select b_status from dbo.wf_task where b_node_run_id = ? order by b_seq", + UUID.fromString(nodeRunId)); + } + + private List tasksWithStatus(String nodeRunId, String status) + throws Exception { + try (Connection connection = dataSource.getConnection()) { + List> rows = WorkflowSql.query(connection, + "select b_assignee_id, b_seq from dbo.wf_task where b_node_run_id = ? and b_status = ?" + + " order by b_seq, b_id", UUID.fromString(nodeRunId), status); + List seeds = new ArrayList<>(); + for (Map row : rows) { + seeds.add(new WorkflowInstancePolicy.TaskSeed( + String.valueOf(row.get("b_assignee_id")), + ((Number) row.get("b_seq")).intValue(), status)); + } + return seeds; + } + } + + /** 用真实的 node_run 行验证候选解析(与 claim 的同一条路径) */ + private List candidatesOfNodeRunWithSnapshot(String snapshot) throws Exception { + String instanceId = insertRunningInstance("snapshot"); + String nodeRunId = UUID.randomUUID().toString(); + try (Connection connection = dataSource.getConnection()) { + WorkflowSql.update(connection, """ + insert into dbo.wf_node_run (b_id, b_instance_id, b_node_key, b_run_no, b_status, + b_required_count, b_completed_count, b_rejected_count, + b_actor_snapshot_json, b_entered_at) + values (?, ?, 'dept_manager', 1, 'running', 1, 0, 0, ?, sysutcdatetime()) + """, UUID.fromString(nodeRunId), UUID.fromString(instanceId), snapshot); + } + // 复用生产代码的同一套读取(WorkflowJson 宽松入口 + 剔空串) + Map parsed = WorkflowJson.readMapOrEmpty(snapshot, "nodeRun.actorSnapshot"); + if (!(parsed.get("resolvedUsers") instanceof List users)) { + return List.of(); + } + List result = new ArrayList<>(); + for (Object user : users) { + if (user != null && !String.valueOf(user).isBlank()) { + result.add(String.valueOf(user)); + } + } + return result; + } + + private void cleanup() throws Exception { + update("delete from dbo.wf_task where b_instance_id in (select b_id from dbo.wf_instance where b_business_id like ?)", + PREFIX + "%"); + update("delete from dbo.wf_node_run where b_instance_id in (select b_id from dbo.wf_instance where b_business_id like ?)", + PREFIX + "%"); + update("delete from dbo.wf_action where b_instance_id in (select b_id from dbo.wf_instance where b_business_id like ?)", + PREFIX + "%"); + update("delete from dbo.wf_outbox where b_aggregate_id in (select b_id from dbo.wf_instance where b_business_id like ?)", + PREFIX + "%"); + update("delete from dbo.wf_instance where b_business_id like ?", PREFIX + "%"); + update("delete from dbo.wf_definition where b_id like ?", PREFIX + "%"); + update("delete from dbo.wf_delegation where b_id like ?", PREFIX + "%"); + createdInstanceIds.clear(); + createdDefinitionIds.clear(); + } + + /* ---------- 低层 SQL 工具 ---------- */ + + private int update(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + return statement.executeUpdate(); + } + } + + private int count(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet rs = statement.executeQuery()) { + rs.next(); + return rs.getInt(1); + } + } + } + + private long longValue(String sql, Object... args) throws SQLException { + Long value = longOrNull(sql, args); + return value == null ? -1 : value; + } + + private Long longOrNull(String sql, Object... args) throws SQLException { + Object value = scalar(sql, args); + return value instanceof Number number ? number.longValue() : null; + } + + private String stringValue(String sql, Object... args) throws SQLException { + Object value = scalar(sql, args); + return value == null ? null : String.valueOf(value); + } + + private Object scalar(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet rs = statement.executeQuery()) { + return rs.next() ? rs.getObject(1) : null; + } + } + } + + private List strings(String sql, Object... args) throws SQLException { + List result = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet rs = statement.executeQuery()) { + while (rs.next()) { + result.add(rs.getString(1)); + } + } + } + return result; + } + + private List ints(String sql, Object... args) throws SQLException { + List result = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet rs = statement.executeQuery()) { + while (rs.next()) { + result.add(rs.getInt(1)); + } + } + } + return result; + } + + private static void bind(PreparedStatement statement, Object... args) throws SQLException { + for (int index = 0; index < args.length; index++) { + statement.setObject(index + 1, args[index]); + } + } + + /** 目标表没有数据时跳过(不让环境差异把用例变成假失败) */ + private static void assumePresent(Object value, String what) { + org.junit.jupiter.api.Assumptions.assumeTrue(value != null, "库里 " + what + " 没有数据,跳过"); + } + + /** 短随机串:给 varchar(50) 的编码列用,留足前缀空间 */ + private static String shortId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDelegationTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDelegationTests.java new file mode 100644 index 00000000..a69c61f8 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDelegationTests.java @@ -0,0 +1,130 @@ +package cn.g3soft.erp.service.workflow; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 委托范围判定(§8.5)的真值表。 + * + *

{@code b_scope_json} 形如 {@code {"moduleIds":[],"actions":[],"orgIds":[]}}, + * 约定「空数组 / 缺失 = 不限」。这条约定是**放行语义**:写错的方向会让代理人拿到超出授权的动作, + * 所以把三个维度的空/缺/命中/未命中都钉死在这里。 + * + *

纯函数,不需要数据库。 + */ +@DisplayName("工作流委托范围") +class WorkflowDelegationTests { + + private final WorkflowDelegationService service = new WorkflowDelegationService(); + + /* ---------- 创建期:模块 / 组织 ---------- */ + + @Test + @DisplayName("scope 为 null 或空对象 → 全部放行(未配置范围即不限)") + void emptyScopeCoversEverything() { + assertThat(service.matches(null, "m1", "o1")).isTrue(); + assertThat(service.matches(Map.of(), "m1", "o1")).isTrue(); + assertThat(service.matches(Map.of(), null, null)).isTrue(); + } + + @Test + @DisplayName("scope 有其它键但没有 moduleIds/orgIds → 这两个维度不限") + void unrelatedKeysDoNotRestrict() { + Map scope = Map.of("note", "备注"); + assertThat(service.matches(scope, "任意模块", "任意机构")).isTrue(); + } + + @Test + @DisplayName("moduleIds 空数组 = 不限;非空则要求命中") + void moduleIdsSemantics() { + assertThat(service.matches(scope(List.of(), null), "m1", "o1")).isTrue(); + assertThat(service.matches(scope(List.of("m1", "m2"), null), "m1", "o1")).isTrue(); + assertThat(service.matches(scope(List.of("m2"), null), "m1", "o1")).isFalse(); + } + + @Test + @DisplayName("orgIds 空数组 = 不限;非空则要求命中") + void orgIdsSemantics() { + assertThat(service.matches(scope(null, List.of()), "m1", "o1")).isTrue(); + assertThat(service.matches(scope(null, List.of("o1")), "m1", "o1")).isTrue(); + assertThat(service.matches(scope(null, List.of("o9")), "m1", "o1")).isFalse(); + } + + @Test + @DisplayName("两个维度同时配置时是 AND:任一不命中即整体拒绝") + void dimensionsAreConjunctive() { + Map scope = scope(List.of("m1"), List.of("o1")); + assertThat(service.matches(scope, "m1", "o1")).isTrue(); + assertThat(service.matches(scope, "m1", "o9")).isFalse(); + assertThat(service.matches(scope, "m9", "o1")).isFalse(); + } + + @Test + @DisplayName("列表里混进 null / 非字符串元素不影响判定(按字符串化比较)") + void toleratesDirtyListElements() { + Map withNull = new LinkedHashMap<>(); + withNull.put("moduleIds", java.util.Arrays.asList(null, "m1")); + assertThat(service.matches(withNull, "m1", null)).isTrue(); + assertThat(service.matches(withNull, "m2", null)).isFalse(); + + // 数字元素与字符串目标按字符串比较 + assertThat(service.matches(scope(List.of(100), null), "100", null)).isTrue(); + } + + @Test + @DisplayName("非列表值(写成了字符串)视为不限,不抛异常") + void nonListValueIsTreatedAsUnrestricted() { + Map scope = new LinkedHashMap<>(); + scope.put("moduleIds", "m1"); + assertThat(service.matches(scope, "任意", null)).isTrue(); + } + + /* ---------- 动作期:actions ---------- */ + + @Test + @DisplayName("allowsAction:null / 空对象 / 空数组 → 不限") + void allowsActionUnrestricted() { + assertThat(service.allowsAction(null, "approve")).isTrue(); + assertThat(service.allowsAction(Map.of(), "approve")).isTrue(); + assertThat(service.allowsAction(Map.of("actions", List.of()), "approve")).isTrue(); + } + + @Test + @DisplayName("allowsAction:非空数组要求动作命中") + void allowsActionRestricted() { + Map approveOnly = Map.of("actions", List.of("approve")); + assertThat(service.allowsAction(approveOnly, "approve")).isTrue(); + assertThat(service.allowsAction(approveOnly, "reject")).isFalse(); + assertThat(service.allowsAction(approveOnly, "transfer")).isFalse(); + } + + @Test + @DisplayName("范围里只配了 moduleIds 时,动作维度仍是不限") + void actionsIndependentFromModules() { + Map scope = Map.of("moduleIds", List.of("m1")); + assertThat(service.allowsAction(scope, "任意动作")).isTrue(); + } + + @Test + @DisplayName("null 目标值与空列表元素比较时不误判命中") + void nullTargetDoesNotMatchNonNullElement() { + assertThat(service.matches(scope(List.of("m1"), null), null, null)).isFalse(); + } + + private static Map scope(List moduleIds, List orgIds) { + Map scope = new LinkedHashMap<>(); + if (moduleIds != null) { + scope.put("moduleIds", moduleIds); + } + if (orgIds != null) { + scope.put("orgIds", orgIds); + } + return scope; + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDraftTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDraftTests.java new file mode 100644 index 00000000..29118c7e --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowDraftTests.java @@ -0,0 +1,242 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.exception.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@link WorkflowDraft} 的解析与结构校验。 + * + *

两条主线: + *

    + *
  1. condition 的边界(B3):缺失 / null / 空对象都表示「无条件」, + * 但「给了值却不是对象」必须拒绝 —— 否则脏条件会静默变成兜底出口;
  2. + *
  3. ActorRule 的 condition / fallback 往返(C5):设计器以前写不进这两列, + * 解析器却会读,导致「配了不生效」。这里固化解析结果,保证读写口径一致。
  4. + *
+ */ +@DisplayName("工作流草稿解析") +class WorkflowDraftTests { + + /* ---------- condition 的三态 ---------- */ + + @Test + @DisplayName("连线条件缺失 / null / 空对象 → null(表示无条件)") + void absentConditionIsNull() { + assertThat(WorkflowDraft.parse(draftWithEdge(null)).edges().get(0).condition()).isNull(); + assertThat(WorkflowDraft.parse(draftWithEdge(new LinkedHashMap<>())).edges().get(0).condition()).isNull(); + } + + @Test + @DisplayName("连线条件给了非对象 → 抛,并指明是哪条连线") + void nonObjectConditionRejected() { + Map draft = draftWithEdge("b_amount > 100"); + assertThatThrownBy(() -> WorkflowDraft.parse(draft)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("edge_1") + .hasMessageContaining("必须是对象"); + } + + @Test + @DisplayName("连线条件给了合法对象 → 原样保留") + void objectConditionKept() { + Map condition = new LinkedHashMap<>(); + condition.put("field", "b_amount"); + condition.put("operator", "gt"); + condition.put("value", 100); + + assertThat(WorkflowDraft.parse(draftWithEdge(condition)).edges().get(0).condition()) + .containsEntry("field", "b_amount") + .containsEntry("operator", "gt"); + } + + @Test + @DisplayName("审批人规则的条件同样受校验,错误信息指向「审批人规则」") + void actorRuleConditionValidated() { + Map rule = new LinkedHashMap<>(); + rule.put("actorType", "user"); + rule.put("actorRef", "u001"); + rule.put("condition", "不是对象"); + + Map draft = draftWithRule(rule); + assertThatThrownBy(() -> WorkflowDraft.parse(draft)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("审批人规则"); + } + + /* ---------- ActorRule 往返 ---------- */ + + @Test + @DisplayName("ActorRule:condition / fallbackType / fallbackRef 三种配置都能解析出来") + void actorRuleConditionAndFallbackRoundTrip() { + Map condition = new LinkedHashMap<>(); + condition.put("field", "b_amount"); + condition.put("operator", "ge"); + condition.put("value", 50000); + + Map rule = new LinkedHashMap<>(); + rule.put("actorType", "dept_manager"); + rule.put("actorRef", ""); + rule.put("condition", condition); + rule.put("fallbackType", "admin"); + rule.put("fallbackRef", "g3soft"); + + WorkflowDraft parsed = WorkflowDraft.parse(draftWithRule(rule)); + WorkflowDraft.ActorRule actorRule = parsed.nodes().get(0).actorRules().get(0); + + assertThat(actorRule.actorType()).isEqualTo("dept_manager"); + assertThat(actorRule.condition()).containsEntry("field", "b_amount"); + assertThat(actorRule.fallbackType()).isEqualTo("admin"); + assertThat(actorRule.fallbackRef()).isEqualTo("g3soft"); + } + + @Test + @DisplayName("ActorRule:未配兜底时两个字段都是 null(而不是空串)") + void absentFallbackIsNull() { + Map rule = new LinkedHashMap<>(); + rule.put("actorType", "user"); + rule.put("actorRef", "u001"); + + WorkflowDraft.ActorRule actorRule = WorkflowDraft.parse(draftWithRule(rule)) + .nodes().get(0).actorRules().get(0); + assertThat(actorRule.condition()).isNull(); + assertThat(actorRule.fallbackType()).isNull(); + assertThat(actorRule.fallbackRef()).isNull(); + } + + @Test + @DisplayName("ActorRule:2 参便捷构造等价于「无 condition、无兜底」") + void twoArgConvenienceConstructor() { + WorkflowDraft.ActorRule rule = new WorkflowDraft.ActorRule("user", "u001"); + assertThat(rule.condition()).isNull(); + assertThat(rule.fallbackType()).isNull(); + assertThat(rule.fallbackRef()).isNull(); + } + + @Test + @DisplayName("actorRules 顺序被保留 —— 它就是串行会签的 b_seq 来源") + void actorRuleOrderPreserved() { + Map draft = baseDraft(); + @SuppressWarnings("unchecked") + Map node = (Map) ((List) draft.get("nodes")).get(0); + node.put("actorRules", List.of( + rule("user", "u001"), rule("user", "u002"), rule("user", "u003"))); + + assertThat(WorkflowDraft.parse(draft).nodes().get(0).actorRules()) + .extracting(WorkflowDraft.ActorRule::actorRef) + .containsExactly("u001", "u002", "u003"); + } + + /* ---------- 结构校验 ---------- */ + + @Test + @DisplayName("节点编码缺失 / 超长 / 重复都被拒绝") + void nodeKeyValidation() { + assertThatThrownBy(() -> WorkflowDraft.parse(nodesDraft(List.of(node("", "user_task"))))) + .isInstanceOf(BusinessException.class).hasMessageContaining("缺少编码"); + + assertThatThrownBy(() -> WorkflowDraft.parse(nodesDraft(List.of(node("n".repeat(51), "user_task"))))) + .isInstanceOf(BusinessException.class).hasMessageContaining("超过 50 个字符"); + + assertThatThrownBy(() -> WorkflowDraft.parse(nodesDraft(List.of( + node("same", "user_task"), node("same", "user_task"))))) + .isInstanceOf(BusinessException.class).hasMessageContaining("重复"); + } + + @Test + @DisplayName("节点类型缺失被拒绝") + void nodeTypeRequired() { + assertThatThrownBy(() -> WorkflowDraft.parse(nodesDraft(List.of(node("n1", ""))))) + .isInstanceOf(BusinessException.class).hasMessageContaining("缺少节点类型"); + } + + @Test + @DisplayName("连线两端必须存在于节点集合里") + void edgeEndpointsMustExist() { + Map draft = baseDraft(); + draft.put("edges", List.of(edge("e1", "start", "查无此节点", null))); + assertThatThrownBy(() -> WorkflowDraft.parse(draft)) + .isInstanceOf(BusinessException.class).hasMessageContaining("两端节点不存在"); + } + + @Test + @DisplayName("空草稿(null / 空对象)解析成空的节点与连线,不抛") + void emptyDraftIsValid() { + assertThat(WorkflowDraft.parse(null).nodes()).isEmpty(); + assertThat(WorkflowDraft.parse(null).edges()).isEmpty(); + assertThat(WorkflowDraft.parse(Map.of()).nodes()).isEmpty(); + } + + @Test + @DisplayName("nodes / edges 不是列表时按空处理(宽容解析)") + void nonListCollectionsTolerated() { + Map draft = new LinkedHashMap<>(); + draft.put("nodes", "不是列表"); + draft.put("edges", 42); + assertThat(WorkflowDraft.parse(draft).nodes()).isEmpty(); + } + + /* ---------- 辅助 ---------- */ + + private static Map draftWithEdge(Object condition) { + Map draft = baseDraft(); + draft.put("edges", List.of(edge("edge_1", "start", "approve", condition))); + return draft; + } + + private static Map draftWithRule(Map rule) { + Map draft = baseDraft(); + @SuppressWarnings("unchecked") + Map node = (Map) ((List) draft.get("nodes")).get(0); + node.put("actorRules", List.of(rule)); + return draft; + } + + private static Map baseDraft() { + Map draft = new LinkedHashMap<>(); + draft.put("nodes", new ArrayList<>(List.of( + node("start", "start"), node("approve", "user_task")))); + draft.put("edges", new ArrayList()); + return draft; + } + + private static Map nodesDraft(List> nodes) { + Map draft = new LinkedHashMap<>(); + draft.put("nodes", new ArrayList<>(nodes)); + return draft; + } + + private static Map node(String nodeKey, String nodeType) { + Map node = new LinkedHashMap<>(); + node.put("nodeKey", nodeKey); + node.put("nodeType", nodeType); + node.put("name", nodeKey); + return node; + } + + private static Map edge(String edgeKey, String from, String to, Object condition) { + Map edge = new LinkedHashMap<>(); + edge.put("edgeKey", edgeKey); + edge.put("from", from); + edge.put("to", to); + if (condition != null) { + edge.put("condition", condition); + } + return edge; + } + + private static Map rule(String actorType, String actorRef) { + Map rule = new LinkedHashMap<>(); + rule.put("actorType", actorType); + rule.put("actorRef", actorRef); + return rule; + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowEndToEndTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowEndToEndTests.java new file mode 100644 index 00000000..f2d6f5d4 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowEndToEndTests.java @@ -0,0 +1,868 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.service.permission.SqlPermissionService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 工作流**端到端**(真库):用一套自建的流程定义走完整的「送审 → 审批 → 流转 → 结束」链路。 + * + *

与 {@link WorkflowDbIntegrationTests} 的分工:那个类用手工插入的 wf_instance / wf_task 行 + * 验证单个修复点的 SQL 语义;本类**不造运行数据**,只造**配置**(定义 / 版本 / 节点 / 连线 / + * 审批人规则 / 绑定 / 模块 / 权限点),然后调用公开服务方法 {@code submit} / {@code act} / + * {@code withdraw},让真实的 `advance` / `enterNode` / `activateUserTask` / 条件选边 / 投影 + * 全部跑一遍。这样才能证明「流程真的能走通」,而不只是「各段 SQL 各自正确」。 + * + *

自建配置的原因:mock 的 `expense_approval` 绑定故意停用,且它的条件引用 `b_amount`, + * 而 `v_b_othercompany` 没有该字段(见 sql/90_mock 注释),无法直接用于送审。 + * + *

门禁:{@code @Tag("db")},默认不跑;{@code ./mvnw.cmd -o test -Pdb-tests}。 + * 所有测试数据用 {@value #PREFIX} 前缀,{@code @AfterEach} 全量清理。 + */ +@SpringBootTest +@Tag("db") +@DisplayName("工作流端到端(真库)") +class WorkflowEndToEndTests { + + private static final String PREFIX = "zztest_e2e_"; + private static final String ORG = "G3HD"; + private static final String ADMIN = SqlPermissionService.ADMIN_ACCOUNT; + + /** 演示用户(mock 数据里已存在且启用),用于充当审批人 */ + private static final String APPROVER_A = "demo_zhangwei"; + private static final String APPROVER_B = "demo_liqiang"; + + private static final String DEF_ID = PREFIX + "def"; + private static final String VER_ID = PREFIX + "ver"; + private static final String BINDING_ID = PREFIX + "binding"; + private static final String MODULE_ID = PREFIX + "module"; + + @Resource + private DataSource dataSource; + + @Resource + private WorkflowRuntimeService runtimeService; + + @Resource + private WorkflowRuntimeQueryService queryService; + + @BeforeEach + void setUp() throws Exception { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(ADMIN); + cleanup(); + buildFixture(); + } + + @AfterEach + void tearDown() throws Exception { + cleanup(); + OrgContext.clear(); + } + + /* ============================================================================ + 一、真正的完整流程:送审 → 逐节点审批 → 结束 + ============================================================================ */ + + @Test + @DisplayName("E2E 送审后自动落到第一个审批节点,并生成真实待办") + void submitLandsOnFirstApprovalNode() throws Exception { + Map submitted = submit("biz-happy", Map.of("b_amount", 100)); + + assertThat(submitted.get("duplicated")).isEqualTo(false); + assertThat(submitted.get("status")).isEqualTo("running"); + String instanceId = String.valueOf(submitted.get("instanceId")); + assertThat(instanceId).isNotBlank(); + + // 实例落库,且开始节点已记录为 completed(start 自动通过) + assertThat(instanceStatus(instanceId)).isEqualTo("running"); + assertThat(nodeRunStatus(instanceId, "start")).isEqualTo("completed"); + assertThat(nodeRunStatus(instanceId, "approve1")).isEqualTo("running"); + + // 真实待办:approve1 给 APPROVER_A 生成 1 条 pending + List> tasks = tasksOf(instanceId); + assertThat(tasks).hasSize(1); + assertThat(tasks.get(0).get("node_key")).isEqualTo("approve1"); + assertThat(tasks.get(0).get("assignee")).isEqualTo(APPROVER_A); + assertThat(tasks.get(0).get("status")).isEqualTo("pending"); + + // 送审动作 + outbox 都应留痕 + assertThat(actionCount(instanceId, "submit")).isEqualTo(1); + assertThat(outboxCount(instanceId, "instance.submitted")).isEqualTo(1); + } + + @Test + @DisplayName("E2E 走完整条链:approve1 →(条件)→ approve2 → end,实例 approved") + void fullApprovalChainReachesEnd() throws Exception { + String instanceId = instanceIdOf(submit("biz-chain", Map.of("b_amount", 100))); + + // 第一关:APPROVER_A 通过 → 条件选边(b_amount=100 < 50000 走 approve2 支线) + actAs(APPROVER_A, taskIdOf(instanceId, "approve1"), "approve"); + + assertThat(nodeRunStatus(instanceId, "approve1")).isEqualTo("completed"); + assertThat(nodeRunStatus(instanceId, "approve2")).isEqualTo("running"); + assertThat(instanceStatus(instanceId)).isEqualTo("running"); + + // 第二关:APPROVER_B 通过 → 进入 end → 实例 approved + actAs(APPROVER_B, taskIdOf(instanceId, "approve2"), "approve"); + + assertThat(nodeRunStatus(instanceId, "approve2")).isEqualTo("completed"); + assertThat(nodeRunStatus(instanceId, "end")).isEqualTo("completed"); + assertThat(instanceStatus(instanceId)).isEqualTo("approved"); + + // 结束后不再有待办 + assertThat(openTaskCount(instanceId)).isZero(); + // 动作留痕:2 次节点审批的 approve(带 node_run_id)+ 1 条实例级收口 approve + // (finishInstance 会以 approve 为动作码写一条实例终态记录,node_run_id 为 null) + assertThat(actionCount(instanceId, "approve")).isEqualTo(3); + assertThat(taskLevelActionCount(instanceId, "approve")).isEqualTo(2); + assertThat(instanceLevelActionCount(instanceId, "approve")).isEqualTo(1); + // 实例有完成时间 + assertThat(completedAt(instanceId)).isNotNull(); + } + + @Test + @DisplayName("E2E 条件选边真的生效:金额走另一条支线,落到不同节点") + void conditionRoutingPicksOtherBranch() throws Exception { + // b_amount = 999999 ≥ 50000 → 应走 big_amount 支线,而不是 approve2 + String instanceId = instanceIdOf(submit("biz-big", Map.of("b_amount", 999999))); + actAs(APPROVER_A, taskIdOf(instanceId, "approve1"), "approve"); + + assertThat(nodeRunStatus(instanceId, "big_amount")).isEqualTo("running"); + assertThat(nodeRunStatus(instanceId, "approve2")).isNull(); + + // 分支点应写一条 route 留痕(D3),且轨迹里能看到两条出线的求值结果 + assertThat(actionCount(instanceId, "route")).isEqualTo(1); + + actAs(APPROVER_B, taskIdOf(instanceId, "big_amount"), "approve"); + assertThat(instanceStatus(instanceId)).isEqualTo("approved"); + } + + @Test + @DisplayName("E2E 业务状态投影真的写回业务表") + void businessStatusProjectionUpdatesBusinessRow() throws Exception { + // 模块指向真实的 b_con_type,主键 b_id 是 bigint(雪花),状态列用 b_xh。 + // 注意:该表**没有 b_row_version 列**(实测全库只有 wf_instance 有), + // 所以这里只验证状态值被写回;「有版本列时一并自增」的分支由 + // WorkflowInstancePolicyTests 的纯单测覆盖(两种列存在性 × 行数矩阵)。 + long businessKey = firstBusinessKey(); + long before = longValue("select b_xh from dbo.b_con_type where b_id = ?", businessKey); + + String instanceId = instanceIdOf(submitWithBusinessId("biz-proj", String.valueOf(businessKey))); + actAs(APPROVER_A, taskIdOf(instanceId, "approve1"), "approve"); + actAs(APPROVER_B, taskIdOf(instanceId, "approve2"), "approve"); + + assertThat(instanceStatus(instanceId)).isEqualTo("approved"); + // approved → 状态映射值 8888(fixture 的 b_status_map_json 里配的) + assertThat(longValue("select b_xh from dbo.b_con_type where b_id = ?", businessKey)).isEqualTo(8888L); + + // 投影审计:s_log_audit 有该行,字段级表记录了 b_xh 的前后值 + assertThat(count("select count(1) from dbo.s_log_audit where b_data_id = ?", String.valueOf(businessKey))) + .isGreaterThanOrEqualTo(1); + assertThat(count("select count(1) from dbo.s_log_audit_field where b_data_id = ? and b_field = 'b_xh'", + String.valueOf(businessKey))).isGreaterThanOrEqualTo(1); + + // 恢复业务行与审计,避免影响真实数据 + update("update dbo.b_con_type set b_xh = ? where b_id = ?", before, businessKey); + update("delete from dbo.s_log_audit_field where b_data_id = ?", String.valueOf(businessKey)); + update("delete from dbo.s_log_audit where b_data_id = ?", String.valueOf(businessKey)); + } + + /* ============================================================================ + 二、驳回 / 退回 + ============================================================================ */ + + @Test + @DisplayName("E2E reject(terminate)→ 实例 rejected,同节点其余待办取消") + void rejectTerminatesInstance() throws Exception { + String instanceId = instanceIdOf(submit("biz-reject", Map.of("b_amount", 100))); + actAs(APPROVER_A, taskIdOf(instanceId, "approve1"), "reject"); + + assertThat(instanceStatus(instanceId)).isEqualTo("rejected"); + assertThat(completedAt(instanceId)).isNotNull(); + // 任务级 reject 1 条 + finishInstance 的实例级收口 reject 1 条 + assertThat(taskLevelActionCount(instanceId, "reject")).isEqualTo(1); + assertThat(instanceLevelActionCount(instanceId, "reject")).isEqualTo(1); + assertThat(openTaskCount(instanceId)).isZero(); + } + + @Test + @DisplayName("E2E return → 实例 returned(退回发起人),后续节点不再激活") + void returnSendsBackToInitiator() throws Exception { + String instanceId = instanceIdOf(submit("biz-return", Map.of("b_amount", 100))); + actAs(APPROVER_A, taskIdOf(instanceId, "approve1"), "return"); + + assertThat(instanceStatus(instanceId)).isEqualTo("returned"); + assertThat(actionCount(instanceId, "return")).isEqualTo(1); + assertThat(nodeRunStatus(instanceId, "approve2")).isNull(); + assertThat(openTaskCount(instanceId)).isZero(); + } + + /* ============================================================================ + 三、幂等 + ============================================================================ */ + + @Test + @DisplayName("E2E 送审幂等:同一 idempotencyKey 重复送审返回同一实例(不新建)") + void submitIsIdempotent() throws Exception { + String key = "idem-" + UUID.randomUUID(); + Map first = submit("biz-idem", Map.of("b_amount", 100), key); + Map second = submit("biz-idem", Map.of("b_amount", 100), key); + + assertThat(second.get("duplicated")).isEqualTo(true); + // 实例主键是 UUID:库里回读是大写,submit 首次返回的是 Java 的 lowercase 串, + // 比较时要忽略大小写(同一个值,不是不同实例) + assertThat(String.valueOf(second.get("instanceId"))) + .isEqualToIgnoringCase(String.valueOf(first.get("instanceId"))); + assertThat(count("select count(1) from dbo.wf_instance where b_business_id = ?", "biz-idem")).isEqualTo(1); + } + + @Test + @DisplayName("E2E 审批幂等:同一 idempotencyKey 重复审批只生效一次") + void actIsIdempotent() throws Exception { + String instanceId = instanceIdOf(submit("biz-act-idem", Map.of("b_amount", 100))); + String taskId = taskIdOf(instanceId, "approve1"); + String key = "act-idem-" + UUID.randomUUID(); + + Map params = new LinkedHashMap<>(); + params.put("taskId", taskId); + params.put("action", "approve"); + params.put("idempotencyKey", key); + + OrgContext.setUserId(APPROVER_A); + try { + runtimeService.act(params); + // 重放同一 key:应返回 duplicated 而不是报错 + Map replay = runtimeService.act(params); + assertThat(replay.get("duplicated")).isEqualTo(true); + } finally { + OrgContext.setUserId(ADMIN); + } + // 任务级 approve 只记了一次(另有一条实例级收口不算在内) + assertThat(taskLevelActionCount(instanceId, "approve")).isEqualTo(1); + } + + /* ============================================================================ + 四、B2 并发死锁 + ============================================================================ */ + + @Test + @DisplayName("B2 并发:act(approve) 与 withdraw 同时打同一实例,无 1205 死锁且最终状态一致") + void concurrentActAndWithdrawDoesNotDeadlock() throws Exception { + // 反复多轮:死锁是概率性的,单跑一次可能恰好不撞上 + for (int round = 0; round < 12; round++) { + String instanceId = instanceIdOf(submit("biz-race-" + round, Map.of("b_amount", 100))); + String taskId = taskIdOf(instanceId, "approve1"); + + List errors = runConcurrently( + () -> { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(APPROVER_A); + runtimeService.act(Map.of("taskId", taskId, "action", "approve")); + }, + () -> { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(ADMIN); // 发起人撤回 + runtimeService.withdraw(Map.of("instanceId", instanceId)); + }); + + // 关键断言:不允许出现 SQL Server 死锁(1205)。业务竞争(如「任务已被处理」)是允许的。 + for (Throwable error : errors) { + assertThat(isDeadlock(error)) + .as("第 %d 轮出现死锁:%s", round, rootMessage(error)) + .isFalse(); + } + + // 最终必须收敛到某个确定终态,且不会两件事都成功 + String status = instanceStatus(instanceId); + assertThat(status).as("第 %d 轮最终状态", round).isIn("approved", "withdrawn"); + // 无论谁赢,都不该残留可办理的待办 + assertThat(openTaskCount(instanceId)).as("第 %d 轮残留待办", round).isZero(); + assertThat(instanceRowVersion(instanceId)).isGreaterThan(0L); + } + } + + @Test + @DisplayName("B2 并发:两个审批人对同一任务同时 approve,无死锁且只成功一次") + void concurrentApproveOnSameTaskIsSafe() throws Exception { + for (int round = 0; round < 12; round++) { + String instanceId = instanceIdOf(submit("biz-race2-" + round, Map.of("b_amount", 100))); + String taskId = taskIdOf(instanceId, "approve1"); + + List errors = runConcurrently( + () -> { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(APPROVER_A); + runtimeService.act(Map.of("taskId", taskId, "action", "approve")); + }, + () -> { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(APPROVER_A); + runtimeService.act(Map.of("taskId", taskId, "action", "approve")); + }); + + for (Throwable error : errors) { + assertThat(isDeadlock(error)) + .as("第 %d 轮出现死锁:%s", round, rootMessage(error)) + .isFalse(); + } + // 任务只被核销一次 + assertThat(actionCount(instanceId, "approve")).as("第 %d 轮 approve 动作数", round).isEqualTo(1); + } + } + + @Test + @DisplayName("B2 锁序:实例行先于任务行(与 withdraw/cancel 同序,故不会 AB-BA 死锁)") + void lockOrderIsInstanceFirst() throws Exception { + // act 的第一步就是按实例加 UPDLOCK;这里用两个方向相反的并发操作反复压测 + for (int round = 0; round < 10; round++) { + String instanceId = instanceIdOf(submit("biz-order-" + round, Map.of("b_amount", 100))); + String taskId = taskIdOf(instanceId, "approve1"); + + List errors = runConcurrently( + () -> { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(ADMIN); + runtimeService.cancel(Map.of("instanceId", instanceId)); + }, + () -> { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(APPROVER_A); + runtimeService.act(Map.of("taskId", taskId, "action", "approve")); + }); + + for (Throwable error : errors) { + assertThat(isDeadlock(error)) + .as("第 %d 轮出现死锁:%s", round, rootMessage(error)) + .isFalse(); + } + assertThat(instanceStatus(instanceId)).isIn("approved", "cancelled"); + } + } + + /* ============================================================================ + 五、C6 / C7 验收项 + ============================================================================ */ + + @Test + @DisplayName("C6 return_previous:当前节点先收口为 completed,不留 running 悬挂") + void returnPreviousClosesCurrentNodeRun() throws Exception { + // 用第二个节点(rejectMode=return_previous)来验证 + String instanceId = instanceIdOf(submit("biz-c6", Map.of("b_amount", 100))); + actAs(APPROVER_A, taskIdOf(instanceId, "approve1"), "approve"); + assertThat(nodeRunStatus(instanceId, "approve2")).isEqualTo("running"); + + // 在 approve2 上 reject:rejectMode=return_previous → 回到 approve1 重跑 + actAs(APPROVER_B, taskIdOf(instanceId, "approve2"), "reject"); + + // 关键:approve2 的最新一次运行必须是 completed,不能停在 running + assertThat(latestNodeRunStatus(instanceId, "approve2")).isEqualTo("completed"); + assertThat(runningNodeRunCount(instanceId, "approve2")).isZero(); + // 且回到 approve1 重新激活(run_no 递增) + assertThat(latestNodeRunStatus(instanceId, "approve1")).isEqualTo("running"); + assertThat(nodeRunCount(instanceId, "approve1")).isEqualTo(2); + // 实例仍在运行 + assertThat(instanceStatus(instanceId)).isEqualTo("running"); + } + + @Test + @DisplayName("C7 service_task 节点的动作码是 service(不是 cc)") + void serviceTaskWritesServiceAction() throws Exception { + // b_amount 取 999999 走 big_amount 支线;该支线在 fixture 里串了 service_task + String instanceId = instanceIdOf(submit("biz-c7", Map.of("b_amount", 100))); + actAs(APPROVER_A, taskIdOf(instanceId, "approve1"), "approve"); + actAs(APPROVER_B, taskIdOf(instanceId, "approve2"), "approve"); + + // fixture 在 end 之前放了一个 service 节点 + assertThat(actionCount(instanceId, "service")).isEqualTo(1); + assertThat(actionCount(instanceId, "cc")).isZero(); + assertThat(nodeRunStatus(instanceId, "svc")).isEqualTo("completed"); + assertThat(instanceStatus(instanceId)).isEqualTo("approved"); + } + + @Test + @DisplayName("C2 时间出参带 Z:经 WorkflowSql 读出的时间列是 UTC ISO 串") + void configSideTimesAreUtcIso() throws Exception { + String instanceId = instanceIdOf(submit("biz-c2", Map.of("b_amount", 100))); + + // 走 WorkflowSql(工作流读写统一出口):时间应转成带 Z 的 ISO 串。 + // 这正是 C2 修的缺陷——配置侧曾有一份漏掉 TimeUtils.toApiValue 的私有复制, + // 导致读出的时间不带 Z。现在两侧共用 WorkflowSql,这里同时验证运行侧与配置侧口径。 + try (Connection connection = dataSource.getConnection()) { + Object startedAt = WorkflowSql.scalar(connection, + "select b_started_at from dbo.wf_instance where b_id = ?", UUID.fromString(instanceId)); + assertThat(String.valueOf(startedAt)).endsWith("Z"); + + // 配置侧读同一个库的时间列(这里借 wf_instance.b_completed_at 之外的 example: + // 版本发布时间),确认配置侧不会因为路径不同而丢掉 Z + Object publishedAt = WorkflowSql.scalar(connection, + "select b_published_at from dbo.wf_version where b_id = ?", VER_ID); + assertThat(String.valueOf(publishedAt)).endsWith("Z"); + } + + // 待办查询也返回时间列,且实例启动时间带 Z + OrgContext.setUserId(APPROVER_A); + try { + Map todo = queryService.todoList(Map.of("page", 1, "size", 10)); + assertThat(todo).containsKey("rows"); + } finally { + OrgContext.setUserId(ADMIN); + } + } + + /* ============================================================================ + 夹具:一套最小但完整的流程配置 + ============================================================================ */ + + /** + * 建配置:模块(可投影到 b_con_type)+ 定义 + 已发布版本 + 节点/连线/审批人规则 + 绑定 + 权限点。 + * + *

+     * start → approve1(A, single) → gateway(金额分支)
+     *                                 ├─ amount >= 50000 → big_amount(B) ┐
+     *                                 └─ 否则             → approve2(B) ─┴→ svc(service) → end
+     * 
+ */ + private void buildFixture() throws Exception { + try (Connection connection = dataSource.getConnection()) { + // 模块:指向真实表 b_con_type,状态列 b_xh,主键 b_id(bigint) + WorkflowSql.update(connection, """ + insert into dbo.s_module (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, + b_module_type, b_view_table, b_save_table, b_key_field, + b_canuse, b_xh, b_bz) + values (?, null, 1, ?, ?, ?, 'data', 'b_con_type', 'b_con_type', 'b_id', 1, 9999, ?) + """, MODULE_ID, "/" + MODULE_ID + "/", PREFIX + "模块", "e2e.test", PREFIX + "端到端测试模块"); + + WorkflowSql.update(connection, """ + insert into dbo.wf_definition (b_id, b_name, b_i18n, b_canuse, b_bz, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, null, 1, ?, ?, sysutcdatetime(), ?, sysutcdatetime()) + """, DEF_ID, PREFIX + "端到端流程", PREFIX + "测试用", ADMIN, ADMIN); + + WorkflowSql.update(connection, """ + insert into dbo.wf_version (b_id, b_definition_id, b_version_no, b_status, b_schema_version, + b_checksum, b_published_by, b_published_at, b_bz, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, 1, 'published', 1, ?, ?, sysutcdatetime(), ?, + ?, sysutcdatetime(), ?, sysutcdatetime()) + """, VER_ID, DEF_ID, "e2e-checksum", ADMIN, PREFIX + "已发布", ADMIN, ADMIN); + + // 节点 + node(connection, "start", "开始", "start", null, null, null, 10); + node(connection, "approve1", "第一关", "user_task", "single", "terminate", null, 20); + node(connection, "gateway", "金额分支", "exclusive_gateway", null, null, null, 30); + node(connection, "big_amount", "大额审批", "user_task", "single", "terminate", null, 40); + node(connection, "approve2", "第二关", "user_task", "single", "return_previous", null, 50); + node(connection, "svc", "服务节点", "service_task", null, null, null, 60); + node(connection, "end", "结束", "end", null, null, null, 70); + + // 连线:gateway 两条出线,条件在前,默认边在后(§6.5 按 priority 判断) + edge(connection, "e1", "start", "approve1", null, 10); + edge(connection, "e2", "approve1", "gateway", null, 10); + edge(connection, "e3", "gateway", "big_amount", + "{\"field\":\"b_amount\",\"operator\":\"ge\",\"value\":50000}", 10); + edge(connection, "e4", "gateway", "approve2", null, 20); + edge(connection, "e5", "big_amount", "svc", null, 10); + edge(connection, "e6", "approve2", "svc", null, 10); + edge(connection, "e7", "svc", "end", null, 10); + + // 审批人:用库里真实存在且启用的演示用户 + actorRule(connection, "r1", "approve1", "user", APPROVER_A); + actorRule(connection, "r2", "approve2", "user", APPROVER_B); + actorRule(connection, "r3", "big_amount", "user", APPROVER_B); + + // 绑定:状态映射 approved/rejected/returned → b_xh,便于验证投影 + WorkflowSql.update(connection, """ + insert into dbo.wf_binding (b_id, b_module_id, b_event, b_version_id, b_match_json, + b_org_scope_json, b_status_field, b_status_map_json, b_priority, + b_effective_from, b_effective_to, b_canuse, b_bz, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, ?, 'submit', ?, null, null, 'b_xh', ?, 1, null, null, 1, ?, + ?, sysutcdatetime(), ?, sysutcdatetime()) + """, BINDING_ID, MODULE_ID, VER_ID, + "{\"running\":1,\"approved\":8888,\"rejected\":7777,\"returned\":6666,\"withdrawn\":5555}", + PREFIX + "端到端绑定", ADMIN, ADMIN); + + // 权限点:submit / approve / reject / return / withdraw(管理员提权,但普通审批人要走白名单) + for (String action : List.of("submit", "approve", "reject", "return", "withdraw")) { + String powerId = PREFIX + "power." + action; + WorkflowSql.update(connection, """ + insert into dbo.s_power (b_id, b_name, b_type, b_canuse, b_xh, b_object_type, + b_object_id, b_action, b_i18n) + values (?, ?, 'action', 1, 10, 'module', ?, ?, null) + """, powerId, PREFIX + action, MODULE_ID, action); + for (String user : List.of(APPROVER_A, APPROVER_B)) { + WorkflowSql.update(connection, """ + insert into dbo.s_user_power (b_user_id, b_power_id, b_canuse) + values (?, ?, 1) + """, user, powerId); + } + } + } + } + + private void node(Connection connection, String key, String name, String type, String mode, + String rejectMode, String config, int xh) throws SQLException { + WorkflowSql.update(connection, """ + insert into dbo.wf_node (b_id, b_version_id, b_node_key, b_name, b_node_type, + b_approval_mode, b_reject_mode, b_due_hours, b_config_json, b_xh, b_canuse) + values (?, ?, ?, ?, ?, ?, ?, null, ?, ?, 1) + """, PREFIX + "n_" + key, VER_ID, key, name, type, mode, rejectMode, config, xh); + } + + private void edge(Connection connection, String id, String from, String to, String condition, int priority) + throws SQLException { + WorkflowSql.update(connection, """ + insert into dbo.wf_edge (b_id, b_version_id, b_from_node_key, b_to_node_key, + b_condition_json, b_priority, b_canuse, b_bz) + values (?, ?, ?, ?, ?, ?, 1, null) + """, PREFIX + "e_" + id, VER_ID, from, to, condition, priority); + } + + private void actorRule(Connection connection, String id, String nodeKey, String type, String ref) + throws SQLException { + WorkflowSql.update(connection, """ + insert into dbo.wf_actor_rule (b_id, b_version_id, b_node_key, b_actor_type, b_actor_ref, + b_condition_json, b_fallback_type, b_fallback_ref, + b_priority, b_canuse, b_bz) + values (?, ?, ?, ?, ?, null, 'admin', null, 10, 1, null) + """, PREFIX + "r_" + id, VER_ID, nodeKey, type, ref); + } + + /* ============================================================================ + 服务调用与断言工具 + ============================================================================ */ + + private Map submit(String businessId, Map context) throws Exception { + return submit(businessId, context, null); + } + + private Map submit(String businessId, Map context, String idempotencyKey) + throws Exception { + Map params = new LinkedHashMap<>(); + params.put("moduleId", MODULE_ID); + params.put("businessId", businessId); + params.put("businessNo", businessId); + params.put("context", new LinkedHashMap<>(context)); + if (idempotencyKey != null) { + params.put("idempotencyKey", idempotencyKey); + } + return runtimeService.submit(params); + } + + private Map submitWithBusinessId(String businessId, String realKey) throws Exception { + Map params = new LinkedHashMap<>(); + params.put("moduleId", MODULE_ID); + params.put("businessId", realKey); + params.put("businessNo", realKey); + params.put("context", new LinkedHashMap(Map.of("b_amount", 100))); + return runtimeService.submit(params); + } + + private void actAs(String userId, String taskId, String action) throws Exception { + OrgContext.setUserId(userId); + try { + Map params = new LinkedHashMap<>(); + params.put("taskId", taskId); + params.put("action", action); + params.put("comment", "e2e-" + action); + runtimeService.act(params); + } finally { + OrgContext.setUserId(ADMIN); + } + } + + private String instanceIdOf(Map submitted) { + return String.valueOf(submitted.get("instanceId")); + } + + private String taskIdOf(String instanceId, String nodeKey) throws Exception { + String taskId = stringValue(""" + select top 1 convert(varchar(50), b_id) from dbo.wf_task + where b_instance_id = ? and b_node_key = ? and b_status in ('pending','claimed') + order by b_seq + """, UUID.fromString(instanceId), nodeKey); + assertThat(taskId).as("实例 %s 节点 %s 应有待办", instanceId, nodeKey).isNotNull(); + return taskId; + } + + private List> tasksOf(String instanceId) throws Exception { + try (Connection connection = dataSource.getConnection()) { + return WorkflowSql.query(connection, """ + select b_node_key as node_key, b_assignee_id as assignee, b_status as status, b_seq + from dbo.wf_task where b_instance_id = ? order by b_seq + """, UUID.fromString(instanceId)); + } + } + + private String instanceStatus(String instanceId) throws Exception { + return stringValue("select b_status from dbo.wf_instance where b_id = ?", UUID.fromString(instanceId)); + } + + private Long completedAt(String instanceId) throws Exception { + Object value = scalar("select b_completed_at from dbo.wf_instance where b_id = ?", + UUID.fromString(instanceId)); + return value == null ? null : 1L; + } + + private long instanceRowVersion(String instanceId) throws Exception { + return longValue("select b_row_version from dbo.wf_instance where b_id = ?", UUID.fromString(instanceId)); + } + + /** 某节点的“最新一次”运行状态(退回后会有多轮,按 b_run_no 取最新) */ + private String latestNodeRunStatus(String instanceId, String nodeKey) throws Exception { + return stringValue(""" + select top 1 b_status from dbo.wf_node_run + where b_instance_id = ? and b_node_key = ? order by b_run_no desc + """, UUID.fromString(instanceId), nodeKey); + } + + private String nodeRunStatus(String instanceId, String nodeKey) throws Exception { + return latestNodeRunStatus(instanceId, nodeKey); + } + + private int nodeRunCount(String instanceId, String nodeKey) throws Exception { + return count("select count(1) from dbo.wf_node_run where b_instance_id = ? and b_node_key = ?", + UUID.fromString(instanceId), nodeKey); + } + + private int runningNodeRunCount(String instanceId, String nodeKey) throws Exception { + return count("select count(1) from dbo.wf_node_run where b_instance_id = ? and b_node_key = ? and b_status = 'running'", + UUID.fromString(instanceId), nodeKey); + } + + private int openTaskCount(String instanceId) throws Exception { + return count("select count(1) from dbo.wf_task where b_instance_id = ? and b_status in ('pending','claimed')", + UUID.fromString(instanceId)); + } + + private int actionCount(String instanceId, String action) throws Exception { + return count("select count(1) from dbo.wf_action where b_instance_id = ? and b_action = ?", + UUID.fromString(instanceId), action); + } + + /** + * 任务级动作(带 node_run_id / task_id)。 + * + *

为什么要和 {@link #instanceLevelActionCount} 分开数:{@code finishInstance} 在实例收口时 + * 也会写一条 {@code approve}/{@code reject}(node_run_id 与 task_id 都是 null), + * 所以「用户点了几次通过」不能直接拿动作总数断言。 + */ + private int taskLevelActionCount(String instanceId, String action) throws Exception { + return count("select count(1) from dbo.wf_action where b_instance_id = ? and b_action = ?" + + " and b_task_id is not null", UUID.fromString(instanceId), action); + } + + /** 实例级动作(无 task_id),即 finishInstance 的收口记录 */ + private int instanceLevelActionCount(String instanceId, String action) throws Exception { + return count("select count(1) from dbo.wf_action where b_instance_id = ? and b_action = ?" + + " and b_task_id is null", UUID.fromString(instanceId), action); + } + + private int outboxCount(String instanceId, String eventCode) throws Exception { + return count("select count(1) from dbo.wf_outbox where b_aggregate_id = ? and b_event_code = ?", + UUID.fromString(instanceId), eventCode); + } + + private long firstBusinessKey() throws Exception { + Long key = longOrNull("select top 1 b_id from dbo.b_con_type order by b_id"); + org.junit.jupiter.api.Assumptions.assumeTrue(key != null, "b_con_type 无数据,跳过投影用例"); + return key; + } + + /* ---------- 并发执行 ---------- */ + + /** 用 start gate 让两个动作尽量同时开跑,收集异常(不抛出,由调用方断言) */ + private static List runConcurrently(ThrowingRunnable first, ThrowingRunnable second) + throws Exception { + java.util.concurrent.CountDownLatch ready = new java.util.concurrent.CountDownLatch(2); + java.util.concurrent.CountDownLatch go = new java.util.concurrent.CountDownLatch(1); + List errors = java.util.Collections.synchronizedList(new ArrayList<>()); + + List threads = new ArrayList<>(); + for (ThrowingRunnable work : List.of(first, second)) { + Thread thread = new Thread(() -> { + ready.countDown(); + try { + go.await(); + work.run(); + } catch (Throwable error) { + errors.add(error); + } finally { + OrgContext.clear(); + } + }); + threads.add(thread); + thread.start(); + } + ready.await(); + go.countDown(); + for (Thread thread : threads) { + thread.join(30_000); + } + return errors; + } + + /** 允许抛受检异常的任务体(服务方法都声明 throws SQLException) */ + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } + + /** 是否为 SQL Server 死锁(错误码 1205),会顺着 cause / suppressed 链找 */ + private static boolean isDeadlock(Throwable error) { + java.util.Set seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + java.util.Deque queue = new java.util.ArrayDeque<>(); + // ArrayDeque 不接受 null,这里显式挡掉(cause / getNextException 都可能是 null) + if (error != null) { + queue.add(error); + } + while (!queue.isEmpty()) { + Throwable current = queue.poll(); + if (current == null || !seen.add(current)) { + continue; + } + if (current instanceof java.sql.SQLException sql) { + for (Throwable next = sql; next != null; next = ((java.sql.SQLException) next).getNextException()) { + if (((java.sql.SQLException) next).getErrorCode() == 1205) { + return true; + } + } + } + String message = current.getMessage(); + if (message != null && (message.contains("1205") || message.contains("deadlock") + || message.contains("死锁"))) { + return true; + } + Throwable cause = current.getCause(); + if (cause != null) { + queue.add(cause); + } + for (Throwable suppressed : current.getSuppressed()) { + if (suppressed != null) { + queue.add(suppressed); + } + } + } + return false; + } + + private static String rootMessage(Throwable error) { + Throwable current = error; + while (current.getCause() != null) { + current = current.getCause(); + } + return current.toString(); + } + + /* ---------- 低层 SQL ---------- */ + + private int update(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + java.sql.PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + return statement.executeUpdate(); + } + } + + private int count(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + java.sql.PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (java.sql.ResultSet rs = statement.executeQuery()) { + rs.next(); + return rs.getInt(1); + } + } + } + + private long longValue(String sql, Object... args) throws SQLException { + Long value = longOrNull(sql, args); + return value == null ? -1 : value; + } + + private Long longOrNull(String sql, Object... args) throws SQLException { + Object value = scalar(sql, args); + return value instanceof Number number ? number.longValue() : null; + } + + private String stringValue(String sql, Object... args) throws SQLException { + Object value = scalar(sql, args); + return value == null ? null : String.valueOf(value); + } + + private Object scalar(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + java.sql.PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (java.sql.ResultSet rs = statement.executeQuery()) { + return rs.next() ? rs.getObject(1) : null; + } + } + } + + private static void bind(java.sql.PreparedStatement statement, Object... args) throws SQLException { + for (int index = 0; index < args.length; index++) { + statement.setObject(index + 1, args[index]); + } + } + + /** 按前缀清理:先删运行数据,再删配置,最后删模块/权限点 */ + private void cleanup() throws Exception { + update("delete from dbo.wf_task where b_instance_id in (select b_id from dbo.wf_instance where b_module_id = ?)", + MODULE_ID); + update("delete from dbo.wf_cc where b_instance_id in (select b_id from dbo.wf_instance where b_module_id = ?)", + MODULE_ID); + update("delete from dbo.wf_node_run where b_instance_id in (select b_id from dbo.wf_instance where b_module_id = ?)", + MODULE_ID); + update("delete from dbo.wf_action where b_instance_id in (select b_id from dbo.wf_instance where b_module_id = ?)", + MODULE_ID); + update("delete from dbo.wf_outbox where b_aggregate_id in (select b_id from dbo.wf_instance where b_module_id = ?)", + MODULE_ID); + update("delete from dbo.wf_instance where b_module_id = ?", MODULE_ID); + update("delete from dbo.wf_binding where b_id = ?", BINDING_ID); + update("delete from dbo.wf_actor_rule where b_version_id = ?", VER_ID); + update("delete from dbo.wf_edge where b_version_id = ?", VER_ID); + update("delete from dbo.wf_node where b_version_id = ?", VER_ID); + update("delete from dbo.wf_version where b_id = ?", VER_ID); + update("delete from dbo.wf_definition where b_id = ?", DEF_ID); + update("delete from dbo.s_user_power where b_power_id like ?", PREFIX + "power.%"); + update("delete from dbo.s_power where b_id like ?", PREFIX + "power.%"); + update("delete from dbo.s_module where b_id = ?", MODULE_ID); + } + + /** 夹具自检:确认配置真的建成了(否则后续断言会以难懂的方式失败) */ + @Test + @DisplayName("夹具自检:配置齐备(节点/连线/规则/绑定/权限)") + void fixtureIsComplete() throws Exception { + assertThat(count("select count(1) from dbo.wf_node where b_version_id = ?", VER_ID)).isEqualTo(7); + assertThat(count("select count(1) from dbo.wf_edge where b_version_id = ?", VER_ID)).isEqualTo(7); + assertThat(count("select count(1) from dbo.wf_actor_rule where b_version_id = ?", VER_ID)).isEqualTo(3); + assertThat(count("select count(1) from dbo.wf_binding where b_id = ?", BINDING_ID)).isEqualTo(1); + assertThat(count("select count(1) from dbo.s_module where b_id = ?", MODULE_ID)).isEqualTo(1); + assertThat(count("select count(1) from dbo.s_power where b_object_id = ?", MODULE_ID)).isEqualTo(5); + + // 审批人都必须是启用用户,否则解析为空会让实例进 error + assertThat(count("select count(1) from dbo.b_user where b_id in (?, ?) and b_canuse = 1", + APPROVER_A, APPROVER_B)).isEqualTo(2); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepTests.java new file mode 100644 index 00000000..9ce77e18 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowExpirySweepTests.java @@ -0,0 +1,436 @@ +package cn.g3soft.erp.service.workflow; + +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.service.permission.SqlPermissionService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 超时扫描(D2 / §12 补充策略)的**真库**测试。 + * + *

与 {@code WorkflowDbIntegrationTests.expiryOnlyMarksTask} 的区别:那个用例是 + * **手工造行 + 手工 UPDATE**,验证的是「只改任务状态、不改实例状态」这条**语义**; + * 本类驱动的是**真实的调度链路** {@code sweepQuietly()} → 按机构 fan-out → {@code sweepOrg()}, + * 也就是**线上真正会跑的那段代码**(含它的选取条件、动作/outbox 落库、事务边界与异常兜底)。 + * + *

为什么要专门测:这条链路是**后台定时任务**,没有请求上下文,一旦写错不会有人立刻发现—— + * 前端只会看到待办「莫名消失」。所以把「该标的标了、不该标的没动、跑两次不重复标」 + * 三件事都钉死。 + * + *

门禁:{@code @Tag("db")},默认不跑;{@code ./mvnw.cmd -o test -Pdb-tests}。 + * 测试数据用 {@value #PREFIX} 前缀,{@code @AfterEach} 清理。 + * + *

为什么可以直接调:{@code sweepQuietly()} 是包内可见(非 private), + * 且扫描间隔默认 {@code 5m}(见 application.yaml),所以测试期间定时器不会自己触发, + * 手动调用即可得到确定性结果——不需要 sleep,也不需要等调度。 + */ +@SpringBootTest +@Tag("db") +@DisplayName("工作流超时扫描(真库)") +class WorkflowExpirySweepTests { + + private static final String PREFIX = "zztest_sweep_"; + private static final String ORG = "G3HD"; + private static final String ADMIN = SqlPermissionService.ADMIN_ACCOUNT; + private static final String ASSIGNEE = "demo_zhangwei"; + + @Resource + private DataSource dataSource; + + @Resource + private WorkflowExpirySweepService sweepService; + + private final List instanceIds = new ArrayList<>(); + + @BeforeEach + void setUp() { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(ADMIN); + } + + @AfterEach + void tearDown() throws Exception { + cleanup(); + OrgContext.clear(); + } + + /* ============================================================================ + 一、核心语义:到期就标记,且只标记 + ============================================================================ */ + + @Test + @DisplayName("到期任务被标记为 expired,并写 expire 动作 + task.expired outbox") + void expiredTaskIsMarkedAndRecorded() throws Exception { + Fixture fixture = insertInstanceWithTask("due", -60, "pending"); + + sweep(); + + // 任务被标记 + assertThat(taskStatus(fixture.taskId())).isEqualTo("expired"); + + // 写了一条系统动作:动作码 expire、无操作人(系统动作)、记录原状态 + assertThat(count(""" + select count(1) from dbo.wf_action + where b_task_id = ? and b_action = 'expire' and b_operator_id is null + and b_from_status = 'pending' and b_to_status = 'expired' + """, UUID.fromString(fixture.taskId()))).isEqualTo(1); + + // 写了 outbox,事件码 task.expired,聚合指向该任务 + assertThat(count(""" + select count(1) from dbo.wf_outbox + where b_event_code = 'task.expired' and b_aggregate_type = 'task' and b_aggregate_id = ? + """, fixture.taskId())).isEqualTo(1); + } + + @Test + @DisplayName("只标记,不改流程:实例仍 running、node_run 仍 running、没有新的待办") + void sweepDoesNotChangeFlowState() throws Exception { + Fixture fixture = insertInstanceWithTask("onlymark", -60, "pending"); + + sweep(); + + // 这是 D2 刻意选的语义:超期不自动通过/否决,也不置 error 打断流程 + assertThat(instanceStatus(fixture.instanceId())).isEqualTo("running"); + assertThat(instanceCompletedAt(fixture.instanceId())).isNull(); + assertThat(nodeRunStatus(fixture.nodeRunId())).isEqualTo("running"); + assertThat(nodeRunExitedAt(fixture.nodeRunId())).isNull(); + + // 没有为它生成任何新任务(不自动提升串行后继的直接体现) + assertThat(count("select count(1) from dbo.wf_task where b_instance_id = ?", + UUID.fromString(fixture.instanceId()))).isEqualTo(1); + + // 也没有写终态 outbox(instance.* 那类) + assertThat(count(""" + select count(1) from dbo.wf_outbox + where b_aggregate_id = ? and b_event_code like 'instance.%' + """, UUID.fromString(fixture.instanceId()))).isZero(); + } + + @Test + @DisplayName("claimed 任务同样会被标记,动作里记录的原状态是 claimed") + void claimedTaskIsAlsoMarked() throws Exception { + Fixture fixture = insertInstanceWithTask("claimed", -30, "claimed"); + + sweep(); + + assertThat(taskStatus(fixture.taskId())).isEqualTo("expired"); + assertThat(count(""" + select count(1) from dbo.wf_action + where b_task_id = ? and b_action = 'expire' and b_from_status = 'claimed' + """, UUID.fromString(fixture.taskId()))).isEqualTo(1); + } + + /* ============================================================================ + 二、边界:不该动的绝不能动 + ============================================================================ */ + + @Test + @DisplayName("未到期任务不动(b_due_at 在未来)") + void futureTaskIsUntouched() throws Exception { + Fixture fixture = insertInstanceWithTask("future", 60, "pending"); + + sweep(); + + assertThat(taskStatus(fixture.taskId())).isEqualTo("pending"); + assertThat(actionCount(fixture.taskId(), "expire")).isZero(); + } + + @Test + @DisplayName("没配时限(b_due_at 为 null)的任务永不被标记 —— 这正是 E1 那条路径") + void taskWithoutDueDateIsNeverExpired() throws Exception { + Fixture fixture = insertInstanceWithTask("nodue", null, "pending"); + + sweep(); + + // 未配 b_due_hours 的节点建出的任务 b_due_at 是 null,永远不该超时 + assertThat(taskStatus(fixture.taskId())).isEqualTo("pending"); + assertThat(actionCount(fixture.taskId(), "expire")).isZero(); + } + + @Test + @DisplayName("waiting(串行会签排队中)任务不参与超时 —— 它还没轮到人处理") + void waitingTaskIsNotExpired() throws Exception { + Fixture fixture = insertInstanceWithTask("waiting", -60, "waiting"); + + sweep(); + + // 选取条件限定 pending/claimed;waiting 是「排队等前面的人签完」, + // 即使时间已过也不该被算作超期(否则串行链会被从中间掐断) + assertThat(taskStatus(fixture.taskId())).isEqualTo("waiting"); + assertThat(actionCount(fixture.taskId(), "expire")).isZero(); + } + + @Test + @DisplayName("已办结任务(approved/rejected)不再被重复处理") + void finishedTaskIsNotTouched() throws Exception { + for (String status : List.of("approved", "rejected", "cancelled", "expired")) { + Fixture fixture = insertInstanceWithTask("done-" + status, -60, status); + sweep(); + assertThat(taskStatus(fixture.taskId())) + .as("状态 %s 的任务不该被扫描改动", status) + .isEqualTo(status); + assertThat(actionCount(fixture.taskId(), "expire")) + .as("状态 %s 的任务不该产生新的 expire 动作", status) + .isZero(); + } + } + + /* ============================================================================ + 三、幂等:跑两次只标一次 + ============================================================================ */ + + @Test + @DisplayName("连跑两次:第二次不再匹配(状态已变 expired),动作与 outbox 都不重复") + void sweepIsIdempotent() throws Exception { + Fixture fixture = insertInstanceWithTask("idem", -60, "pending"); + + sweep(); + assertThat(taskStatus(fixture.taskId())).isEqualTo("expired"); + + // 再跑一次:选取条件 b_status in ('pending','claimed') 已不匹配 + sweep(); + + assertThat(actionCount(fixture.taskId(), "expire")).isEqualTo(1); + assertThat(count(""" + select count(1) from dbo.wf_outbox + where b_event_code = 'task.expired' and b_aggregate_id = ? + """, fixture.taskId())).isEqualTo(1); + } + + /* ============================================================================ + 四、批量与多机构 + ============================================================================ */ + + @Test + @DisplayName("一次扫描标记多条到期任务(同一机构内批量生效)") + void sweepHandlesMultipleTasks() throws Exception { + List fixtures = new ArrayList<>(); + for (int index = 0; index < 5; index++) { + fixtures.add(insertInstanceWithTask("batch" + index, -60 - index, "pending")); + } + + sweep(); + + for (Fixture fixture : fixtures) { + assertThat(taskStatus(fixture.taskId())).isEqualTo("expired"); + } + } + + @Test + @DisplayName("fan-out 到各机构:G3HD 在配置目录里,扫描会覆盖到它") + void sweepFansOutToConfiguredOrgs() throws Exception { + // 这条断言的意义:sweepQuietly 是按 configLoader.listOrgIds() 逐个设 OrgContext 再取连接。 + // 若 fan-out 逻辑坏了(比如没设上下文),G3HD 的任务根本不会被扫到, + // 上面所有用例都会失败 —— 这里显式固化「配置目录里确实有 G3HD」这个前提。 + Fixture fixture = insertInstanceWithTask("fanout", -60, "pending"); + + sweep(); + + assertThat(taskStatus(fixture.taskId())) + .as("扫描必须真正 fan-out 到 G3HD 才可能标记到它") + .isEqualTo("expired"); + } + + @Test + @DisplayName("扫描不抛异常(后台任务抛出去会被静默吞掉,这里确保它自己兜住)") + void sweepDoesNotThrow() { + // 即使库里有各种脏数据,sweepQuietly 也必须正常返回 —— 它内部逐机构 try/catch + sweep(); + sweep(); + } + + /* ============================================================================ + 夹具 + ============================================================================ */ + + /** + * 驱动一次真实扫描,并在之后**恢复机构 / 用户上下文**。 + * + *

为什么必须包一层:{@code sweepQuietly()} 在每个机构扫完后会执行 + * {@code OrgContext.clear()}(见其 {@code finally})——这对后台任务是**正确**的 + * (不许把上下文泄漏到下一次调度),但对测试的副作用是:调用完之后 + * {@code dataSource} 这个按 {@code OrgContext} 路由的主库就取不到连接了, + * 后续断言与清理都会报「当前请求未设置机构上下文」。 + * + *

所以这里刻意**不改生产代码**去迁就测试,而是在测试侧恢复上下文。 + */ + private void sweep() { + try { + sweepService.sweepQuietly(); + } finally { + OrgContext.setOrgId(ORG); + OrgContext.setUserId(ADMIN); + } + } + + private record Fixture(String instanceId, String nodeRunId, String taskId) { + } + + /** + * 造一条 running 实例 + running node_run + 指定状态/时限的任务。 + * + * @param dueMinutesFromNow 相对现在的分钟数:负数=已到期,正数=未到期,null=不设时限 + */ + private Fixture insertInstanceWithTask(String tag, Integer dueMinutesFromNow, String taskStatus) + throws Exception { + String instanceId = UUID.randomUUID().toString(); + String nodeRunId = UUID.randomUUID().toString(); + String taskId = UUID.randomUUID().toString(); + + try (Connection connection = dataSource.getConnection()) { + WorkflowSql.update(connection, """ + insert into dbo.wf_instance + (b_id, b_definition_id, b_version_id, b_binding_id, b_module_id, b_event, + b_business_id, b_business_no, b_org_id, b_initiator_id, b_initiator_dept_id, b_status, + b_context_json, b_snapshot_json, b_row_version, b_started_at, + b_created_by, b_created_at, b_updated_by, b_updated_at) + values (?, 'expense_approval', 'expense_approval_v1', 'demo_expense_binding', + 'v_b_othercompany', 'submit', ?, ?, ?, ?, null, 'running', + '', '', 0, sysutcdatetime(), ?, sysutcdatetime(), ?, sysutcdatetime()) + """, UUID.fromString(instanceId), PREFIX + tag, PREFIX + tag, ORG, ADMIN, ADMIN, ADMIN); + + WorkflowSql.update(connection, """ + insert into dbo.wf_node_run + (b_id, b_instance_id, b_node_key, b_run_no, b_status, b_required_count, + b_completed_count, b_rejected_count, b_actor_snapshot_json, b_entered_at) + values (?, ?, 'dept_manager', 1, 'running', 1, 0, 0, ?, sysutcdatetime()) + """, UUID.fromString(nodeRunId), UUID.fromString(instanceId), + "{\"rules\":[],\"resolvedUsers\":[\"" + ASSIGNEE + "\"],\"notes\":[]}"); + + // b_due_at:null 走字面 null(不能把 null 传进 dateadd —— 那是 E1 的坑) + if (dueMinutesFromNow == null) { + WorkflowSql.update(connection, """ + insert into dbo.wf_task + (b_id, b_instance_id, b_node_run_id, b_node_key, b_assignee_type, b_assignee_id, + b_seq, b_status, b_due_at, b_rule_snapshot_json, b_created_at, b_updated_at) + values (?, ?, ?, 'dept_manager', 'user', ?, 10, ?, null, '', + sysutcdatetime(), sysutcdatetime()) + """, UUID.fromString(taskId), UUID.fromString(instanceId), + UUID.fromString(nodeRunId), ASSIGNEE, taskStatus); + } else { + WorkflowSql.update(connection, """ + insert into dbo.wf_task + (b_id, b_instance_id, b_node_run_id, b_node_key, b_assignee_type, b_assignee_id, + b_seq, b_status, b_due_at, b_rule_snapshot_json, b_created_at, b_updated_at) + values (?, ?, ?, 'dept_manager', 'user', ?, 10, ?, + dateadd(minute, ?, sysutcdatetime()), '', sysutcdatetime(), sysutcdatetime()) + """, UUID.fromString(taskId), UUID.fromString(instanceId), + UUID.fromString(nodeRunId), ASSIGNEE, taskStatus, dueMinutesFromNow); + } + } + instanceIds.add(instanceId); + return new Fixture(instanceId, nodeRunId, taskId); + } + + /* ============================================================================ + 断言工具 + ============================================================================ */ + + private String taskStatus(String taskId) throws Exception { + return stringValue("select b_status from dbo.wf_task where b_id = ?", UUID.fromString(taskId)); + } + + private String instanceStatus(String instanceId) throws Exception { + return stringValue("select b_status from dbo.wf_instance where b_id = ?", UUID.fromString(instanceId)); + } + + private Object instanceCompletedAt(String instanceId) throws Exception { + return scalar("select b_completed_at from dbo.wf_instance where b_id = ?", UUID.fromString(instanceId)); + } + + private String nodeRunStatus(String nodeRunId) throws Exception { + return stringValue("select b_status from dbo.wf_node_run where b_id = ?", UUID.fromString(nodeRunId)); + } + + private Object nodeRunExitedAt(String nodeRunId) throws Exception { + return scalar("select b_exited_at from dbo.wf_node_run where b_id = ?", UUID.fromString(nodeRunId)); + } + + private int actionCount(String taskId, String action) throws Exception { + return count("select count(1) from dbo.wf_action where b_task_id = ? and b_action = ?", + UUID.fromString(taskId), action); + } + + /* ---------- 低层 SQL ---------- */ + + private int count(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet rs = statement.executeQuery()) { + rs.next(); + return rs.getInt(1); + } + } + } + + private String stringValue(String sql, Object... args) throws SQLException { + Object value = scalar(sql, args); + return value == null ? null : String.valueOf(value); + } + + private Object scalar(String sql, Object... args) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, args); + try (ResultSet rs = statement.executeQuery()) { + return rs.next() ? rs.getObject(1) : null; + } + } + } + + private static void bind(PreparedStatement statement, Object... args) throws SQLException { + for (int index = 0; index < args.length; index++) { + statement.setObject(index + 1, args[index]); + } + } + + /** + * 按前缀清理本类造的运行数据。 + * + *

⚠️ **删除顺序有依赖**:`task.expired` 这条 outbox 的 {@code b_aggregate_type='task'}、 + * {@code b_aggregate_id} 存的是**任务主键**,而任务主键与实例主键是两个不同的 UUID。 + * 所以清理 outbox 时既要按实例主键删(instance.* 那类),也要**按任务主键删**—— + * 后者必须在 {@code wf_task} 还在的时候执行,否则子查询查不到任何行、outbox 就漏掉了。 + * 这正是本方法最初写成「先删 task 再删 outbox」时踩到的坑(残留了 20 条 task.expired)。 + */ + private void cleanup() throws Exception { + try (Connection connection = dataSource.getConnection()) { + for (String sql : List.of( + // 1) 先按「任务主键」删 outbox —— 必须在 wf_task 还存在时做 + "delete from dbo.wf_outbox where b_aggregate_id in" + + " (select convert(varchar(50), b_id) from dbo.wf_task where b_instance_id in" + + " (select b_id from dbo.wf_instance where b_business_id like ?))", + // 2) 再按「实例主键」删 outbox(instance.* 那类) + "delete from dbo.wf_outbox where b_aggregate_id in" + + " (select convert(varchar(50), b_id) from dbo.wf_instance where b_business_id like ?)", + // 3) 最后删运行数据本身 + "delete from dbo.wf_action where b_instance_id in" + + " (select b_id from dbo.wf_instance where b_business_id like ?)", + "delete from dbo.wf_task where b_instance_id in" + + " (select b_id from dbo.wf_instance where b_business_id like ?)", + "delete from dbo.wf_node_run where b_instance_id in" + + " (select b_id from dbo.wf_instance where b_business_id like ?)", + "delete from dbo.wf_instance where b_business_id like ?")) { + WorkflowSql.update(connection, sql, PREFIX + "%"); + } + } + instanceIds.clear(); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowInstancePolicyTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowInstancePolicyTests.java new file mode 100644 index 00000000..904f481c --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowInstancePolicyTests.java @@ -0,0 +1,377 @@ +package cn.g3soft.erp.service.workflow; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link WorkflowInstancePolicy} 的纯判定测试:会签票数、投影守卫、职责分离三态开关。 + * + *

这些都是「配置 → 决策」的映射,算错的表现是流程静默走错或卡死,且原先只能靠连真库的 + * 集成测试覆盖(这正是交接说明 §十 担心的「P5 搬移没有安全网」)。抽出来之后可以不连库直接钉死。 + */ +@DisplayName("工作流运行期判定") +class WorkflowInstancePolicyTests { + + @Nested + @DisplayName("会签票数") + class RequiredCount { + + @Test + @DisplayName("all / serial:全部候选人都要签") + void allAndSerialRequireEveryone() { + assertThat(WorkflowInstancePolicy.requiredCount("all", Map.of(), 5)).isEqualTo(5); + assertThat(WorkflowInstancePolicy.requiredCount("serial", Map.of(), 3)).isEqualTo(3); + assertThat(WorkflowInstancePolicy.requiredCount("all", Map.of(), 1)).isEqualTo(1); + } + + @Test + @DisplayName("single / any / 未知模式:1 票即通过") + void singleAnyUnknownNeedOne() { + assertThat(WorkflowInstancePolicy.requiredCount("single", Map.of(), 5)).isEqualTo(1); + assertThat(WorkflowInstancePolicy.requiredCount("any", Map.of(), 5)).isEqualTo(1); + assertThat(WorkflowInstancePolicy.requiredCount("", Map.of(), 5)).isEqualTo(1); + assertThat(WorkflowInstancePolicy.requiredCount(null, Map.of(), 5)).isEqualTo(1); + assertThat(WorkflowInstancePolicy.requiredCount("查无此模式", Map.of(), 5)).isEqualTo(1); + } + + @Test + @DisplayName("n_of_m:取 requiredCount 并夹在 [1, 候选数]") + void nOfMClamps() { + assertThat(WorkflowInstancePolicy.requiredCount("n_of_m", config("requiredCount", 3), 5)).isEqualTo(3); + // 配得比候选数还大 → 夹到候选数(否则永远凑不满票,流程卡死) + assertThat(WorkflowInstancePolicy.requiredCount("n_of_m", config("requiredCount", 99), 2)).isEqualTo(2); + // 配成 0 / 负数 → 夹到 1(否则「零票通过」) + assertThat(WorkflowInstancePolicy.requiredCount("n_of_m", config("requiredCount", 0), 5)).isEqualTo(1); + assertThat(WorkflowInstancePolicy.requiredCount("n_of_m", config("requiredCount", -3), 5)).isEqualTo(1); + // 没配 → 默认 1 + assertThat(WorkflowInstancePolicy.requiredCount("n_of_m", Map.of(), 5)).isEqualTo(1); + } + + @Test + @DisplayName("percentage:候选数 × 比例向上取整,同样夹紧") + void percentageCeils() { + // 5 × 0.5 = 2.5 → 3 + assertThat(WorkflowInstancePolicy.requiredCount("percentage", config("requiredRatio", 0.5), 5)) + .isEqualTo(3); + // 3 × 0.34 = 1.02 → 2(向上取整,不是四舍五入) + assertThat(WorkflowInstancePolicy.requiredCount("percentage", config("requiredRatio", 0.34), 3)) + .isEqualTo(2); + // 恰好整除 + assertThat(WorkflowInstancePolicy.requiredCount("percentage", config("requiredRatio", 0.5), 4)) + .isEqualTo(2); + // 比例 > 1 → 夹到候选数 + assertThat(WorkflowInstancePolicy.requiredCount("percentage", config("requiredRatio", 3), 4)) + .isEqualTo(4); + // 比例 0 → 夹到 1 + assertThat(WorkflowInstancePolicy.requiredCount("percentage", config("requiredRatio", 0), 4)) + .isEqualTo(1); + } + + @Test + @DisplayName("候选数为 0 时按 1 算:不能让节点零票通过") + void zeroCandidatesStillNeedsOne() { + assertThat(WorkflowInstancePolicy.requiredCount("all", Map.of(), 0)).isEqualTo(1); + assertThat(WorkflowInstancePolicy.requiredCount("serial", Map.of(), 0)).isEqualTo(1); + assertThat(WorkflowInstancePolicy.requiredCount("n_of_m", config("requiredCount", 5), 0)).isEqualTo(1); + } + + @Test + @DisplayName("配置写成字符串也能读(数据库 JSON 里数字可能是字符串)") + void acceptsNumericStrings() { + assertThat(WorkflowInstancePolicy.requiredCount("n_of_m", config("requiredCount", "3"), 5)).isEqualTo(3); + assertThat(WorkflowInstancePolicy.requiredCount("percentage", config("requiredRatio", "0.5"), 5)) + .isEqualTo(3); + } + + @Test + @DisplayName("配置为 null 或脏值不抛异常") + void toleratesNullAndDirtyConfig() { + assertThat(WorkflowInstancePolicy.requiredCount("n_of_m", null, 5)).isEqualTo(1); + assertThat(WorkflowInstancePolicy.requiredCount("n_of_m", config("requiredCount", "不是数字"), 5)) + .isEqualTo(1); + assertThat(WorkflowInstancePolicy.requiredCount("percentage", config("requiredRatio", "x"), 5)) + .isEqualTo(1); + } + } + + @Nested + @DisplayName("审批时限") + class DueMinutes { + + @Test + @DisplayName("小时 → 分钟,四舍五入") + void convertsHoursToMinutes() { + assertThat(WorkflowInstancePolicy.dueMinutesOf(1)).isEqualTo(60); + assertThat(WorkflowInstancePolicy.dueMinutesOf(2.5)).isEqualTo(150); + assertThat(WorkflowInstancePolicy.dueMinutesOf(0.5)).isEqualTo(30); + assertThat(WorkflowInstancePolicy.dueMinutesOf("3")).isEqualTo(180); + } + + @Test + @DisplayName("未配置 / 非正数 / 脏值 → null(不设应处理时间)") + void absentMeansNoDeadline() { + assertThat(WorkflowInstancePolicy.dueMinutesOf(null)).isNull(); + assertThat(WorkflowInstancePolicy.dueMinutesOf(0)).isNull(); + assertThat(WorkflowInstancePolicy.dueMinutesOf(-5)).isNull(); + assertThat(WorkflowInstancePolicy.dueMinutesOf("")).isNull(); + assertThat(WorkflowInstancePolicy.dueMinutesOf("待定")).isNull(); + } + } + + @Nested + @DisplayName("业务状态投影守卫") + class ProjectionGuard { + + @Test + @DisplayName("配置齐全 → 去更新;有版本列时要求一并自增") + void updateDecision() { + var withVersion = WorkflowInstancePolicy.decideProjection( + "b_expense", "b_id", "b_status", "1", "123", false, true); + assertThat(withVersion) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Update.class); + assertThat(((WorkflowInstancePolicy.ProjectionDecision.Update) withVersion).withRowVersion()).isTrue(); + + var withoutVersion = WorkflowInstancePolicy.decideProjection( + "b_expense", "b_id", "b_status", "1", "123", false, false); + assertThat(((WorkflowInstancePolicy.ProjectionDecision.Update) withoutVersion).withRowVersion()) + .isFalse(); + } + + @Test + @DisplayName("只读模块 / 没配状态字段 / 状态未映射 → 静默跳过") + void skipWhenNotConfigured() { + // 只读模块:b_save_table 为空 + assertThat(WorkflowInstancePolicy.decideProjection( + "", "b_id", "b_status", "1", "123", false, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Skip.class); + // 没配主键字段 + assertThat(WorkflowInstancePolicy.decideProjection( + "b_expense", "", "b_status", "1", "123", false, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Skip.class); + // 绑定没配状态字段 + assertThat(WorkflowInstancePolicy.decideProjection( + "b_expense", "b_id", "", "1", "123", false, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Skip.class); + // 该状态没映射到业务状态值 + assertThat(WorkflowInstancePolicy.decideProjection( + "b_expense", "b_id", "b_status", "", "123", false, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Skip.class); + } + + @Test + @DisplayName("表名 / 字段名不合法(要拼进 SQL)→ Illegal,调用方抛业务异常") + void illegalIdentifiers() { + // 注意:空串会先被前面的「只读模块」分支拦成 Skip,故不在此列 + for (String bad : new String[]{"b_expense; drop table x", "b-expense", "1table", "b expense"}) { + assertThat(WorkflowInstancePolicy.decideProjection( + bad, "b_id", "b_status", "1", "123", false, false)) + .as("表名 %s 应判为不合法", bad) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Illegal.class); + } + assertThat(WorkflowInstancePolicy.decideProjection( + "b_expense", "b_id)", "b_status", "1", "123", false, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Illegal.class); + assertThat(WorkflowInstancePolicy.decideProjection( + "b_expense", "b_id", "b_status] = 1 --", "1", "123", false, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Illegal.class); + } + + @Test + @DisplayName("空表名走 Skip 而不是 Illegal:只读模块是正常配置,不该报错") + void emptyTableIsSkipNotIllegal() { + assertThat(WorkflowInstancePolicy.decideProjection( + "", "b_id", "b_status", "1", "123", false, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Skip.class); + } + + @Test + @DisplayName("非字符键列 + 业务主键不是数字 → KeyMismatch(留痕但不回滚审批)") + void keyTypeMismatch() { + var decision = WorkflowInstancePolicy.decideProjection( + "b_expense", "b_id", "b_status", "1", "EXP-2026-001", false, true); + assertThat(decision).isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.KeyMismatch.class); + assertThat(((WorkflowInstancePolicy.ProjectionDecision.KeyMismatch) decision).reason()) + .contains("不是数字"); + } + + @Test + @DisplayName("字符键列可以接受任意字符串主键(雪花 bigint 之外的老编码)") + void textKeyAcceptsAnyString() { + assertThat(WorkflowInstancePolicy.decideProjection( + "b_expense", "b_id", "b_status", "1", "EXP-2026-001", true, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Update.class); + // 字符键 + 空主键也不属于类型不匹配,交给 0 行留痕处理 + assertThat(WorkflowInstancePolicy.decideProjection( + "b_expense", "b_id", "b_status", "1", "", true, false)) + .isInstanceOf(WorkflowInstancePolicy.ProjectionDecision.Update.class); + } + } + + @Nested + @DisplayName("投影影响行数") + class ProjectionOutcome { + + @Test + @DisplayName("恰好 1 行 → 无 payload(正常)") + void exactlyOneRowIsNormal() { + assertThat(WorkflowInstancePolicy.projectionOutcome( + 1, "b_expense", "b_id", "b_status", "123", true)).isNull(); + } + + @Test + @DisplayName("0 行 / 多行 → 留痕载荷,含行数、目标、业务主键与是否有版本列") + void otherCountsAreRecorded() { + for (int rows : new int[]{0, 2, 7}) { + Map payload = WorkflowInstancePolicy.projectionOutcome( + rows, "b_expense", "b_id", "b_status", "123", true); + assertThat(payload).isNotNull(); + assertThat(payload.get("reason").toString()).contains("影响行数=" + rows); + assertThat(payload).containsEntry("target", "b_expense.b_id / b_status") + .containsEntry("businessId", "123") + .containsEntry("hasVersionColumn", true); + } + } + } + + @Nested + @DisplayName("职责分离开关") + class SoD { + + private static final String INITIATOR = "u001"; + + @Test + @DisplayName("没配(缺省)→ 不排除发起人:allowInitiator 默认允许") + void absentMeansInitiatorAllowed() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(Map.of(), INITIATOR)).isEmpty(); + assertThat(WorkflowInstancePolicy.excludedFromConfig(null, INITIATOR)).isEmpty(); + assertThat(WorkflowInstancePolicy.excludedFromConfig(config("allowInitiator", null), INITIATOR)) + .isEmpty(); + } + + @Test + @DisplayName("显式 allowInitiator=true → 也不排除") + void explicitTrueDoesNotExclude() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(config("allowInitiator", true), INITIATOR)) + .isEmpty(); + } + + @Test + @DisplayName("显式 allowInitiator=false → 排除发起人(这就是三态的意义:false 与「没配」语义相反)") + void explicitFalseExcludes() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(config("allowInitiator", false), INITIATOR)) + .containsExactly(INITIATOR); + } + + @Test + @DisplayName("excludeCreator=true → 以发起人近似排除创建人") + void excludeCreatorApproximatesInitiator() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(config("excludeCreator", true), INITIATOR)) + .containsExactly(INITIATOR); + assertThat(WorkflowInstancePolicy.excludedFromConfig(config("excludeCreator", false), INITIATOR)) + .isEmpty(); + } + + @Test + @DisplayName("两个开关同时开也只排除一次(集合去重)") + void bothSwitchesExcludeOnce() { + Map both = new LinkedHashMap<>(); + both.put("allowInitiator", false); + both.put("excludeCreator", true); + assertThat(WorkflowInstancePolicy.excludedFromConfig(both, INITIATOR)).containsExactly(INITIATOR); + } + + @Test + @DisplayName("发起人为空时不排除空串(否则会把空编码并进排除集)") + void blankInitiatorNotExcluded() { + assertThat(WorkflowInstancePolicy.excludedFromConfig(config("allowInitiator", false), "")) + .isEmpty(); + assertThat(WorkflowInstancePolicy.excludedFromConfig(config("allowInitiator", false), null)) + .isEmpty(); + } + + @Test + @DisplayName("excludePreviousActors 是独立开关") + void previousActorsSwitch() { + assertThat(WorkflowInstancePolicy.excludesPreviousActors(config("excludePreviousActors", true))) + .isTrue(); + assertThat(WorkflowInstancePolicy.excludesPreviousActors(config("excludePreviousActors", false))) + .isFalse(); + assertThat(WorkflowInstancePolicy.excludesPreviousActors(Map.of())).isFalse(); + assertThat(WorkflowInstancePolicy.excludesPreviousActors(null)).isFalse(); + } + } + + @Nested + @DisplayName("三态开关与标识符判定") + class Flags { + + @Test + @DisplayName("configFlagOrNull:true/false/数字/字符串 都能识别") + void threeStateReading() { + assertThat(WorkflowInstancePolicy.configFlagOrNull(config("k", true), "k")).isTrue(); + assertThat(WorkflowInstancePolicy.configFlagOrNull(config("k", false), "k")).isFalse(); + assertThat(WorkflowInstancePolicy.configFlagOrNull(config("k", 1), "k")).isTrue(); + assertThat(WorkflowInstancePolicy.configFlagOrNull(config("k", 0), "k")).isFalse(); + assertThat(WorkflowInstancePolicy.configFlagOrNull(config("k", "true"), "k")).isTrue(); + assertThat(WorkflowInstancePolicy.configFlagOrNull(config("k", " FALSE "), "k")).isFalse(); + } + + @Test + @DisplayName("configFlagOrNull:缺失 / 无法识别 → null,而不是 false") + void unrecognizedIsNull() { + assertThat(WorkflowInstancePolicy.configFlagOrNull(Map.of(), "k")).isNull(); + assertThat(WorkflowInstancePolicy.configFlagOrNull(config("k", null), "k")).isNull(); + assertThat(WorkflowInstancePolicy.configFlagOrNull(config("k", "是"), "k")).isNull(); + assertThat(WorkflowInstancePolicy.configFlagOrNull(null, "k")).isNull(); + } + + @Test + @DisplayName("configFlag:宽松版,无法识别一律 false") + void lenientFlag() { + assertThat(WorkflowInstancePolicy.configFlag(config("k", "true"), "k")).isTrue(); + assertThat(WorkflowInstancePolicy.configFlag(config("k", "是"), "k")).isFalse(); + assertThat(WorkflowInstancePolicy.configFlag(Map.of(), "k")).isFalse(); + } + + @Test + @DisplayName("isDigits:只认纯数字串") + void digits() { + assertThat(WorkflowInstancePolicy.isDigits("123")).isTrue(); + assertThat(WorkflowInstancePolicy.isDigits("0")).isTrue(); + assertThat(WorkflowInstancePolicy.isDigits("12a")).isFalse(); + assertThat(WorkflowInstancePolicy.isDigits("-1")).isFalse(); + assertThat(WorkflowInstancePolicy.isDigits("1.5")).isFalse(); + assertThat(WorkflowInstancePolicy.isDigits("")).isFalse(); + assertThat(WorkflowInstancePolicy.isDigits(null)).isFalse(); + } + + @Test + @DisplayName("isSafeIdentifier:字母数字下划线,且不以数字开头") + void safeIdentifiers() { + assertThat(WorkflowInstancePolicy.isSafeIdentifier("b_expense")).isTrue(); + assertThat(WorkflowInstancePolicy.isSafeIdentifier("_tmp1")).isTrue(); + assertThat(WorkflowInstancePolicy.isSafeIdentifier("1table")).isFalse(); + assertThat(WorkflowInstancePolicy.isSafeIdentifier("b expense")).isFalse(); + assertThat(WorkflowInstancePolicy.isSafeIdentifier("b;drop")).isFalse(); + assertThat(WorkflowInstancePolicy.isSafeIdentifier("")).isFalse(); + assertThat(WorkflowInstancePolicy.isSafeIdentifier(null)).isFalse(); + } + } + + /* ---------- 辅助 ---------- */ + + private static Map config(String key, Object value) { + Map config = new LinkedHashMap<>(); + if (value != null) { + config.put(key, value); + } + return config; + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowInstanceRowTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowInstanceRowTests.java new file mode 100644 index 00000000..a77720d8 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowInstanceRowTests.java @@ -0,0 +1,79 @@ +package cn.g3soft.erp.service.workflow; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link WorkflowInstanceRow} 的内存版本推进。 + * + *

这条测试守的是 B1 的一个隐蔽前提:一个事务里可能对同一实例连续更新多次 + * (典型是 {@code activateUserTask} 先写摘要、随后 {@code failInstance} 改状态)。 + * 上一条 UPDATE 已经把库里的 {@code b_row_version} 加了 1,若内存快照不跟着推进, + * 第二次更新就会拿旧版本去比对,被自己的乐观锁判成「并发推进」而误报失败。 + */ +@DisplayName("工作流实例内存快照") +class WorkflowInstanceRowTests { + + @Test + @DisplayName("构造后各字段可读,版本为传入值") + void exposesConstructorValues() { + WorkflowInstanceRow row = newRow(1L); + assertThat(row.instanceId()).isEqualTo("inst-1"); + assertThat(row.definitionId()).isEqualTo("def-1"); + assertThat(row.versionId()).isEqualTo("ver-1"); + assertThat(row.bindingId()).isEqualTo("bind-1"); + assertThat(row.moduleId()).isEqualTo("mod-1"); + assertThat(row.businessId()).isEqualTo("biz-1"); + assertThat(row.businessNo()).isEqualTo("NO-0001"); + assertThat(row.initiatorId()).isEqualTo("u001"); + assertThat(row.initiatorDeptId()).isEqualTo("d001"); + assertThat(row.status()).isEqualTo("running"); + assertThat(row.rowVersion()).isEqualTo(1L); + } + + @Test + @DisplayName("版本可被推进,且读回的是最新值") + void rowVersionAdvances() { + WorkflowInstanceRow row = newRow(1L); + row.rowVersion(2L); + assertThat(row.rowVersion()).isEqualTo(2L); + row.rowVersion(3L); + assertThat(row.rowVersion()).isEqualTo(3L); + } + + @Test + @DisplayName("连续多次推进即「同事务多次更新」的版本轨迹") + void repeatedAdvanceMatchesRepeatedUpdates() { + WorkflowInstanceRow row = newRow(7L); + for (long expected = 8L; expected <= 11L; expected++) { + row.rowVersion(expected); + assertThat(row.rowVersion()).isEqualTo(expected); + } + } + + @Test + @DisplayName("除版本外其余字段是不可变的(没有 setter)") + void otherFieldsAreImmutable() throws Exception { + for (String name : new String[]{"instanceId", "definitionId", "versionId", "bindingId", + "moduleId", "businessId", "businessNo", "initiatorId", "initiatorDeptId", "status"}) { + String capitalized = Character.toUpperCase(name.charAt(0)) + name.substring(1); + boolean hasSetter = java.util.Arrays.stream(WorkflowInstanceRow.class.getDeclaredMethods()) + .anyMatch(m -> m.getName().equals(name) && m.getParameterCount() == 1); + // 只有 rowVersion 有单参 setter;同名的 0 参 getter 是允许的 + assertThat(hasSetter) + .as("字段 %s 不应有单参 setter(对应 getter %s)", name, capitalized) + .isFalse(); + } + boolean hasVersionSetter = java.util.Arrays.stream(WorkflowInstanceRow.class.getDeclaredMethods()) + .anyMatch(m -> m.getName().equals("rowVersion") && m.getParameterCount() == 1); + assertThat(hasVersionSetter).isTrue(); + } + + private static WorkflowInstanceRow newRow(long rowVersion) { + return new WorkflowInstanceRow( + "inst-1", "def-1", "ver-1", "bind-1", "mod-1", + "biz-1", "NO-0001", "u001", "d001", "running", rowVersion); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowJsonTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowJsonTests.java new file mode 100644 index 00000000..20ae6ce3 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowJsonTests.java @@ -0,0 +1,102 @@ +package cn.g3soft.erp.service.workflow; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@link WorkflowJson} 双入口的回归测试。 + * + *

这是 B3 缺陷的守卫线:原型把「配坏了」和「没配」都降级成空 Map, + * 于是脏条件被当成**无条件边**命中,静默走错分支。所以「严格入口必须抛」这条断言 + * 的价值不在于解析本身,而在于不让那次降级被重新引入。 + */ +@DisplayName("工作流 JSON 读取") +class WorkflowJsonTests { + + /* ---------- readMapOrNull:面向作者配置,配坏了必须抛 ---------- */ + + @Test + @DisplayName("严格:null / 空串 / 空白 → null,表示「没配」") + void strictTreatsAbsentAsNull() { + assertThat(WorkflowJson.readMapOrNull(null, "x")).isNull(); + assertThat(WorkflowJson.readMapOrNull("", "x")).isNull(); + assertThat(WorkflowJson.readMapOrNull(" ", "x")).isNull(); + assertThat(WorkflowJson.readMapOrNull("\t\r\n ", "x")).isNull(); + } + + @Test + @DisplayName("严格:合法对象 → Map,且内容可读") + void strictParsesObject() { + Map parsed = WorkflowJson.readMapOrNull("{\"field\":\"b_amount\"}", "x"); + assertThat(parsed).containsEntry("field", "b_amount"); + } + + @Test + @DisplayName("严格:JSON 合法但不是对象(数组 / 标量)→ 抛,不当成无条件") + void strictRejectsNonObject() { + assertThatThrownBy(() -> WorkflowJson.readMapOrNull("[1,2]", "edge.cond:e1")) + .isInstanceOf(WorkflowConfigException.class) + .hasMessageContaining("edge.cond:e1"); + assertThatThrownBy(() -> WorkflowJson.readMapOrNull("100", "edge.cond:e1")) + .isInstanceOf(WorkflowConfigException.class); + assertThatThrownBy(() -> WorkflowJson.readMapOrNull("\"文本\"", "edge.cond:e1")) + .isInstanceOf(WorkflowConfigException.class); + assertThatThrownBy(() -> WorkflowJson.readMapOrNull("null", "edge.cond:e1")) + .isInstanceOf(WorkflowConfigException.class); + } + + @Test + @DisplayName("严格:语法损坏 → 抛,绝不静默降级成空对象") + void strictRejectsBrokenJson() { + assertThatThrownBy(() -> WorkflowJson.readMapOrNull("{不是 JSON}", "binding.match:b1")) + .isInstanceOf(WorkflowConfigException.class) + .hasMessageContaining("binding.match:b1"); + assertThatThrownBy(() -> WorkflowJson.readMapOrNull("{\"a\":}", "binding.match:b1")) + .isInstanceOf(WorkflowConfigException.class); + assertThatThrownBy(() -> WorkflowJson.readMapOrNull("{", "binding.match:b1")) + .isInstanceOf(WorkflowConfigException.class); + } + + @Test + @DisplayName("严格:异常消息里带出错原文(超长时截断),便于定位") + void strictAbbreviatesLongText() { + String longBroken = "{" + "x".repeat(500); + assertThatThrownBy(() -> WorkflowJson.readMapOrNull(longBroken, "lbl")) + .isInstanceOf(WorkflowConfigException.class) + .hasMessageContaining("lbl") + .hasMessageContaining("…"); + } + + /* ---------- readMapOrEmpty:面向我们自己的列,坏了当空,不阻断流程 ---------- */ + + @Test + @DisplayName("宽松:null / 空白 / 损坏 / 非对象 一律空 Map,不抛") + void lenientNeverThrows() { + assertThat(WorkflowJson.readMapOrEmpty(null, "x")).isEmpty(); + assertThat(WorkflowJson.readMapOrEmpty(" ", "x")).isEmpty(); + assertThat(WorkflowJson.readMapOrEmpty("{不是 JSON}", "x")).isEmpty(); + assertThat(WorkflowJson.readMapOrEmpty("[1,2]", "x")).isEmpty(); + assertThat(WorkflowJson.readMapOrEmpty("\"文本\"", "x")).isEmpty(); + assertThat(WorkflowJson.readMapOrEmpty("", "x")).isEmpty(); + } + + @Test + @DisplayName("宽松:合法对象照常读出") + void lenientParsesObject() { + assertThat(WorkflowJson.readMapOrEmpty("{\"summary\":\"已撤回\"}", "x")) + .containsEntry("summary", "已撤回"); + } + + @Test + @DisplayName("宽松:返回的空 Map 不可变(调用方不该往里写)") + void lenientEmptyIsImmutable() { + Map empty = WorkflowJson.readMapOrEmpty(null, "x"); + assertThatThrownBy(() -> empty.put("k", "v")) + .isInstanceOf(UnsupportedOperationException.class); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowSerialTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowSerialTests.java new file mode 100644 index 00000000..bd337e72 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/workflow/WorkflowSerialTests.java @@ -0,0 +1,208 @@ +package cn.g3soft.erp.service.workflow; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 串行会签的任务生成与推进选择(A1 / §9.6)。 + * + *

这条链是原型里**完全不通**的功能:任务建出来之后 waitng 状态再也没有被翻回 pending, + * 节点永远凑不满票数。修法分两半 —— {@code taskSeeds} 保证「只有第一位是 pending」, + * {@code promoteNextSerialTask} 在每次表决后翻下一位。这里覆盖前半半段的生成规则, + * 以及推进时「取 (seq, id) 最小者」的选择规则(排序反了会变成后一位先审)。 + */ +@DisplayName("工作流串行会签") +class WorkflowSerialTests { + + /* ---------- 任务生成 ---------- */ + + @Test + @DisplayName("serial:全部候选都建任务,只有第一位是 pending,其余 waiting") + void serialOnlyFirstIsPending() { + List seeds = + WorkflowInstancePolicy.taskSeeds("serial", List.of("u001", "u002", "u003")); + + assertThat(seeds).hasSize(3); + assertThat(seeds).extracting(WorkflowInstancePolicy.TaskSeed::userId) + .containsExactly("u001", "u002", "u003"); + assertThat(seeds).extracting(WorkflowInstancePolicy.TaskSeed::status) + .containsExactly("pending", "waiting", "waiting"); + } + + @Test + @DisplayName("serial:序号按 10 递增,顺序即审批先后(b_seq 来源)") + void serialSeqIncrementsByTen() { + assertThat(WorkflowInstancePolicy.taskSeeds("serial", List.of("u001", "u002", "u003"))) + .extracting(WorkflowInstancePolicy.TaskSeed::seq) + .containsExactly(10, 20, 30); + } + + @Test + @DisplayName("single:只给第一名候选建任务(其余人不该看到这条待办)") + void singleOnlyFirstCandidate() { + List seeds = + WorkflowInstancePolicy.taskSeeds("single", List.of("u001", "u002", "u003")); + + assertThat(seeds).hasSize(1); + assertThat(seeds.get(0).userId()).isEqualTo("u001"); + assertThat(seeds.get(0).status()).isEqualTo("pending"); + assertThat(seeds.get(0).seq()).isEqualTo(10); + } + + @Test + @DisplayName("single 只有一个候选时同样只建一条") + void singleWithOneCandidate() { + assertThat(WorkflowInstancePolicy.taskSeeds("single", List.of("u001"))) + .singleElement() + .satisfies(seed -> { + assertThat(seed.userId()).isEqualTo("u001"); + assertThat(seed.status()).isEqualTo("pending"); + }); + } + + @Test + @DisplayName("any / all / n_of_m / percentage:全部候选同时 pending(并行)") + void parallelModesAllPending() { + List users = List.of("u001", "u002", "u003"); + for (String mode : List.of("any", "all", "n_of_m", "percentage")) { + assertThat(WorkflowInstancePolicy.taskSeeds(mode, users)) + .as("模式 %s 应全部 pending", mode) + .extracting(WorkflowInstancePolicy.TaskSeed::status) + .containsExactly("pending", "pending", "pending"); + } + } + + @Test + @DisplayName("模式为空 / null 按 single 处理") + void blankModeFallsBackToSingle() { + assertThat(WorkflowInstancePolicy.taskSeeds("", List.of("u001", "u002"))).hasSize(1); + assertThat(WorkflowInstancePolicy.taskSeeds(null, List.of("u001", "u002"))).hasSize(1); + assertThat(WorkflowInstancePolicy.taskSeeds(" ", List.of("u001", "u002"))).hasSize(1); + } + + @Test + @DisplayName("候选为空 → 不建任务(调用方在此之前已把实例置 error)") + void noCandidatesNoTasks() { + assertThat(WorkflowInstancePolicy.taskSeeds("serial", List.of())).isEmpty(); + assertThat(WorkflowInstancePolicy.taskSeeds("single", List.of())).isEmpty(); + assertThat(WorkflowInstancePolicy.taskSeeds("serial", null)).isEmpty(); + } + + @Test + @DisplayName("serial 单人:只有一条 pending,与 single 的区别是不会产生 waiting") + void serialWithSingleCandidate() { + List seeds = + WorkflowInstancePolicy.taskSeeds("serial", List.of("u001")); + assertThat(seeds).hasSize(1); + assertThat(seeds.get(0).status()).isEqualTo("pending"); + // 应签人数 = 候选数 = 1,一票即完成,无需推进下一位 + assertThat(WorkflowInstancePolicy.requiredCount("serial", java.util.Map.of(), seeds.size())).isEqualTo(1); + } + + @Test + @DisplayName("serial 的应签人数等于候选数:必须每个人都签") + void serialRequiredCountEqualsCandidates() { + List users = List.of("u001", "u002", "u003", "u004"); + assertThat(WorkflowInstancePolicy.requiredCount("serial", java.util.Map.of(), users.size())) + .isEqualTo(users.size()) + .isEqualTo(WorkflowInstancePolicy.taskSeeds("serial", users).size()); + } + + /* ---------- 推进选择 ---------- */ + + @Test + @DisplayName("推进:取 (seq, id) 最小的一条 waiting") + void picksSmallestSeq() { + List waiting = List.of( + new WorkflowInstancePolicy.TaskSeed("t-c", 30), + new WorkflowInstancePolicy.TaskSeed("t-a", 10), + new WorkflowInstancePolicy.TaskSeed("t-b", 20)); + + assertThat(WorkflowInstancePolicy.nextWaitingTask(waiting)).isEqualTo("t-a"); + } + + @Test + @DisplayName("推进:seq 相同时按 id 稳定兜底(不会随机选)") + void tiesBrokenById() { + List waiting = List.of( + new WorkflowInstancePolicy.TaskSeed("t-b", 10), + new WorkflowInstancePolicy.TaskSeed("t-a", 10)); + assertThat(WorkflowInstancePolicy.nextWaitingTask(waiting)).isEqualTo("t-a"); + } + + @Test + @DisplayName("推进:没有 waiting / 空列表 → null(调用方据此空转,不报错)") + void noWaitingReturnsNull() { + assertThat(WorkflowInstancePolicy.nextWaitingTask(List.of())).isNull(); + assertThat(WorkflowInstancePolicy.nextWaitingTask(null)).isNull(); + } + + @Test + @DisplayName("推进:跳过被职责分离排除的人(否则会把任务推给不该审的人)") + void skipsExcludedUsers() { + List waiting = List.of( + new WorkflowInstancePolicy.TaskSeed("u001", 10), + new WorkflowInstancePolicy.TaskSeed("u002", 20), + new WorkflowInstancePolicy.TaskSeed("u003", 30)); + + assertThat(WorkflowInstancePolicy.nextWaitingTask(waiting, Set.of("u001"))).isEqualTo("u002"); + assertThat(WorkflowInstancePolicy.nextWaitingTask(waiting, Set.of("u001", "u002"))) + .isEqualTo("u003"); + } + + @Test + @DisplayName("推进:所有人都被排除 → null(不硬推,留给运维加签)") + void allExcludedReturnsNull() { + List waiting = List.of( + new WorkflowInstancePolicy.TaskSeed("u001", 10), + new WorkflowInstancePolicy.TaskSeed("u002", 20)); + assertThat(WorkflowInstancePolicy.nextWaitingTask(waiting, Set.of("u001", "u002"))).isNull(); + } + + @Test + @DisplayName("推进:排除集为空 / null 等同于不排除") + void emptyExclusionIsNoOp() { + List waiting = List.of( + new WorkflowInstancePolicy.TaskSeed("u002", 20), + new WorkflowInstancePolicy.TaskSeed("u001", 10)); + assertThat(WorkflowInstancePolicy.nextWaitingTask(waiting, Set.of())).isEqualTo("u001"); + assertThat(WorkflowInstancePolicy.nextWaitingTask(waiting, null)).isEqualTo("u001"); + } + + @Test + @DisplayName("推进:脏数据(null 元素 / null id)被跳过而不是 NPE") + void toleratesDirtyEntries() { + List waiting = new java.util.ArrayList<>(); + waiting.add(null); + waiting.add(new WorkflowInstancePolicy.TaskSeed(null, 5)); + waiting.add(new WorkflowInstancePolicy.TaskSeed("t-ok", 20)); + + assertThat(WorkflowInstancePolicy.nextWaitingTask(waiting)).isEqualTo("t-ok"); + } + + @Test + @DisplayName("推进顺序与生成顺序一致:三位串行的完整轨迹是 u001 → u002 → u003") + void fullSerialTrajectory() { + List users = List.of("u001", "u002", "u003"); + List seeds = WorkflowInstancePolicy.taskSeeds("serial", users); + + // 初始:只有第一位 pending + assertThat(seeds.get(0).status()).isEqualTo("pending"); + + // 第一位通过后,waiting 集合里剩下后两位,推进应依次给出 u002、u003 + List remaining = seeds.subList(1, seeds.size()); + String second = WorkflowInstancePolicy.nextWaitingTask(remaining); + assertThat(second).isEqualTo("u002"); + + List afterSecond = seeds.subList(2, seeds.size()); + assertThat(WorkflowInstancePolicy.nextWaitingTask(afterSecond)).isEqualTo("u003"); + + // 全部通过后没有 waiting,节点完成而不是继续推进 + assertThat(WorkflowInstancePolicy.nextWaitingTask(List.of())).isNull(); + } +} diff --git a/code/g3soft-erp/sql/20_registration/13_table_test_menu.sql b/code/g3soft-erp/sql/20_registration/13_table_test_menu.sql new file mode 100644 index 00000000..bd71115d --- /dev/null +++ b/code/g3soft-erp/sql/20_registration/13_table_test_menu.sql @@ -0,0 +1,86 @@ +/* ============================================================================ + 表格测试菜单(s_menu)—— 根菜单项 + + 用途:把原「海运 → 订舱单」(/ocean/booking)整页搬迁为独立测试页后, + 在 s_menu 里挂一个**一级(根)菜单**指向它,与前端路由 + /table-test(见 web/src/router/index.ts 的 protectedRoutes)一一对应。 + + 背景:本仓库的侧栏菜单已改为**动态**(读 s_menu,对照 fms-vue)—— + 前端静态路由只声明「有哪些页面组件」,是否出现在侧栏、叫什么名字由 s_menu 决定。 + 因此新增页面必须同时做两件事: + 1. 加一条静态路由(前端代码) + 2. 在 s_menu 里录一条菜单(本脚本) + + 说明: + 1. 幂等:菜单按 b_id 判断存在性,重复运行无副作用。 + 2. 权限:本脚本默认**不建权限点**。侧栏可见性与路由进入由 menu.{菜单编码} 授权控制, + 而权限 store 对 g3soft 账号提权(一切放行),故用 g3soft 登录即可看到本菜单。 + 若要让普通账号也能看到,请另行执行本文件末尾「可选:给普通账号授权」那段 + (建 s_power(menu.table_test) + s_user_power 授权),否则非管理员账号的侧栏 + 会因白名单语义**不显示**该菜单(这是预期行为,不是 bug)。 + 3. 排序:b_xh = 1,让它排在「审批中心(5)」「基础资料(10)」「系统管理(10)」等之前, + 作为根菜单下的第一项。 + 4. b_parent_id 为 null 即根节点;b_depth = 0。 + 5. b_path 约定:节点编码前后均带 /,根级为 /table_test/(与 FMS 树结构规范一致)。 + ============================================================================ */ + +if not exists (select 1 from dbo.s_menu where b_id = 'table_test') +begin + insert into dbo.s_menu (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_menu_type, b_route, b_icon, b_xh, b_canuse) + values ('table_test', null, 0, '/table_test/', N'表格测试', 'menu.table_test', 'page', '/table-test', 'table', 1, 1); +end +GO + +/* ---------------------------------------------------------------------------- + 多语言(s_i18n):菜单名,默认语言为中文时写入中文值。 + 已存在同键同语言的行不覆盖(人工翻译优先)。 + ---------------------------------------------------------------------------- */ + +declare @i18n table (k varchar(150) primary key, v nvarchar(1000) not null); +insert into @i18n (k, v) values + ('menu.table_test', N'表格测试'); + +insert into dbo.s_i18n (b_key, b_locale, b_value, b_canuse) +select i.k, t.b_id, i.v, 1 +from @i18n i + cross join (select b_id from dbo.s_i18n_type where b_default = 1) t +where not exists (select 1 from dbo.s_i18n x where x.b_key = i.k and x.b_locale = t.b_id); +GO + +/* ---------------------------------------------------------------------------- + 自检 + ---------------------------------------------------------------------------- */ + +select '菜单' as 项, count(*) as 行数 from dbo.s_menu where b_id = 'table_test' +union all +select '多语言', count(*) from dbo.s_i18n where b_key = 'menu.table_test'; +GO + +/* ============================================================================ + 可选:给普通账号授权(默认注释掉,按需启用) + + 权限点是白名单语义:s_power 里没有 menu.table_test、或用户没被授权, + 侧栏就不显示该菜单、路由守卫也会拦到 /403。 + g3soft 超管不受此限(前后端都对其提权),所以调试时可先用 g3soft 登录。 + + 启用前请把 'demo_zhangwei' 换成目标账号;若该机构库尚未建 s_power / + s_user_power 表,请先建表再执行。 + ============================================================================ */ + +/* +-- 1) 权限点:menu.table_test +if not exists (select 1 from dbo.s_power where b_id = 'menu.table_test') +begin + insert into dbo.s_power (b_id, b_name, b_type, b_canuse, b_xh, b_object_type, b_object_id, b_action, b_i18n) + values ('menu.table_test', N'表格测试', 'menu', 1, 10, 'menu', 'table_test', 'access', 'menu.table_test'); +end +GO + +-- 2) 授权:把权限点授给指定账号 +if not exists (select 1 from dbo.s_user_power where b_user_id = 'demo_zhangwei' and b_power_id = 'menu.table_test') +begin + insert into dbo.s_user_power (b_user_id, b_power_id, b_canuse, b_created_by, b_created_at) + values ('demo_zhangwei', 'menu.table_test', 1, 'g3soft', sysutcdatetime()); +end +GO +*/ diff --git a/code/g3soft-erp/sql/20_registration/14_dashboard_menu.sql b/code/g3soft-erp/sql/20_registration/14_dashboard_menu.sql new file mode 100644 index 00000000..18b49d19 --- /dev/null +++ b/code/g3soft-erp/sql/20_registration/14_dashboard_menu.sql @@ -0,0 +1,39 @@ +/* ============================================================================ + 首页菜单(s_menu)—— 根菜单项 + + 用途:登录后的默认落地页(前端 router 的 HOME_PATH = /dashboard)。 + 本次与原「表格测试」菜单一起补上:页面路由必须先存在,菜单才点得通。 + + 说明: + 1. 幂等:菜单按 b_id 判断存在性,重复运行无副作用。 + 2. 排序:b_xh = 0,排在「表格测试(1)」之前,作为根菜单第一项。 + 3. b_parent_id 为 null 即根节点;b_depth = 0; + b_path 约定节点编码前后均带 /,根级为 /dashboard/。 + 4. 权限:不建权限点。g3soft 超管提权可见;普通账号需另配 + s_power(menu.dashboard) + s_user_power,做法见 13_table_test_menu.sql 末尾注释。 + ============================================================================ */ + +if not exists (select 1 from dbo.s_menu where b_id = 'dashboard') +begin + insert into dbo.s_menu (b_id, b_parent_id, b_depth, b_path, b_name, b_i18n, b_menu_type, b_route, b_icon, b_xh, b_canuse) + values ('dashboard', null, 0, '/dashboard/', N'首页', 'menu.dashboard', 'page', '/dashboard', 'layout-dashboard', 0, 1); +end +GO + +/* ---------------------------------------------------------------------------- + 多语言(s_i18n):菜单名。已存在同键同语言的行不覆盖(人工翻译优先)。 + ---------------------------------------------------------------------------- */ + +declare @i18n table (k varchar(150) primary key, v nvarchar(1000) not null); +insert into @i18n (k, v) values + ('menu.dashboard', N'首页'); + +insert into dbo.s_i18n (b_key, b_locale, b_value, b_canuse) +select i.k, t.b_id, i.v, 1 +from @i18n i + cross join (select b_id from dbo.s_i18n_type where b_default = 1) t +where not exists (select 1 from dbo.s_i18n x where x.b_key = i.k and x.b_locale = t.b_id); +GO + +select '菜单' as 项, count(*) as 行数 from dbo.s_menu where b_id = 'dashboard'; +GO diff --git a/code/g3soft-erp/sql/README.md b/code/g3soft-erp/sql/README.md index a8bbeb3b..4763fec1 100644 --- a/code/g3soft-erp/sql/README.md +++ b/code/g3soft-erp/sql/README.md @@ -20,6 +20,8 @@ | 4 | `20_registration/10_fms_workflow_module.sql` | 4 个只读视图 + `s_module` / `s_field` / `s_module_schema` / `s_menu_module` 注册 | 是(先删后建) | | 5 | `20_registration/11_fms_workflow_menu.sql` | 流程管理 / 审批中心菜单 + `s_i18n` | 是 | | 6 | `20_registration/12_fms_workflow_power.sql` | 菜单权限点 + 动作权限点 + 授予 `g3soft` | 是 | +| 6.5 | `20_registration/13_table_test_menu.sql` | 表格测试根菜单(`/table-test`)+ `s_i18n` | 是 | +| 6.6 | `20_registration/14_dashboard_menu.sql` | 首页根菜单(`/dashboard`)+ `s_i18n` | 是 | | 7 | `30_patches/01_fms_workflow_index_patch.sql` | 补充索引(见下) | 是 | | 8 | `90_mock/01_fms_workflow_mock.sql` | **示例**流程定义 `expense_approval`(14 节点 / 17 连线 / 13 审批人规则) | 否 | | 9 | `90_mock/02_fms_workflow_runtime_mock.sql` | **示例**运行数据:演示机构/用户 + 6 个实例 | 否 | @@ -71,6 +73,42 @@ select distinct b_module_id from dbo.wf_binding where b_canuse = 1 - **审计探写**:`WorkflowAuditWriter` 每事务探测 `s_log_audit` 是否存在;不存在时写一条 `wf_outbox(audit.skipped)` 并继续。**库里没有审计表不算故障**,不会阻断审批。 +## 菜单现在是动态的(第 6.5 步的背景) + +前端侧栏菜单已改为**动态**:读 `s_menu` 表构建菜单树(对照 fms-vue 的 `stores/menus.js` ++ `AppSidebar`),不再使用前端常量菜单。因此**新增页面必须两步都做**: + +1. 在前端加一条静态路由(`web/src/router/index.ts`,数据库存不了 Vue 组件); +2. 在 `s_menu` 里录一条菜单(决定是否出现在侧栏、叫什么、排第几)。 + +`13_table_test_menu.sql` 就是第 2 步的示例:把「表格测试」页挂成**根菜单**,路由 `/table-test`。 + +**权限是白名单语义**:`s_power` 里没有 `menu.{菜单编码}`、或用户未被授权,侧栏就**不显示**该菜单、 +路由守卫也会拦到 `/403`。`g3soft` 超管前后端都提权、不受此限,调试时先用它登录最省事; +要给普通账号开放,用该脚本末尾注释掉的「可选:给普通账号授权」那段。 + +### ⚠️ 菜单与路由必须成对,否则点进去是 404 + +**`s_menu` 里有 `b_route` 的每一条页面菜单,前端都必须有一条 path 完全相同的静态路由。** +少一条,点击该菜单就会落到 router 末尾的 `/:pathMatch(.*)*` 通配路由 → 显示 404 缺省页。 + +实测过的坑:只给已实现的页面建路由、其余菜单不建,会表现为「**菜单点进去全是 404**」—— +而 Vite dev server 对任意路径都返回 HTTP 200(SPA fallback),所以浏览器网络面板里 +**看不到 404 状态码**,容易误判成后端或代理问题。真正的判据是**路由是否匹配**,不是 HTTP 状态。 + +当前 `s_menu` 的 13 条页面菜单与前端路由已逐条对齐: + +| s_menu `b_route` | 页面 | 状态 | +|---|---|---| +| `/dashboard` | 首页 | 占位(`views/dashboard/index.vue`) | +| `/table-test` | 表格测试 | **已实现**(G3Table 完整范例) | +| `/approval/todo`、`/initiated`、`/done`、`/cc` | 审批中心 | 占位 | +| `/base/othercompany`、`/contact`、`/otherdata` | 基础资料 | 占位 | +| `/system/user`、`/system/workflow`、`/system/log/login`、`/system/log/audit` | 系统管理 | 占位 | + +占位页统一复用 `web/src/components/PlaceholderPage.vue`:把真实页面写进对应视图文件即可, +**路由与菜单都不用再改**。 + ## 补充索引说明(第 7 步) | 索引 | 解决什么 | diff --git a/code/g3soft-erp/web/src/components/DomainPlaceholder.vue b/code/g3soft-erp/web/src/components/DomainPlaceholder.vue deleted file mode 100644 index cd7da122..00000000 --- a/code/g3soft-erp/web/src/components/DomainPlaceholder.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - diff --git a/code/g3soft-erp/web/src/components/PlaceholderPage.vue b/code/g3soft-erp/web/src/components/PlaceholderPage.vue new file mode 100644 index 00000000..918a44a2 --- /dev/null +++ b/code/g3soft-erp/web/src/components/PlaceholderPage.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/code/g3soft-erp/web/src/constants/menu.ts b/code/g3soft-erp/web/src/constants/menu.ts deleted file mode 100644 index a3a0383a..00000000 --- a/code/g3soft-erp/web/src/constants/menu.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { MenuItem } from '@/types/menu' - -/** - * 常量菜单 —— 路由与菜单的唯一数据源。 - * - * 路由(router/modules/*.ts)与侧栏菜单都从这里读取,新增业务域只改这一处。 - * 后端下发动态菜单接入后,本文件退化为「无权限时的兜底」或直接删除。 - * - * 结构说明:有 children 的节点是「分组」,本身不跳转;叶子节点才对应一个路由。 - * 分组的 path 仍保留,用于「点父级直接进第一个子页」的兜底与激活态判断。 - */ -export const menuItems: MenuItem[] = [ - { - key: 'ocean', - title: '海运', - path: '/ocean', - icon: 'Ship', - children: [ - { key: 'ocean-booking', title: '订舱单', path: '/ocean/booking' }, - { key: 'ocean-bill', title: '提单管理', path: '/ocean/bill' }, - { key: 'ocean-schedule', title: '船期查询', path: '/ocean/schedule' }, - ], - }, - { - key: 'air', - title: '空运', - path: '/air', - icon: 'Plane', - children: [ - { key: 'air-booking', title: '空运委托', path: '/air/booking' }, - { key: 'air-awb', title: '运单管理', path: '/air/awb' }, - ], - }, - { - key: 'container', - title: '箱管', - path: '/container', - icon: 'Container', - children: [ - { key: 'container-ledger', title: '集装箱台账', path: '/container/ledger' }, - { key: 'container-tracking', title: '箱动态跟踪', path: '/container/tracking' }, - ], - }, - { - key: 'finance', - title: '财务', - path: '/finance', - icon: 'Wallet', - children: [ - { key: 'finance-receivable', title: '应收费用', path: '/finance/receivable' }, - { key: 'finance-payable', title: '应付费用', path: '/finance/payable' }, - { key: 'finance-statement', title: '对账单', path: '/finance/statement' }, - { key: 'finance-invoice', title: '发票管理', path: '/finance/invoice' }, - ], - }, - { - key: 'crm', - title: 'CRM', - path: '/crm', - icon: 'Users', - children: [ - { key: 'crm-customer', title: '客户档案', path: '/crm/customer' }, - { key: 'crm-lead', title: '销售线索', path: '/crm/lead' }, - ], - }, - { - key: 'system', - title: '系统管理', - path: '/system', - icon: 'Settings', - children: [ - { key: 'system-user', title: '用户管理', path: '/system/user' }, - { key: 'system-role', title: '角色权限', path: '/system/role' }, - { key: 'system-menu', title: '菜单配置', path: '/system/menu' }, - { key: 'system-keepalive-test', title: 'keep-alive 测试', path: '/system/keepalive-test' }, - ], - }, -] diff --git a/code/g3soft-erp/web/src/layouts/LayoutHeader.vue b/code/g3soft-erp/web/src/layouts/LayoutHeader.vue index b9e1c5a1..568727d4 100644 --- a/code/g3soft-erp/web/src/layouts/LayoutHeader.vue +++ b/code/g3soft-erp/web/src/layouts/LayoutHeader.vue @@ -15,7 +15,7 @@ import { User, } from '@lucide/vue' import { G3Message } from '@g3soft/ui' -import { useUserStore } from '@/stores' +import { usePermissionStore, useUserStore } from '@/stores' import { usePreferencesStore } from '@/preferences' import { useFullscreen } from '@/composables/useFullscreen' import PreferencesDrawer from '@/preferences/components/PreferencesDrawer.vue' @@ -23,14 +23,14 @@ import PreferencesDrawer from '@/preferences/components/PreferencesDrawer.vue' const route = useRoute() const router = useRouter() const userStore = useUserStore() +const permissionStore = usePermissionStore() const prefStore = usePreferencesStore() const pref = computed(() => prefStore.preferences) const pageTitle = computed(() => (route.meta.title as string | undefined) ?? '') -const displayName = computed( - () => userStore.userInfo?.realName || userStore.userInfo?.userName || '未登录', -) +/** 显示名取真实登录用户(姓名优先,退回账号) */ +const displayName = computed(() => userStore.displayName) /** 偏好设置抽屉开关 */ const preferencesOpen = ref(false) @@ -59,14 +59,18 @@ function toggleSidebar(): void { /** * 退出登录。 * - * 对照 vben:登出走 authStore.logout() → resetAllStores(), - * 把用户/权限等 store 全部 $reset()。本仓库用户模块尚未落地, - * 所以这里做当前能做的事:清会话(userStore.clear:清 userInfo + localStorage 的 token)。 + * ⚠️ 后端【没有】登出接口(fms 同样没有):服务端会话靠「下次登录覆盖 sessionId」 + * 自然失效(ActiveSessionRegistry.activate)。所以这里是纯前端行为, + * 与 fms-vue 的 AppTopbar.logout 一致: * - * TODO 用户模块落地后补:调登出接口、清权限码、销毁动态路由。 + * 1. clearSession —— 清 token + 用户详情(刻意保留机构码/账号供登录页回显) + * 2. 清权限缓存 —— 否则切账号后仍带着上一个账号的权限判定 + * 3. 用 router.replace 而非 push —— 避免用户按后退又回到已退出的页面 */ function logout(): void { - userStore.clear() + userStore.clearSession() + permissionStore.clear() + G3Message.info('已退出登录') router.replace('/login') } diff --git a/code/g3soft-erp/web/src/layouts/LayoutHeaderMenu.vue b/code/g3soft-erp/web/src/layouts/LayoutHeaderMenu.vue index d4706c72..3af99fda 100644 --- a/code/g3soft-erp/web/src/layouts/LayoutHeaderMenu.vue +++ b/code/g3soft-erp/web/src/layouts/LayoutHeaderMenu.vue @@ -5,8 +5,8 @@ import { ChevronDown } from '@lucide/vue' import { G3Dropdown } from '@g3soft/ui' import type { DropdownItemData } from '@g3soft/ui' import { usePreferencesStore } from '@/preferences' -import { findMenuItemByPath, findParentKeyByPath } from '@/router' -import { menuItems } from '@/constants/menu' +import { buildMenuItems, findMenuItemByPath, findParentKeyByPath } from '@/router' +import { useMenuStore } from '@/stores' import { getMenuIcon } from './icons' import type { MenuItem } from '@/types/menu' @@ -23,6 +23,7 @@ const route = useRoute() const router = useRouter() const prefStore = usePreferencesStore() const pref = computed(() => prefStore.preferences) +const menuStore = useMenuStore() /** * 把菜单节点递归映射成 DropdownItemData。 @@ -78,7 +79,7 @@ function isActive(key: string): boolean { * 「有无子菜单」两种项的尺寸、hover、选中样式完全同源,不会各写一套。 */ const items = computed(() => - menuItems + buildMenuItems(menuStore.visibleMenus) .filter((item) => !item.hidden) .map((item) => ({ ...item, diff --git a/code/g3soft-erp/web/src/layouts/LayoutMixedColumn.vue b/code/g3soft-erp/web/src/layouts/LayoutMixedColumn.vue index 2278a0fc..48cd56ad 100644 --- a/code/g3soft-erp/web/src/layouts/LayoutMixedColumn.vue +++ b/code/g3soft-erp/web/src/layouts/LayoutMixedColumn.vue @@ -19,8 +19,26 @@ import { useMixedMenu } from './useMixedMenu' const prefStore = usePreferencesStore() const pref = computed(() => prefStore.preferences) -const { columnItems, isGroupOpen, toggleGroup, isLeafActive, isGroupActive, isLeafOnly } = - useMixedMenu() +const { + columnItems, + isGroupOpen, + toggleGroup, + isLeafActive, + isGroupActive, + isLeafOnly, + selectPath, +} = useMixedMenu() + +/** + * 叶子点击:同步写入乐观选中(高亮立刻跟随点击,不等异步路由落地)。 + * 只在「普通左键点击」时写:修饰键/中键是浏览器语义的「新标签/新窗口打开」, + * 当前页并不会跳转,此时不该改当前页的高亮。 + * (不判 event.defaultPrevented —— RouterLink 的 onClick 先于本回合并会 preventDefault。) + */ +function onLeafClick(path: string, event: MouseEvent): void { + if (event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return + selectPath(path) +} const columnDark = computed(() => pref.value.theme.semiDarkSidebar || prefStore.isDark) @@ -78,6 +96,7 @@ const showLogoText = computed( class="layout-mixed-column__item is-child" :class="{ 'is-active': isLeafActive(child.path) }" :to="child.path" + @click="onLeafClick(child.path, $event)" > {{ child.title }} @@ -90,6 +109,7 @@ const showLogoText = computed( class="layout-mixed-column__item" :class="{ 'is-active': isLeafActive(node.path) }" :to="node.path" + @click="onLeafClick(node.path, $event)" > {{ node.title }} diff --git a/code/g3soft-erp/web/src/layouts/LayoutSidebar.vue b/code/g3soft-erp/web/src/layouts/LayoutSidebar.vue index 77e35d54..fa520abf 100644 --- a/code/g3soft-erp/web/src/layouts/LayoutSidebar.vue +++ b/code/g3soft-erp/web/src/layouts/LayoutSidebar.vue @@ -6,7 +6,8 @@ import { useSidebarMenu } from './useSidebarMenu' import { usePreferencesStore } from '@/preferences' import SidebarMenuItem from './SidebarMenuItem.vue' -const { items, isLeafActive, isGroupActive, isGroupOpen, toggleGroup } = useSidebarMenu() +const { items, isLeafActive, isGroupActive, isGroupOpen, toggleGroup, selectPath } = + useSidebarMenu() const prefStore = usePreferencesStore() const pref = computed(() => prefStore.preferences) @@ -101,6 +102,8 @@ provide('sidebar-menu', { isGroupActive, isGroupOpen, toggleGroup, + // 叶子点击时同步置为选中(乐观选中),避免快速连点的选中闪烁 + selectPath, }) diff --git a/code/g3soft-erp/web/src/layouts/LayoutTabbar.vue b/code/g3soft-erp/web/src/layouts/LayoutTabbar.vue index 374ef462..02d54989 100644 --- a/code/g3soft-erp/web/src/layouts/LayoutTabbar.vue +++ b/code/g3soft-erp/web/src/layouts/LayoutTabbar.vue @@ -25,21 +25,41 @@ const pref = computed(() => prefStore.preferences) /** 是否 chrome 风格 */ const isChrome = computed(() => pref.value.tabbar.styleType === 'chrome') +/** + * 视觉激活的页签 key(乐观)。 + * + * 不直接用 route.fullPath 做高亮:路由是异步的(懒加载 import + 守卫), + * 快速连点页签时,中间那次导航可能先落地,于是「被离开的那个页签」短暂亮起又熄灭 + * (没→有→没)。这里在点击时同步写入,路由落地后由 watch 校正,高亮只跟随最后一次点击。 + */ +const selectedKey = ref(route.fullPath) + +// 路由落地 → 校正乐观选中(覆盖前进/后退、关闭页签后的跳转、程序化跳转) +watch( + () => route.fullPath, + (key) => { + selectedKey.value = key + }, +) + function isActive(tab: TabItem): boolean { - return tab.key === route.fullPath + return tab.key === selectedKey.value } function goTo(tab: TabItem): void { - if (!isActive(tab)) { - router.push(tab.fullPath) - } + if (tab.key === selectedKey.value) return + // 先同步置为激活(乐观),再跳转 + selectedKey.value = tab.key + router.push(tab.fullPath) } function closeTab(tab: TabItem): void { + const wasActive = tab.key === selectedKey.value const next = appStore.removeTab(tab.key) - // 关闭的是当前页签时,跳到相邻页签 - if (isActive(tab) && next) { + // 关闭的是当前页签时,跳到相邻页签(并同步乐观选中,避免出现"无高亮"的空档) + if (wasActive && next) { + selectedKey.value = next.key router.push(next.fullPath) } } @@ -251,9 +271,11 @@ watch( /* ---------------- 页签操作菜单(刷新/关闭/关闭其他/关闭左侧/关闭右侧/关闭全部) ---------------- */ -/** 批量操作后:若当前路由页签已被关闭,跳转到 fallback */ +/** 批量操作后:若当前激活页签已被关闭,跳转到 fallback */ function fallbackTo(key: string): void { - if (!appStore.tabs.some((item) => item.key === route.fullPath)) { + if (!appStore.tabs.some((item) => item.key === selectedKey.value)) { + // 当前激活页签已被批量关闭 → 跳到 fallback(同步乐观选中) + selectedKey.value = key router.push(key) } } diff --git a/code/g3soft-erp/web/src/layouts/SidebarMenuItem.vue b/code/g3soft-erp/web/src/layouts/SidebarMenuItem.vue index 36656fe7..d1bc1db5 100644 --- a/code/g3soft-erp/web/src/layouts/SidebarMenuItem.vue +++ b/code/g3soft-erp/web/src/layouts/SidebarMenuItem.vue @@ -34,8 +34,21 @@ const ctx = inject<{ isGroupActive: (key: string) => boolean isGroupOpen: (key: string) => boolean toggleGroup: (key: string) => void + selectPath: (path: string) => void }>('sidebar-menu')! +/** + * 叶子点击:同步写入乐观选中(高亮立刻跟随点击,不等异步路由落地)。 + * + * 只在「普通左键点击」时写:修饰键/中键是浏览器语义的「新标签/新窗口打开」, + * 当前页并不会跳转,此时不该改当前页的高亮。 + * (不判 event.defaultPrevented —— RouterLink 的 onClick 先于本回合并会 preventDefault。) + */ +function onLeafClick(path: string, event: MouseEvent): void { + if (event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return + ctx.selectPath(path) +} + /** 解包后的折叠状态(响应式,模板里可直接用)。 */ const collapsed = computed(() => unref(ctx.collapsed)) @@ -64,6 +77,8 @@ function toDropdownItems(nodes: SidebarMenuNode[]): DropdownItemData[] { item.selected = route.path === node.path item.onClick = () => { + // 折叠态浮层同理:先同步乐观选中,再跳转 + ctx.selectPath(node.path) if (route.path !== node.path) router.push(node.path) } return item @@ -183,6 +198,7 @@ function onExpandLeave(el: Element): void { :style="{ '--sidebar-level': level }" :to="item.path" :title="collapsed ? item.title : undefined" + @click="onLeafClick(item.path, $event)" > {{ item.title }} diff --git a/code/g3soft-erp/web/src/layouts/icons.ts b/code/g3soft-erp/web/src/layouts/icons.ts index e9328877..13dfd7cb 100644 --- a/code/g3soft-erp/web/src/layouts/icons.ts +++ b/code/g3soft-erp/web/src/layouts/icons.ts @@ -1,21 +1,81 @@ import type { Component } from 'vue' -import { Container, LayoutDashboard, Plane, Settings, Ship, Users, Wallet } from '@lucide/vue' +import { + Building2, + CircleCheck, + Container, + Database, + FileText, + LayoutDashboard, + ListChecks, + Package, + Plane, + Settings, + Ship, + Table, + Users, + Wallet, +} from '@lucide/vue' /** * 菜单图标注册表。 - * 菜单常量里只写图标名(字符串),在此映射到具体组件—— - * 这样 constants/menu.ts 不必 import 任何组件,保持纯数据。 + * + * s_menu.b_icon 里存的是**图标名字符串**(如 `settings` / `database` / `circle-check`), + * 在此映射到 lucide 组件 —— 数据库只存名字,不存组件,前端换图标库不必洗数据。 + * + * ⚠️ 现实数据是 **kebab-case**(少数历史数据可能是 PascalCase), + * 因此查表时先按原名、再按归一化名(去连字符、首字母大写)各查一次。 + * 只认一种写法会让另一批菜单**静默丢失图标**(getMenuIcon 返回 undefined,不报错)。 */ const iconMap: Record = { + // kebab-case:与 s_menu 实际存量数据一致 + 'circle-check': CircleCheck, + database: Database, + settings: Settings, + users: Users, + ship: Ship, + plane: Plane, + container: Container, + package: Package, + wallet: Wallet, + table: Table, + 'list-checks': ListChecks, + 'building-2': Building2, + 'file-text': FileText, + 'layout-dashboard': LayoutDashboard, + // PascalCase:兼容早期常量菜单写下的名字 + CircleCheck, + Database, + Settings, + Users, Ship, Plane, Container, + Package, Wallet, - Users, - Settings, + Table, + ListChecks, + Building2, + FileText, LayoutDashboard, } +/** 把 `circle-check` 归一化成 `CircleCheck`;已是 PascalCase 的原样返回。 */ +function toPascalCase(name: string): string { + return name + .split('-') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') +} + +/** + * 按图标名取组件。 + * + * @param name s_menu.b_icon 的原始值(kebab-case 或 PascalCase) + * @returns 图标组件;未登记的名字返回 undefined(不抛错,菜单照常渲染只是没图标) + */ export function getMenuIcon(name?: string): Component | undefined { - return name ? iconMap[name] : undefined + if (!name) return undefined + const key = String(name).trim() + return iconMap[key] ?? iconMap[toPascalCase(key)] } diff --git a/code/g3soft-erp/web/src/layouts/useMixedMenu.ts b/code/g3soft-erp/web/src/layouts/useMixedMenu.ts index 144e209c..087402f8 100644 --- a/code/g3soft-erp/web/src/layouts/useMixedMenu.ts +++ b/code/g3soft-erp/web/src/layouts/useMixedMenu.ts @@ -1,7 +1,7 @@ import { computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { menuItems } from '@/constants/menu' -import { findParentKeysByPath } from '@/router' +import { buildMenuItems, findParentKeysByPath } from '@/router' +import { useMenuStore } from '@/stores' import { getMenuIcon } from './icons' import type { MenuItem, SidebarMenuNode } from '@/types/menu' @@ -47,6 +47,35 @@ function hasActiveLeaf(list: SidebarMenuNode[], isActive: (path: string) => bool ) } +/** + * 视觉选中的叶子路径(乐观),**模块级共享**。 + * + * 为什么放在模块作用域而不是 composable 内: + * 双列布局下 LayoutMixedRail(第一列)与 LayoutMixedColumn(第二列)是**两个兄弟组件**, + * 各自调用 useMixedMenu()。它们必须看到同一份「当前选中」—— 点第一列切分组要立刻高亮 + * 该列并刷新第二列,点第二列叶子要立刻高亮该叶子。若各持一份组件内 ref,两列会各亮各的。 + * + * 与 useSidebarMenu 的 selectedPath 同理:高亮不直接读异步的 route.path,点击时同步写入, + * 路由落地后由各实例的 watch 校正,避免快速连点出现「没→有→没」闪烁。 + * 初始为空串,由 useMixedMenu 里的 watch(immediate) 在挂载时对齐到当前路由。 + */ +const selectedPath = ref('') + +/** 点击叶子时同步置为选中(乐观选中)。 */ +function selectPath(path: string): void { + selectedPath.value = path +} + +/** 叶子是否激活:精确相等或前缀匹配(兼容详情页)。与 useSidebarMenu 同口径。 */ +function isLeafActive(path: string): boolean { + return selectedPath.value === path || selectedPath.value.startsWith(`${path}/`) +} + +/** 分组下(含任意深度)是否有激活叶子。 */ +function isGroupActive(node: SidebarMenuNode): boolean { + return hasActiveLeaf(node.children ?? [], isLeafActive) +} + /** * 双列菜单(layout = 'sidebar-mixed-nav')的核心逻辑。 * @@ -69,9 +98,10 @@ function hasActiveLeaf(list: SidebarMenuNode[], isActive: (path: string) => bool export function useMixedMenu() { const route = useRoute() const router = useRouter() + const menuStore = useMenuStore() - /** 完整菜单树(含 children) */ - const tree = computed(() => mapTree(menuItems)) + /** 完整菜单树(含 children)—— 来自 s_menu 按权限裁剪后的动态菜单 */ + const tree = computed(() => mapTree(buildMenuItems(menuStore.visibleMenus))) /** * 当前路由所属的顶级分组 key。 @@ -80,11 +110,11 @@ export function useMixedMenu() { * 若当前路径本身就是某个顶级项(无 children),则取它自己。 */ const activeRootKey = computed(() => { - const parentKeys = findParentKeysByPath(route.path) + const parentKeys = findParentKeysByPath(selectedPath.value) if (parentKeys.length) return parentKeys[0] // 顶级叶子(没有 children 的一级项):直接取自身 key - const self = tree.value.find((item) => item.path === route.path) + const self = tree.value.find((item) => item.path === selectedPath.value) return self?.key ?? '' }) @@ -113,11 +143,15 @@ export function useMixedMenu() { * - 纯叶子 → 直接跳转; * - 有子项 → 切到该分组(跳它第一个叶子,或它自身路径)。 * - * 注意:这里不维护「当前选中列」状态 —— 第二列内容由 activeRootKey 从路由推导, + * 注意:这里不维护「当前选中列」状态 —— 第二列内容由 activeRootKey 从路由(乐观选中)推导, * 所以跳转后第二列自然就对了,且刷新/前进后退也一致。 + * + * 无论是否真的发起跳转,都先同步写入乐观选中:这样第一列立刻高亮、第二列立刻切到该分组, + * 不必等异步路由落地(否则快速连点会看到选中态跟着导航闪)。 */ function selectRoot(item: SidebarMenuNode): void { if (isLeafOnly(item)) { + selectPath(item.path) if (route.path !== item.path) router.push(item.path) return } @@ -125,6 +159,7 @@ export function useMixedMenu() { // 有子项:优先跳到第一个叶子,否则退回分组自身 path const firstLeaf = findFirstLeaf(item) const target = firstLeaf?.path ?? item.path + selectPath(target) if (route.path !== target) router.push(target) } @@ -143,22 +178,14 @@ export function useMixedMenu() { openKeys.value = index === -1 ? [key] : [] } - /** 第二列的激活判断:与 useSidebarMenu 同口径(精确相等或前缀匹配,兼容详情页) */ - function isLeafActive(path: string): boolean { - return route.path === path || route.path.startsWith(`${path}/`) - } - - function isGroupActive(node: SidebarMenuNode): boolean { - return hasActiveLeaf(node.children ?? [], isLeafActive) - } - - // 路由变化时自动展开第二列中当前页所属的祖先分组(与单列侧栏同行为) + // 路由变化时:校正乐观选中,并自动展开第二列中当前页所属的祖先分组(与单列侧栏同行为) watch( () => route.path, (path) => { - const parentKeys = findParentKeysByPath(path) + // 路由落地 → 校正乐观选中(覆盖前进/后退、编程式跳转、跳转失败的兜底) + selectedPath.value = path // 去掉第一段(顶级分组 key),剩下的才是第二列内部要展开的分组 - openKeys.value = parentKeys.slice(1) + openKeys.value = findParentKeysByPath(path).slice(1) }, { immediate: true }, ) @@ -170,6 +197,7 @@ export function useMixedMenu() { activeRootKey, isLeafOnly, selectRoot, + selectPath, isGroupOpen, toggleGroup, isLeafActive, diff --git a/code/g3soft-erp/web/src/layouts/useSidebarMenu.ts b/code/g3soft-erp/web/src/layouts/useSidebarMenu.ts index 7e6d1c08..905d8652 100644 --- a/code/g3soft-erp/web/src/layouts/useSidebarMenu.ts +++ b/code/g3soft-erp/web/src/layouts/useSidebarMenu.ts @@ -1,8 +1,8 @@ import { computed, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { usePreferencesStore } from '@/preferences' -import { findMenuItemByPath, findParentKeysByPath } from '@/router' -import { menuItems } from '@/constants/menu' +import { buildMenuItems, findMenuItemByPath, findParentKeysByPath } from '@/router' +import { useMenuStore } from '@/stores' import { getMenuIcon } from './icons' import type { MenuItem, SidebarMenuNode } from '@/types/menu' @@ -18,28 +18,57 @@ function mapTree(list: MenuItem[]): SidebarMenuNode[] { } /** - * 侧栏菜单逻辑:把常量菜单转成可渲染结构,并维护展开态与激活态。 + * 侧栏菜单逻辑:把**动态菜单**(s_menu 表按权限裁剪后的树)转成可渲染结构, + * 并维护展开态与激活态。 + * + * 对照 fms-vue 的 AppSidebar:菜单来源是 stores/menu 的 visibleMenus, + * 不再是前端常量。原始行结构(b_id / b_name / b_route)已在 @/router 里 + * 映射成 MenuItem,本 composable 只负责渲染相关的加工。 */ export function useSidebarMenu() { const route = useRoute() const prefStore = usePreferencesStore() const pref = computed(() => prefStore.preferences) + const menuStore = useMenuStore() /** 展开的父级菜单 key(分组与子分组都记录在此) */ const openKeys = ref([]) - const items = computed(() => mapTree(menuItems)) + /** + * 可渲染的菜单树。 + * + * 中间映射依赖 router 里的 buildMenuItems —— 那是「s_menu 行 → + * MenuItem」的唯一转换点,布局侧不重复实现,避免两处列名映射漂移。 + */ + const items = computed(() => mapTree(buildMenuItems(menuStore.visibleMenus))) + + /** + * 视觉选中的叶子路径(乐观)。 + * + * 高亮**不直接读 route.path**:路由是异步的(懒加载 import + 守卫), + * 快速连点两个菜单项时,中间那次导航可能先落地,于是「被离开的那一项」 + * 短暂亮起又熄灭(没→有→没)。这里改为点击时同步写入,路由落地后再由 watch 校正, + * 高亮便只跟随最后一次点击、不再跟着异步导航来回闪。 + */ + const selectedPath = ref(route.path) /** * 当前激活的叶子路径。 * 先精确匹配菜单项(含子级);匹配不到时退回原始路径, - * 让详情页(如 /ocean/booking/123)也能靠前缀判断保持高亮。 + * 让详情页(如 /base/othercompany/123)也能靠前缀判断保持高亮。 */ - const activePath = computed(() => findMenuItemByPath(route.path)?.path ?? route.path) + const activePath = computed( + () => findMenuItemByPath(selectedPath.value)?.path ?? selectedPath.value, + ) /** 判断某个叶子是否处于激活态:精确相等,或当前路径位于其下(详情页场景)。 */ function isLeafActive(path: string): boolean { - return activePath.value === path || route.path.startsWith(`${path}/`) + return activePath.value === path || selectedPath.value.startsWith(`${path}/`) + } + + /** 点击叶子时同步置为选中(乐观选中,见 selectedPath 的说明)。 */ + function selectPath(path: string): void { + selectedPath.value = path } /** @@ -89,11 +118,14 @@ export function useSidebarMenu() { /** * 路由变化时自动展开当前页所属的所有祖先分组(不收起已手动展开的其他分组)。 - * immediate 保证首次进入深链接(如刷新在 /finance/invoice)也能正确展开。 + * immediate 保证首次进入深链接(如刷新在 /system/log/audit)也能正确展开。 */ watch( () => route.path, (path) => { + // 路由落地 → 校正乐观选中(覆盖前进/后退、编程式跳转、以及跳转失败后的兜底) + selectedPath.value = path + const parentKeys = findParentKeysByPath(path) if (!parentKeys.length) return @@ -118,5 +150,6 @@ export function useSidebarMenu() { isGroupActive, isGroupOpen, toggleGroup, + selectPath, } } diff --git a/code/g3soft-erp/web/src/router/index.ts b/code/g3soft-erp/web/src/router/index.ts index bdd6ec10..4f88793e 100644 --- a/code/g3soft-erp/web/src/router/index.ts +++ b/code/g3soft-erp/web/src/router/index.ts @@ -2,22 +2,154 @@ import NProgress from 'nprogress' import 'nprogress/nprogress.css' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' -import airRoutes from './modules/air' import authRoutes from './modules/auth' -import containerRoutes from './modules/container' -import crmRoutes from './modules/crm' -import financeRoutes from './modules/finance' -import oceanRoutes from './modules/ocean' -import systemRoutes from './modules/system' import { APP_TITLE } from '@/constants/common' -import { menuItems } from '@/constants/menu' +import { useMenuStore, usePermissionStore, useUserStore } from '@/stores' +import type { MenuRow } from '@/stores/menu' import type { MenuItem } from '@/types/menu' +/** + * 路由表 —— **静态**声明(对照 fms-vue 的 router/routes.js)。 + * + * ⚠️ 关键设计:菜单是动态的,路由是静态的。 + * s_menu 表决定「侧栏显示什么、能进哪些页面」,但每个页面对应的**组件** + * 必须在前端有静态路由记录才能渲染 —— 数据库里存不了 Vue 组件。 + * 所以新增业务页仍是「加一条静态路由 + 在 s_menu 里录一条菜单」两步, + * 与 fms-vue 完全一致(那边也是 routes.js 静态 + s_menu 动态两者配合)。 + * + * 本仓库目前只保留:登录页、主框架下的「表格测试」页、403 / 500 / 404 缺省页。 + * 原 ocean / air / container / finance / crm / system 六个业务域的页面、路由与 + * 常量菜单已全部移除,改由 s_menu 动态驱动侧栏。 + */ + NProgress.configure({ showSpinner: false }) -/** 由菜单常量派生首页重定向目标,保证「菜单唯一数据源」不被打破。 */ -const firstMenuPath = menuItems[0]?.path ?? '/ocean' +/** 登录后 / 已登录访问登录页时的落地页。 */ +const HOME_PATH = '/dashboard' + +/** + * 受保护页面:挂在 DefaultLayout 下(侧栏 / 顶栏 / 页签)。 + * + * ⚠️ 这里必须**覆盖 s_menu 里每一条页面菜单的 b_route**,一条都不能少。 + * 菜单是动态的(读 s_menu),路由是静态的 —— 如果某个菜单有 b_route 却没有对应 + * 静态路由,点击它就会落到表尾的 `/:pathMatch(.*)*` 通配路由,用户看到的是 **404**。 + * 这正是「菜单点进去全是 404」的成因。 + * + * 当前 s_menu 的页面菜单共 13 条(含首页),本表逐条对应: + * /dashboard、/table-test、 + * /approval/{todo,initiated,done,cc}、 + * /base/{othercompany,contact,otherdata}、 + * /system/{user,workflow}、/system/log/{login,audit} + * + * 未实现的页面统一先用 PlaceholderPage 占位(views 下各占位文件), + * 后续把真实页面写进对应文件即可,**路由与菜单都不用再改**。 + * + * 新增页面时同样两步:① 本表加一条路由 ② s_menu 录一条菜单。 + */ +const protectedRoutes: RouteRecordRaw[] = [ + // ---- 首页(登录后落地页) ---- + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '首页' }, + }, + // ---- 表格测试(由原 /ocean/booking 整页搬迁而来,已实现) ---- + { + path: '/table-test', + name: 'table-test', + component: () => import('@/views/table-test/index.vue'), + meta: { title: '表格测试' }, + }, + + // ---- 审批中心(占位,数据接口已在 services/api/workflow.ts 封装) ---- + { + path: '/approval/todo', + name: 'approval-todo', + component: () => import('@/views/approval/Todo.vue'), + meta: { title: '我的待办' }, + }, + { + path: '/approval/initiated', + name: 'approval-initiated', + component: () => import('@/views/approval/Initiated.vue'), + meta: { title: '我发起的' }, + }, + { + path: '/approval/done', + name: 'approval-done', + component: () => import('@/views/approval/Done.vue'), + meta: { title: '我已处理' }, + }, + { + path: '/approval/cc', + name: 'approval-cc', + component: () => import('@/views/approval/Cc.vue'), + meta: { title: '抄送我的' }, + }, + + // ---- 基础资料(占位) ---- + { + path: '/base/othercompany', + name: 'base-othercompany', + component: () => import('@/views/base/Othercompany.vue'), + meta: { title: '合作伙伴' }, + }, + { + path: '/base/contact', + name: 'base-contact', + component: () => import('@/views/base/Contact.vue'), + meta: { title: '联系人' }, + }, + { + path: '/base/otherdata', + name: 'base-otherdata', + component: () => import('@/views/base/Otherdata.vue'), + meta: { title: '其他数据' }, + }, + + // ---- 系统管理(占位) ---- + { + path: '/system/user', + name: 'system-user', + component: () => import('@/views/system/User.vue'), + meta: { title: '用户管理' }, + }, + { + path: '/system/workflow', + name: 'system-workflow', + component: () => import('@/views/system/workflow/Index.vue'), + meta: { title: '流程管理' }, + }, + { + path: '/system/log/login', + name: 'system-log-login', + component: () => import('@/views/system/log/Login.vue'), + meta: { title: '登录日志' }, + }, + { + path: '/system/log/audit', + name: 'system-log-audit', + component: () => import('@/views/system/log/Audit.vue'), + meta: { title: '操作日志' }, + }, + + // 403 / 500:作为主框架的子路由(保留侧栏/顶栏/页签,用户可直接切走)。 + // 渲染崩溃的兜底不经过这里 —— 那由 App 层的 ErrorBoundary 全屏渲染。 + { + path: '/403', + name: 'forbidden', + component: () => import('@/views/fallback/Forbidden.vue'), + meta: { title: '403' }, + }, + { + path: '/500', + name: 'server-error', + component: () => import('@/views/fallback/ServerError.vue'), + meta: { title: '500' }, + }, +] const routes: RouteRecordRaw[] = [ // 认证域(登录等):顶层路由,与主框架平级、不套 DefaultLayout @@ -25,33 +157,8 @@ const routes: RouteRecordRaw[] = [ { path: '/', component: () => import('@/layouts/DefaultLayout.vue'), - redirect: firstMenuPath, - children: [ - ...oceanRoutes, - ...airRoutes, - ...containerRoutes, - ...financeRoutes, - ...crmRoutes, - ...systemRoutes, - // 403 / 500:作为主框架的子路由(保留侧栏/顶栏/页签,用户可直接切走), - // 与 fms-vue 一致(routes.js 里 /403 /500 挂在布局下)。 - // 渲染崩溃的兜底不经过这里 —— 那由 App 层的 ErrorBoundary 全屏渲染。 - // - // 页签 / 面包屑 / 文档标题统一用「403 / 500」状态码(与 URL 一一对应), - // 页面内的标题仍由 G3Result 按 locale 显示「无权访问 / 页面出错了」。 - { - path: '/403', - name: 'forbidden', - component: () => import('@/views/fallback/Forbidden.vue'), - meta: { title: '403' }, - }, - { - path: '/500', - name: 'server-error', - component: () => import('@/views/fallback/ServerError.vue'), - meta: { title: '500' }, - }, - ], + redirect: HOME_PATH, + children: protectedRoutes, }, // 通配 404:独立布局(无外壳),放最后 { @@ -68,20 +175,45 @@ const router = createRouter({ scrollBehavior: () => ({ top: 0 }), }) -router.beforeEach((to) => { - NProgress.start() +/* ============================================================================ + * s_menu 行 → 菜单视图模型 + * + * 布局组件(侧栏 / 顶栏 / 混合菜单 / 面包屑)消费的是 MenuItem, + * 而 s_menu 的列名是 b_id / b_name / b_route / b_icon。这层映射是唯一的转换点, + * 数据表的命名因此不会扩散到各布局组件里。 + * ========================================================================== */ - const title = to.meta.title as string | undefined - document.title = title ? `${title} - ${APP_TITLE}` : APP_TITLE +/** 单行映射:s_menu 行 → MenuItem(不含 children,子树由 buildMenuItems 递归处理)。 */ +function toMenuItem(row: MenuRow): MenuItem { + return { + key: String(row.b_id || ''), + title: String(row.b_name || ''), + path: String(row.b_route || '').trim(), + icon: row.b_icon ? String(row.b_icon) : undefined, + } +} - // 登录校验待用户模块落地后接入;当前直接放行。 - // vue-router 5 已弃用 next 回调,改用返回值:放行 return true,拦截 return false。 - return true -}) +/** 递归映射:带 children 的 s_menu 树 → MenuItem 树。 */ +export function buildMenuItems(rows: MenuRow[]): MenuItem[] { + return rows.map((row) => { + const item = toMenuItem(row) + if (row.children?.length) { + item.children = buildMenuItems(row.children) + } + return item + }) +} -router.afterEach(() => { - NProgress.done() -}) +/** + * 当前可见菜单树(响应式)。 + * + * 每次调用都新建 computed 会让「同一 tick 内多次调用」产生多个实例, + * 但这里只在守卫与布局里各取一次、且都依赖同一个 store 的 computed, + * 开销可忽略;若将来频繁调用,应改为模块级单例 computed。 + */ +function currentMenuItems(): MenuItem[] { + return buildMenuItems(useMenuStore().visibleMenus) +} /** * 在菜单树中按 path 查找菜单项(含子级)。 @@ -99,7 +231,7 @@ export function findMenuItemByPath(path: string): MenuItem | undefined { return undefined } - return walk(menuItems) + return walk(currentMenuItems()) } /** @@ -119,17 +251,18 @@ export function findMenuItemByKey(key: string): MenuItem | undefined { return undefined } - return walk(menuItems) + return walk(currentMenuItems()) } /** * 由当前路径反查它的所有祖先分组 key(从顶级到最近一级)。 - * 支持多级嵌套与详情页:/ocean/booking/123 会命中「海运」「订舱业务」。 + * 支持多级嵌套与详情页:/base/base_otherdata/od_system 会命中「基础资料」「其他数据」。 * 返回数组保证侧栏能逐层展开所有祖先分组。 * * ⚠️ 这里的「命中」必须是【叶子】命中,不能是「分组 path 前缀命中」—— - * 否则 /ocean/booking 会因 startsWith('/ocean/') 在「海运」这一层就提前返回, - * 丢掉真正的祖先链 [ocean, ocean-booking-group],导致刷新后不展开。 + * 否则分组自身的 path 会抢先返回,丢掉真正的祖先链,导致刷新后不展开。 + * (本仓库分组节点不写 b_route,故用具体路径举例:命中 /base/contact 时 + * 必须返回 [base],而不是在 base 这一层就因前缀匹配提前返回空数组。) */ export function findParentKeysByPath(path: string): string[] { const walk = (items: MenuItem[], parents: string[]): string[] | null => { @@ -142,7 +275,7 @@ export function findParentKeysByPath(path: string): string[] { continue } - // 分组:自身 path 精确命中(父级 path 与叶子相同的情况,如 /ocean/booking) + // 分组:自身 path 精确命中(父级 path 与叶子相同的情况) if (item.path === path) { return parents } @@ -154,7 +287,7 @@ export function findParentKeysByPath(path: string): string[] { return null } - return walk(menuItems, []) ?? [] + return walk(currentMenuItems(), []) ?? [] } /** @@ -164,4 +297,54 @@ export function findParentKeyByPath(path: string): string | undefined { return findParentKeysByPath(path)[0] } +/* ============================================================================ + * 全局守卫:登录校验 + 权限 / 菜单加载 + 菜单入口授权 + * ========================================================================== */ + +router.beforeEach(async (to) => { + NProgress.start() + + const title = to.meta.title as string | undefined + document.title = title ? `${title} - ${APP_TITLE}` : APP_TITLE + + const userStore = useUserStore() + + // 登录页:已登录则回首页,未登录放行 + if (to.path === '/login') { + if (userStore.isAuthenticated) { + return HOME_PATH + } + return true + } + + // 未登录访问受保护页:跳登录页并记住原目标 + if (!userStore.isAuthenticated) { + return { path: '/login', query: { redirect: to.fullPath } } + } + + // 已登录:确保权限与菜单数据就绪。 + // 权限必须先加载完成 —— 菜单裁剪(visibleMenus)依赖 menu.* 授权判定, + // 两者并发 await 会让菜单在权限未就绪时被裁成空树,侧栏一闪而空。 + const permissionStore = usePermissionStore() + await permissionStore.load().catch(() => undefined) + + const menuStore = useMenuStore() + await menuStore.load().catch(() => undefined) + + // 菜单入口校验:路由命中 s_menu 里的页面菜单时,须有该菜单的 menu.* 授权。 + // 侧栏已按授权裁剪,这里兜住「手输地址直接访问」的越权。 + const menu = menuStore.findMenuByRoute(to.path) + if (menu && !permissionStore.canEnterMenu(String(menu.b_id || ''))) { + return '/403' + } + + // vue-router 5 已弃用 next 回调,改用返回值:放行 return true,拦截 return 路径 + return true +}) + +router.afterEach(() => { + NProgress.done() +}) + +export { HOME_PATH } export default router diff --git a/code/g3soft-erp/web/src/router/modules/air.ts b/code/g3soft-erp/web/src/router/modules/air.ts deleted file mode 100644 index 2010ecbf..00000000 --- a/code/g3soft-erp/web/src/router/modules/air.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { RouteRecordRaw } from 'vue-router' - -/** 空运业务域路由。 */ -const airRoutes: RouteRecordRaw[] = [ - { - path: '/air', - name: 'air', - redirect: '/air/booking', - meta: { title: '空运' }, - children: [ - { - path: 'booking', - name: 'air-booking', - component: () => import('@/views/air/Booking.vue'), - meta: { title: '空运委托' }, - }, - { - path: 'awb', - name: 'air-awb', - component: () => import('@/views/air/Awb.vue'), - meta: { title: '运单管理' }, - }, - ], - }, -] - -export default airRoutes diff --git a/code/g3soft-erp/web/src/router/modules/container.ts b/code/g3soft-erp/web/src/router/modules/container.ts deleted file mode 100644 index 6039bae1..00000000 --- a/code/g3soft-erp/web/src/router/modules/container.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { RouteRecordRaw } from 'vue-router' - -/** 箱管业务域路由。 */ -const containerRoutes: RouteRecordRaw[] = [ - { - path: '/container', - name: 'container', - redirect: '/container/ledger', - meta: { title: '箱管' }, - children: [ - { - path: 'ledger', - name: 'container-ledger', - component: () => import('@/views/container/Ledger.vue'), - meta: { title: '集装箱台账' }, - }, - { - path: 'tracking', - name: 'container-tracking', - component: () => import('@/views/container/Tracking.vue'), - meta: { title: '箱动态跟踪' }, - }, - ], - }, -] - -export default containerRoutes diff --git a/code/g3soft-erp/web/src/router/modules/crm.ts b/code/g3soft-erp/web/src/router/modules/crm.ts deleted file mode 100644 index 62762d0b..00000000 --- a/code/g3soft-erp/web/src/router/modules/crm.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { RouteRecordRaw } from 'vue-router' - -/** CRM 业务域路由。 */ -const crmRoutes: RouteRecordRaw[] = [ - { - path: '/crm', - name: 'crm', - redirect: '/crm/customer', - meta: { title: 'CRM' }, - children: [ - { - path: 'customer', - name: 'crm-customer', - component: () => import('@/views/crm/Customer.vue'), - meta: { title: '客户档案' }, - }, - { - path: 'lead', - name: 'crm-lead', - component: () => import('@/views/crm/Lead.vue'), - meta: { title: '销售线索' }, - }, - ], - }, -] - -export default crmRoutes diff --git a/code/g3soft-erp/web/src/router/modules/finance.ts b/code/g3soft-erp/web/src/router/modules/finance.ts deleted file mode 100644 index fc5ec5e6..00000000 --- a/code/g3soft-erp/web/src/router/modules/finance.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { RouteRecordRaw } from 'vue-router' - -/** - * 财务业务域路由。 - * 财务被海运 / 空运 / 箱管共享,其页面应保持「按单据通用」, - * 不在财务目录内为单个业务域复制一套逻辑。 - */ -const financeRoutes: RouteRecordRaw[] = [ - { - path: '/finance', - name: 'finance', - redirect: '/finance/receivable', - meta: { title: '财务' }, - children: [ - { - path: 'receivable', - name: 'finance-receivable', - component: () => import('@/views/finance/Receivable.vue'), - meta: { title: '应收费用' }, - }, - { - path: 'payable', - name: 'finance-payable', - component: () => import('@/views/finance/Payable.vue'), - meta: { title: '应付费用' }, - }, - { - path: 'statement', - name: 'finance-statement', - component: () => import('@/views/finance/Statement.vue'), - meta: { title: '对账单' }, - }, - { - path: 'invoice', - name: 'finance-invoice', - component: () => import('@/views/finance/Invoice.vue'), - meta: { title: '发票管理' }, - }, - ], - }, -] - -export default financeRoutes diff --git a/code/g3soft-erp/web/src/router/modules/ocean.ts b/code/g3soft-erp/web/src/router/modules/ocean.ts deleted file mode 100644 index e24108b8..00000000 --- a/code/g3soft-erp/web/src/router/modules/ocean.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { RouteRecordRaw } from 'vue-router' - -/** - * 海运业务域路由。 - * 父级只做重定向,实际页面挂在子路径上(与 constants/menu.ts 的层级一致)。 - */ -const oceanRoutes: RouteRecordRaw[] = [ - { - path: '/ocean', - name: 'ocean', - redirect: '/ocean/booking', - meta: { title: '海运' }, - children: [ - { - path: 'booking', - name: 'ocean-booking', - component: () => import('@/views/ocean/Booking.vue'), - meta: { title: '订舱单' }, - }, - { - path: 'bill', - name: 'ocean-bill', - component: () => import('@/views/ocean/Bill.vue'), - meta: { title: '提单管理' }, - }, - { - path: 'schedule', - name: 'ocean-schedule', - component: () => import('@/views/ocean/Schedule.vue'), - meta: { title: '船期查询' }, - }, - ], - }, -] - -export default oceanRoutes diff --git a/code/g3soft-erp/web/src/router/modules/system.ts b/code/g3soft-erp/web/src/router/modules/system.ts deleted file mode 100644 index 55a86726..00000000 --- a/code/g3soft-erp/web/src/router/modules/system.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { RouteRecordRaw } from 'vue-router' - -/** 系统管理路由(用户 / 角色 / 菜单 / 参数)。 */ -const systemRoutes: RouteRecordRaw[] = [ - { - path: '/system', - name: 'system', - redirect: '/system/user', - meta: { title: '系统管理' }, - children: [ - { - path: 'user', - name: 'system-user', - component: () => import('@/views/system/User.vue'), - meta: { title: '用户管理' }, - }, - { - path: 'role', - name: 'system-role', - component: () => import('@/views/system/Role.vue'), - meta: { title: '角色权限' }, - }, - { - path: 'menu', - name: 'system-menu', - component: () => import('@/views/system/MenuConfig.vue'), - meta: { title: '菜单配置' }, - }, - { - path: 'keepalive-test', - name: 'system-keepalive-test', - component: () => import('@/views/system/KeepAliveTest.vue'), - meta: { title: 'keep-alive 测试' }, - }, - ], - }, -] - -export default systemRoutes diff --git a/code/g3soft-erp/web/src/services/api/auth.ts b/code/g3soft-erp/web/src/services/api/auth.ts new file mode 100644 index 00000000..08b850e8 --- /dev/null +++ b/code/g3soft-erp/web/src/services/api/auth.ts @@ -0,0 +1,39 @@ +/** + * 认证接口 —— 严格按后端 AuthController 的契约封装。 + * + * ⚠️ 登录参数是 orgid / userid / password(**不是** username), + * 这是后端 ParamUtils.getString(request, "userid") 的取值口径。 + */ + +import { post } from '@/services/request' +import type { LoginResult } from '@/stores/user' + +/** + * 机构用户登录。 + * + * @param orgId 机构码(后端会 trim + 转大写) + * @param account 登录账号,对应后端参数 userid + * @param password 密码 + * + * ⚠️ 账号或密码错误时后端返回 **code=401**(不是业务码): + * 响应拦截器据此判定为「鉴权类错误」并清会话跳登录。 + * 登录页调用本接口时,401 意味着「机构码、账号或密码错误」, + * 需要显式接住(拦截器已经跳过跳转,因为当前就在 /login)。 + */ +export function loginApi(orgId: string, account: string, password: string): Promise { + return post('/auth/login', { + orgid: orgId, + userid: account, + password, + }) +} + +/** + * 退出登录。 + * + * ⚠️ 后端【没有】登出接口(fms 同样没有)。服务端会话的失效靠「下次登录覆盖 sessionId」 + * 自然完成(ActiveSessionRegistry.activate)。 + * 所以退出登录是纯前端行为:清会话 → 跳登录页,见 stores/user 的 clearSession。 + * + * 这里刻意不提供 logoutApi —— 避免后来者误以为存在服务端登出而写出永远 404 的调用。 + */ diff --git a/code/g3soft-erp/web/src/services/api/data.ts b/code/g3soft-erp/web/src/services/api/data.ts new file mode 100644 index 00000000..961163d2 --- /dev/null +++ b/code/g3soft-erp/web/src/services/api/data.ts @@ -0,0 +1,125 @@ +/** + * 通用数据接口 —— 严格按后端 DataController 的契约封装。 + * + * 参数命名一律 snake_case(后端 ParamUtils 按原名取值): + * view_name / search_condition / order_by / search_columns / page_no / page_size + * 不要「顺手改成驼峰」——那会让后端取不到参数而报「缺少必填参数」。 + */ + +import { post } from '@/services/request' + +/** 后端返回的一行数据(列名 → 值,列名大小写由数据库决定)。 */ +export type DataRow = Record + +/** 分页返回结构(后端 DataService.page)。 */ +export interface PageResult { + rows: DataRow[] + /** 本页行数 */ + row_count: number + /** 符合条件总行数 */ + total: number + page_no: number + page_size: number +} + +/** 列结构描述(后端 MetadataReader.describeColumns)。 */ +export interface ColumnInfo { + [key: string]: unknown +} + +/** + * 按表 / 视图加载数据。 + * + * @param viewName 表名或视图名(必填) + * @param searchCondition 查询条件,不含 WHERE 关键字 + * @param orderBy 排序字段,可带 ASC / DESC + * @param searchColumns 需要返回的字段列表;省略时返回全部字段 + */ +export function loadDataApi( + viewName: string, + searchCondition?: string, + orderBy?: string, + searchColumns?: string[], +): Promise { + return post('/data/loaddata', { + view_name: viewName, + search_condition: searchCondition, + order_by: orderBy, + search_columns: searchColumns, + }) +} + +/** + * 执行只读 SQL 查询。 + * + * ⚠️ 后端 SqlText 会校验只读性,写操作关键字(insert/update/delete…)会被拒绝。 + */ +export function loadDataBySqlApi(sql: string): Promise { + return post('/data/loaddatabysql', { sql }) +} + +/** 分页加载表 / 视图数据。 */ +export function pageDataApi(options: { + viewName: string + orderBy: string + searchColumns?: string[] + searchCondition?: string + pageNo?: number + pageSize?: number +}): Promise { + return post('/data/page', { + view_name: options.viewName, + order_by: options.orderBy, + search_columns: options.searchColumns, + search_condition: options.searchCondition, + page_no: options.pageNo ?? 1, + page_size: options.pageSize ?? 20, + }) +} + +/** + * 在同一事务中保存一个或多个表的数据变更。 + * + * ⚠️ requests 是「请求数组」,不是单个对象 —— 后端签名是 List。 + * 元素结构(table / rows / dependent_deletes 等)由保存层定义, + * 此处用 unknown 保持透传,避免在 api 层臆造契约。 + */ +export function saveObjectApi(requests: unknown[]): Promise { + return post('/data/saveobjt', requests) +} + +/** + * 统一删除接口。 + * + * - 模块数据:{ target: 'module', moduleCode, ids } + * - 受控后端服务:{ target: 'direct', table, key_field, ids } + * + * @returns 各表删除行数,形如 { 表名: 行数 } + */ +export function deleteApi(params: { + target: 'module' | 'direct' + moduleCode?: string + table?: string + key_field?: string + ids: unknown[] +}): Promise> { + return post>('/data/delete', params) +} + +/** 获取下一个雪花 ID;count > 1 时返回数组。 */ +export function nextIdApi(count = 1): Promise { + return post('/data/nextid', { count }) +} + +/** 按模块规则原子获取业务编号;count > 1 时返回数组。 */ +export function nextCodeApi(moduleId: string, count = 1): Promise { + return post('/data/nextcode', { + moduleId: String(moduleId), + count, + }) +} + +/** 读取表 / 视图的列结构。 */ +export function describeApi(table: string): Promise { + return post('/data/describe', { table }) +} diff --git a/code/g3soft-erp/web/src/services/api/export.ts b/code/g3soft-erp/web/src/services/api/export.ts new file mode 100644 index 00000000..a1145b29 --- /dev/null +++ b/code/g3soft-erp/web/src/services/api/export.ts @@ -0,0 +1,128 @@ +/** + * 列表导出 —— 严格按后端 ExportController 封装。 + * + * ⚠️ 本域参数是 **camelCase**(moduleId / searchCondition / rowKeys…), + * 与 data 域的 snake_case 不同。 + * + * ⚠️ /export/list 返回的是**文件流**(不是 ApiResponse): + * 调用方需要拿到完整 response 才能读 Content-Disposition 里的文件名, + * 因此这里不经过 post() 的拆包,直接用 axios 实例 + responseType: 'blob'。 + * + * 校验失败时后端在写流之前抛出,由 GlobalExceptionHandler 以 + * HTTP 200 + JSON 业务错误返回 —— 所以 blob 内容可能是错误 JSON, + * 调用方需按 content-type 区分(见下方 isBlobError)。 + */ + +import request from '@/services/request' + +/** 导出请求参数(与后端 ExportService.ExportRequest 一一对应)。 */ +export interface ExportRequest { + /** 模块编码(必填) */ + moduleId: string + /** 查询条件,不含 WHERE */ + searchCondition?: string + /** 排序字段 */ + orderBy?: string + /** 勾选行的主键;为空表示导出全部 */ + rowKeys?: unknown[] + /** 主键字段名 */ + keyField?: string + /** 导出列 */ + columns?: string[] + /** 分批导出的区间起点 */ + rangeStart?: number + /** 分批导出的区间终点 */ + rangeEnd?: number + /** 单卷行数(分卷导出) */ + volumeSize?: number + /** 是否输出合计行,缺省 true */ + withSummary?: boolean +} + +/** + * 导出列表为 Excel 文件流。 + * + * @returns 原始 AxiosResponse,调用方从中取 data(Blob) 与 headers + */ +export async function exportListApi(params: ExportRequest) { + return request.request({ + method: 'POST', + url: '/export/list', + data: params, + responseType: 'blob', + }) +} + +/** 符合条件的行数(批量导出弹窗的「共 N 条」)。 */ +export async function exportCountApi(params: ExportRequest): Promise { + const response = await request.request<{ + code: number + message: string + data: { total: number } + }>({ + method: 'POST', + url: '/export/count', + data: params, + }) + return response.data.data.total +} + +/** + * 各合计列的聚合值(列表底部的「总合计」)。 + * 与导出共用同一条权限链路与聚合 SQL,因此与导出文件里的合计行必然一致。 + */ +export async function exportTotalsApi(params: ExportRequest): Promise> { + const response = await request.request<{ + code: number + message: string + data: Record + }>({ + method: 'POST', + url: '/export/totals', + data: params, + }) + return response.data.data +} + +/** + * blob 响应实际是错误 JSON 时(后端校验失败走 HTTP 200 + JSON), + * 读出其中的 message,供调用方提示。 + * + * @returns 错误文案;不是错误 JSON 时返回 null + */ +export async function readBlobError(blob: Blob): Promise { + // 服务端错误 JSON 的 content-type 是 application/json,成功时是 Excel 的二进制类型 + if (!blob.type.includes('json')) return null + + try { + const text = await blob.text() + const body = JSON.parse(text) as { message?: string } + return body.message || '导出失败' + } catch { + return '导出失败' + } +} + +/** + * 从响应头解析下载文件名(Content-Disposition),供 a[download] 使用。 + * 兼容 filename*=UTF-8''<百分号编码> 与 filename="<原文>" 两种写法。 + */ +export function resolveDownloadFileName( + contentDisposition: string | undefined, + fallback = 'export.xlsx', +): string { + if (!contentDisposition) return fallback + + const encoded = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition) + if (encoded?.[1]) { + try { + return decodeURIComponent(encoded[1]) + } catch { + // 编码异常时退回原文,总比抛错好 + return encoded[1] + } + } + + const plain = /filename="?([^";]+)"?/i.exec(contentDisposition) + return plain?.[1] ?? fallback +} diff --git a/code/g3soft-erp/web/src/services/api/file.ts b/code/g3soft-erp/web/src/services/api/file.ts new file mode 100644 index 00000000..b3b86cf4 --- /dev/null +++ b/code/g3soft-erp/web/src/services/api/file.ts @@ -0,0 +1,86 @@ +/** + * 文件接口 —— 严格按后端 FileController 封装。 + * + * ⚠️ /file/upload 是 multipart,字段名固定为 files / father / moduleId(+ 可选 cateId), + * 不能与 JSON 请求共用默认的 Content-Type: application/json —— + * 这里显式传 FormData,让浏览器自行带 boundary。 + */ + +import { post } from '@/services/request' +import request from '@/services/request' + +/** 文件记录(后端 FileService 落库后的返回行)。 */ +export interface FileRecord { + [key: string]: unknown +} + +/** 存储模式配置。 */ +export interface FileStorageConfig { + /** 'local' | 'oss' */ + storageType: string +} + +/** 返回存储模式配置(local / oss)。 */ +export function fileConfigApi(): Promise { + return post('/file/config') +} + +/** + * 本地上传(支持多文件)。 + * + * @param files 待上传文件 + * @param father 父级标识(业务单据主键) + * @param moduleId 模块编码 + * @param cateId 分类标识(可选) + */ +export function fileUploadApi( + files: File[], + father: string, + moduleId: string, + cateId?: string, +): Promise { + const formData = new FormData() + files.forEach((file) => formData.append('files', file)) + formData.append('father', father) + formData.append('moduleId', moduleId) + if (cateId) formData.append('cateId', cateId) + + return post('/file/upload', formData, { + // 覆盖默认的 application/json,交给浏览器生成 multipart boundary + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} + +/** + * OSS 上传凭证(bucket = 机构码)。 + * 返回结构由 g3oss 决定,用 unknown 保持透传。 + */ +export function fileUploadCredentialApi(): Promise { + return post('/file/upload-credential') +} + +/** 保存文件记录到 DB(OSS 直传后回调)。 */ +export function fileSaveRecordApi(body: Record): Promise { + return post('/file/save-record', body) +} + +/** 删除文件(按子文件 id)。 */ +export function fileDeleteApi(subid: string): Promise { + return post('/file/delete', { subid }) +} + +/** + * 本地文件访问 URL(路径含子目录:{orgId}/{yyyy}/{MM}/{dd}/{file})。 + * + * 这是 GET 静态资源,不是 JSON 接口,因此不走 post / 不拆包 —— + * 直接拼给 / / iframe 的 src 使用。 + * + * @param fileName 相对存储根目录的路径 + */ +export function fileStaticUrl(fileName: string): string { + const base = import.meta.env.VITE_API_BASE || '/api' + return `${base}/file/static/${fileName}` +} + +/** 供调用方在需要完整响应时使用(如自定义下载流程) */ +export { request } diff --git a/code/g3soft-erp/web/src/services/api/index.ts b/code/g3soft-erp/web/src/services/api/index.ts new file mode 100644 index 00000000..0743937a --- /dev/null +++ b/code/g3soft-erp/web/src/services/api/index.ts @@ -0,0 +1,19 @@ +/** + * API 层统一出口。 + * + * 分域按后端 controller 划分,一处改契约只改对应文件: + * auth —— /auth/* 认证 + * data —— /data/* 通用数据(snake_case 参数) + * workflow —— /workflow/* 流程运行侧(camelCase 参数) + * export —— /export/* 列表导出(camelCase 参数,返回文件流) + * file —— /file/* 上传下载 + * + * ⚠️ 参数命名不统一是后端既定事实:data 域用 snake_case(view_name), + * 其余域用 camelCase(moduleId)。封装时按各域真实口径写,不要「顺手统一」。 + */ + +export * from './auth' +export * from './data' +export * from './workflow' +export * from './export' +export * from './file' diff --git a/code/g3soft-erp/web/src/services/api/workflow.ts b/code/g3soft-erp/web/src/services/api/workflow.ts new file mode 100644 index 00000000..8003ff7d --- /dev/null +++ b/code/g3soft-erp/web/src/services/api/workflow.ts @@ -0,0 +1,159 @@ +/** + * 流程运行侧接口 —— 严格按后端 WorkflowRuntimeController / WorkflowRuntimeQueryService 封装。 + * + * ⚠️ 本域的请求参数是 **camelCase**(moduleId / businessId / taskId / pageNo), + * 与 data 域的 snake_case 不同 —— 后端 ParamUtils 按原名取值,两者不可互相套用。 + * + * ⚠️ 前端只能提交意图:改状态、建实例、推进节点、写待办全部在后端一个事务里完成。 + * 不允许直接改 wf_instance / wf_task / wf_cc 或业务表状态。 + */ + +import { post } from '@/services/request' + +/** 流程实例与任务的通用返回结构(字段随接口不同,此处按已知公共字段声明)。 */ +export interface WorkflowResult { + instanceId?: string + taskId?: string + status?: string + currentSummary?: string + taskCount?: number + /** 幂等命中:重复请求返回已有实例 / 已忽略 */ + duplicated?: boolean + message?: string + [key: string]: unknown +} + +/** 审批动作:approve(同意)/ reject(驳回)/ return(退回) */ +export type TaskAction = 'approve' | 'reject' | 'return' + +/* ---------------- 送审与审批动作 ---------------- */ + +/** + * 送审:匹配流程绑定 → 建实例 → 激活首个节点(同一事务)。 + * + * @param idempotencyKey 幂等键,带时重复请求返回已有实例 + * @param context 业务字段,供流程条件与变量使用 + */ +export function workflowSubmitApi(params: { + moduleId: string + businessId: string + businessNo?: string + event?: string + context?: Record + idempotencyKey?: string +}): Promise { + return post('/workflow/runtime/submit', params) +} + +/** 审批动作:同意 / 驳回 / 退回。 */ +export function workflowTaskActionApi(params: { + taskId: string + action: TaskAction + comment?: string + idempotencyKey?: string +}): Promise { + return post('/workflow/runtime/task/action', params) +} + +/** 抢占待办(节点 allowClaim 生效时可用)。 */ +export function workflowTaskClaimApi(taskId: string): Promise { + return post('/workflow/runtime/task/claim', { taskId }) +} + +/** 转交待办。 */ +export function workflowTaskTransferApi(params: { + taskId: string + toUserId: string + comment?: string +}): Promise { + return post('/workflow/runtime/task/transfer', params) +} + +/** 加签:userIds 是加签人账号列表(后端要求非空)。 */ +export function workflowTaskAddApproverApi(params: { + taskId: string + userIds: string[] + comment?: string +}): Promise { + return post('/workflow/runtime/task/add-approver', params) +} + +/** 催办:只发提醒,不改任务状态。 */ +export function workflowTaskUrgeApi(params: { + taskId: string + comment?: string +}): Promise { + return post('/workflow/runtime/task/urge', params) +} + +/** 撤回(发起人)。 */ +export function workflowWithdrawApi(params: { + instanceId: string + comment?: string +}): Promise { + return post('/workflow/runtime/instance/withdraw', params) +} + +/** 取消(管理员)。 */ +export function workflowCancelApi(params: { + instanceId: string + comment?: string +}): Promise { + return post('/workflow/runtime/instance/cancel', params) +} + +/* ---------------- 审批中心与进度查询 ---------------- */ + +/** 待办列表。 */ +export function workflowTodoListApi( + params: { pageNo?: number; pageSize?: number; [key: string]: unknown } = {}, +): Promise> { + return post>('/workflow/runtime/todo/list', params) +} + +/** 已办列表。 */ +export function workflowDoneListApi( + params: { pageNo?: number; pageSize?: number; [key: string]: unknown } = {}, +): Promise> { + return post>('/workflow/runtime/done/list', params) +} + +/** 我发起的流程实例。 */ +export function workflowMyInstancesApi( + params: { pageNo?: number; pageSize?: number; [key: string]: unknown } = {}, +): Promise> { + return post>('/workflow/runtime/instance/mine', params) +} + +/** 实例图:节点 / 连线状态 + 时间线,用于「查看审批进度」。 */ +export function workflowInstanceGraphApi(instanceId: string): Promise> { + return post>('/workflow/runtime/instance/graph', { instanceId }) +} + +/** + * 按业务单据查审批状态:业务详情页的状态条用 + * (返回最近实例 + 我的待办 + 是否已配置绑定)。 + */ +export function workflowInstanceByBusinessApi(params: { + moduleId: string + businessId: string +}): Promise> { + return post>('/workflow/runtime/instance/by-business', params) +} + +/** 任务详情:任务 + 实例 + 节点配置(含表单策略)。 */ +export function workflowTaskDetailApi(taskId: string): Promise> { + return post>('/workflow/runtime/task/detail', { taskId }) +} + +/** 抄送列表。 */ +export function workflowCcListApi( + params: { pageNo?: number; pageSize?: number; [key: string]: unknown } = {}, +): Promise> { + return post>('/workflow/runtime/cc/list', params) +} + +/** 标记抄送已读。 */ +export function workflowCcReadApi(ccId: string): Promise> { + return post>('/workflow/runtime/cc/read', { ccId }) +} diff --git a/code/g3soft-erp/web/src/services/request.ts b/code/g3soft-erp/web/src/services/request.ts index fb894bab..d79d7b44 100644 --- a/code/g3soft-erp/web/src/services/request.ts +++ b/code/g3soft-erp/web/src/services/request.ts @@ -1,6 +1,7 @@ import axios, { type AxiosInstance, type AxiosResponse, type AxiosRequestConfig } from 'axios' import { G3Message } from '@g3soft/ui' import { API_BASE } from '@/constants/common' +import { useUserStore } from '@/stores' /** * HTTP 层:统一拆包、鉴权注入、错误提示。 @@ -18,13 +19,30 @@ import { API_BASE } from '@/constants/common' * 调用方若自己也要提示,据此判断避免弹两次。这是 fms-vue 踩过的坑。 * * 4. 【跳登录去重】—— 并发请求同时 401 时,只跳一次。 + * + * ⚠️ 后端 JwtAuthFilter 拒绝请求时返回的是 **HTTP 200 + body.code=401**(不是 HTTP 401), + * 所以两个位置都要判:成功拦截器里判 body.code,失败拦截器里判 HTTP status。 */ /** 业务成功的 code 约定。 */ const SUCCESS_CODE = 0 -/** 鉴权失败:HTTP 401 与业务码 401 都算。 */ -const AUTH_ERROR_CODES = new Set([401]) +/** + * 鉴权失败的业务码,与后端 ApiResponse 保持一致: + * 401 —— 未登录或登录已失效(JwtAuthFilter 的 AUTH_ERROR_CODE) + * 40101 —— 账号已在其他设备登录(SESSION_REPLACED_CODE,互踢) + * + * ⚠️ 40101 必须一并处理:它同样意味着「当前 token 已不可用」, + * 漏掉会让被顶下线的用户停在「页面看着正常但所有请求都失败」的状态。 + * 两者文案不同,故跳登录时用 reason 区分,由登录页展示对应提示。 + */ +const AUTH_ERROR_CODES = new Set([401, 40101]) + +/** 会话被顶替的业务码,用于区分跳登录的原因文案。 */ +const SESSION_REPLACED_CODE = 40101 + +/** guard 前置条件不成立,文案由服务端拼好,可直接展示(后端 GUARD_ERROR_CODE)。 */ +const GUARD_ERROR_CODE = 2002 /** 业务错误对象:带上 code / data / toasted,供调用方分支处理。 */ export interface RequestError extends Error { @@ -60,30 +78,42 @@ let redirectingToLogin = false * 用 window.location.assign 而非 router.push:这是【会话已失效】的硬跳转, * 整页重载能顺带清掉内存里所有旧状态(store、keep-alive 缓存、定时器), * 比在 SPA 内软跳更干净。 + * + * @param reason 'expired'(登录过期)| 'session-replaced'(互踢),登录页据此提示 */ -function redirectToLogin(): void { +function redirectToLogin(reason: 'expired' | 'session-replaced'): void { const { pathname, search, hash } = window.location const current = pathname.replace(/\/+$/, '') || '/' if (current === '/login' || redirectingToLogin) return redirectingToLogin = true const redirect = `${pathname}${search}${hash}` - const query = new URLSearchParams({ redirect, reason: 'expired' }) + const query = new URLSearchParams({ redirect, reason }) window.location.assign(`/login?${query.toString()}`) } -/** 清会话并跳登录页 */ -function handleUnauthorized(): void { - // 清 token(user store 尚未落地;接入后改为 userStore.clear()) - localStorage.removeItem('g3erp:token') - redirectToLogin() +/** + * 清会话并跳登录页。 + * + * 调 store 的 clearSession 而不是直接碰 localStorage: + * store 是会话状态的唯一出口,绕过去会留下「内存里还认为自己已登录」的不一致。 + */ +function handleUnauthorized(code: number): void { + try { + useUserStore().clearSession() + } catch { + // store 尚未就绪(如 pinia 未激活)时兜底清存储,保证不会带着废 token 重试 + localStorage.removeItem('g3erp:token') + } + redirectToLogin(code === SESSION_REPLACED_CODE ? 'session-replaced' : 'expired') } /* ---------------- 请求拦截 ---------------- */ request.interceptors.request.use( (config) => { - const token = localStorage.getItem('g3erp:token') + // 调用时读取 store,保证 pinia 已激活(模块顶层读取会在 pinia 装好前执行) + const token = useUserStore().token if (token) { config.headers.Authorization = `Bearer ${token}` } @@ -107,11 +137,20 @@ request.interceptors.response.use( if (body.code === SUCCESS_CODE) return response - if (AUTH_ERROR_CODES.has(body.code)) handleUnauthorized() + if (AUTH_ERROR_CODES.has(body.code)) handleUnauthorized(body.code) const err = new Error(body.message || '请求失败') as RequestError err.code = body.code err.data = body.data + + // 2002 的文案由服务端按「发生了什么 + 你该做什么」拼好,可直接展示。 + // 打上 toasted 让调用点据此去重,不再自己拼前缀(前缀在各调用点,无法在此剥离)。 + // 2003(并发冲突)不在这里弹:调用方需要重新拉取当前行再处理,不是一句提示能结束的。 + if (body.code === GUARD_ERROR_CODE) { + G3Message.error(body.message || '条件不满足,本次操作未生效') + err.toasted = true + } + throw err }, (error) => { @@ -119,8 +158,9 @@ request.interceptors.response.use( const body = error?.response?.data as ApiResult | undefined const code = body?.code ?? status ?? -1 - // 仅鉴权类错误清会话跳登录;网络异常 / 5xx 不能把用户登出 - if (AUTH_ERROR_CODES.has(code)) handleUnauthorized() + // 仅鉴权类错误清会话跳登录;网络异常 / 5xx 不能把用户登出。 + // 这里兼容后端返回 HTTP 401 的情况(JwtAuthFilter 走的是 200 + code,两者都要认)。 + if (AUTH_ERROR_CODES.has(code)) handleUnauthorized(code) const message = body?.message || @@ -152,4 +192,16 @@ export async function http(config: AxiosRequestConfig): Promise return response.data.data } +/** + * POST 并直接返回 data(后端绝大多数接口都是 POST)。 + * 与 fms-vue 的 post() 同签名,api 层封装更简洁。 + */ +export function post( + path: string, + data?: unknown, + config?: AxiosRequestConfig, +): Promise { + return http({ ...config, method: 'POST', url: path, data }) +} + export default request diff --git a/code/g3soft-erp/web/src/stores/index.ts b/code/g3soft-erp/web/src/stores/index.ts index 823d479b..c91c21a2 100644 --- a/code/g3soft-erp/web/src/stores/index.ts +++ b/code/g3soft-erp/web/src/stores/index.ts @@ -1,4 +1,7 @@ export { useAppStore } from './app' export type { TabItem } from './app' export { useUserStore } from './user' -export type { UserInfo } from './user' +export type { SessionUser, LastLoginInfo, LoginResult } from './user' +export { usePermissionStore } from './permission' +export { useMenuStore } from './menu' +export type { MenuRow } from './menu' diff --git a/code/g3soft-erp/web/src/stores/menu.ts b/code/g3soft-erp/web/src/stores/menu.ts new file mode 100644 index 00000000..557c0345 --- /dev/null +++ b/code/g3soft-erp/web/src/stores/menu.ts @@ -0,0 +1,150 @@ +import { computed, ref, watch } from 'vue' +import { defineStore } from 'pinia' + +import { loadDataApi, type DataRow } from '@/services/api' +import { buildSortedTree } from '@/utils/tree' +import { usePermissionStore } from './permission' +import { useUserStore } from './user' + +/** + * 动态导航菜单 store(侧栏菜单数据源)。 + * + * 对照 fms-vue 的 src/stores/menus.js: + * 菜单树由「菜单管理」维护在 s_menu 表,含目录(directory)/ 页面(page)两类节点。 + * 可见性规则由前端执行(不改后端): + * - 全部启用菜单(b_canuse = 1)按登录主体加载,再按 menu.* 授权裁剪(无授权不渲染), + * 目录仅当存在可见子菜单时渲染(空目录不显示); + * - 与权限 store 一致:同一登录主体加载去重、并发请求复用、主体切换清空缓存。 + * + * 菜单行结构(s_menu): + * b_id 主键 / b_parent_id 父级 / b_depth 层级 / b_path 路径 / + * b_name 名称 / b_menu_type 类型 / b_route 路由地址 / b_icon 图标 / b_xh 排序 / b_canuse 启用 + */ + +/** s_menu 行 + 运行时 children。 */ +export interface MenuRow extends DataRow { + children?: MenuRow[] +} + +/** + * 递归裁剪菜单树: + * - 页面菜单:没有该菜单的 menu.* 授权时不渲染(白名单:无记录即无入口); + * - 目录:仅当存在可见子菜单时渲染(空目录不显示)。 + */ +function pruneByAccess(nodes: MenuRow[], canEnter: (node: MenuRow) => boolean): MenuRow[] { + const result: MenuRow[] = [] + for (const node of nodes) { + const children = node.children?.length ? pruneByAccess(node.children, canEnter) : [] + // 目录:没有可访问子菜单时不渲染 + if (String(node.b_menu_type || '') === 'directory') { + if (children.length) result.push({ ...node, children }) + continue + } + if (!canEnter(node)) continue + result.push(children.length ? { ...node, children } : { ...node }) + } + return result +} + +export const useMenuStore = defineStore('menu', () => { + const userStore = useUserStore() + const permissionStore = usePermissionStore() + + /** s_menu 原始记录(b_canuse = 1) */ + const rawMenus = ref([]) + const loadedPrincipalKey = ref('') + let loadingPromise: Promise | null = null + let loadingPrincipalKey = '' + let loadVersion = 0 + + /** 当前登录主体键:机构码 + 用户 id */ + function principalKey(): string { + const userId = String(userStore.loginInfo.user?.id || '') + return `${String(userStore.userInfo.orgId || '')}:${userId}` + } + + /** 加载菜单数据;同一主体重复调用去重,并发调用共享同一请求。 */ + async function load(): Promise { + if (!userStore.isAuthenticated) return + + const currentPrincipal = principalKey() + if (loadedPrincipalKey.value === currentPrincipal) return + if (loadingPromise && loadingPrincipalKey === currentPrincipal) return loadingPromise + + const requestVersion = ++loadVersion + loadingPrincipalKey = currentPrincipal + + const request = loadDataApi('s_menu', 'b_canuse = 1', 'b_xh ASC, b_id ASC') + .then((rows) => { + if (requestVersion !== loadVersion) return + rawMenus.value = (rows || []) as MenuRow[] + loadedPrincipalKey.value = currentPrincipal + }) + .finally(() => { + if (requestVersion !== loadVersion) return + loadingPromise = null + loadingPrincipalKey = '' + }) + + loadingPromise = request + return request + } + + /** + * 当前主体可见的菜单树(s_menu 原始行结构,含 children 子树): + * 已按 menu.* 授权裁剪,未授权的页面菜单与因此变空的目录都不出现。 + */ + const visibleMenus = computed(() => { + if (!rawMenus.value.length) return [] + // 浅拷贝交给 buildSortedTree,避免 children 挂到 rawMenus 上污染原始数据 + const tree = buildSortedTree(rawMenus.value as DataRow[]) as MenuRow[] + return pruneByAccess(tree, (node) => permissionStore.canEnterMenu(String(node.b_id || ''))) + }) + + /** + * 按路由地址找菜单:b_route 精确匹配,或命中它的子路径(如详情页 /base/othercompany/123)。 + * 返回 null 表示该路由不在 s_menu,路由守卫据此放行。 + */ + function findMenuByRoute(path: string): MenuRow | null { + const target = String(path || '') + .split('?')[0] + .replace(/\/+$/, '') + if (!target) return null + return ( + rawMenus.value.find((row) => { + const route = String(row.b_route || '') + .trim() + .replace(/\/+$/, '') + if (!route) return false + return target === route || target.startsWith(`${route}/`) + }) || null + ) + } + + /** 清空已加载的菜单数据 */ + function clear(): void { + // 仅当「进行中的请求属于别的主体」时才作废它(如切换账号), + // 否则保留进行中请求、只清空已加载数据,交由后续 load 结果填充。 + const currentPrincipal = principalKey() + if (loadingPrincipalKey && loadingPrincipalKey !== currentPrincipal) { + loadVersion++ + loadingPromise = null + loadingPrincipalKey = '' + } + loadedPrincipalKey.value = '' + rawMenus.value = [] + } + + // 登录主体变化(退出 / 切换账号)时清空菜单缓存 + watch( + () => [ + userStore.userInfo.orgId, + userStore.loginInfo.user?.id, + userStore.loginInfo.user?.account, + userStore.loginInfo.token, + ], + clear, + ) + + return { load, clear, rawMenus, visibleMenus, findMenuByRoute } +}) diff --git a/code/g3soft-erp/web/src/stores/permission.ts b/code/g3soft-erp/web/src/stores/permission.ts new file mode 100644 index 00000000..850c98a4 --- /dev/null +++ b/code/g3soft-erp/web/src/stores/permission.ts @@ -0,0 +1,218 @@ +import { computed, ref, watch } from 'vue' +import { defineStore } from 'pinia' + +import { loadDataApi, type DataRow } from '@/services/api' +import { + buildActionPowerCode, + buildMenuPowerCode, + buildModulePowerCode, + isStandardModuleAction, +} from '@/utils/powerCodes' +import { useUserStore } from './user' + +/** + * 授权表缺失时的兜底:按无授权处理,不让权限问题阻塞登录主流程。 + * 定义在模块作用域(而非 load 内)以避免每次调用重复创建函数。 + */ +function failSafe(): DataRow[] { + return [] +} + +/** + * 权限 store:读取权限相关表并暴露访问与功能点判断。 + * + * s_module 系统有哪些模块(b_id 即模块编码,b_canuse = 1 为启用) + * s_power 系统有哪些权限点(menu.* / module.* / action.*) + * s_user_power 当前用户拥有哪些权限点(白名单:无记录即无权限) + * + * 判定口径(与后端 SqlPermissionService 一致): + * - s_power 中不存在该权限点、或任一侧 b_canuse = 0,授权一律不生效; + * - 标准操作(read / create / update / delete / export)由「模块使用权限」统一表达, + * 有 module.* 即允许(见 @/utils/powerCodes); + * - 业务动作(审核 / 反审 / 结算 …)按 action.{模块}.{动作} 单独授权; + * - 菜单入口(menu.*)只控制「能否进入页面」,与模块权限相互独立。 + * + * - 管理员账号(g3soft)提权:全部启用模块可见、一切判断放行,不查授权表; + * 口径与后端 SqlPermissionService.ADMIN_ACCOUNT 严格一致,避免前后端判定打架。 + * - 同一主体(orgId:userId)加载结果去重,并发请求复用。 + */ +export const usePermissionStore = defineStore('permission', () => { + const userStore = useUserStore() + + /** 已加载完成的主体键(orgId:userId),用于去重 */ + const loadedPrincipalKey = ref('') + /** 启用的模块列表(s_module 行) */ + const modules = ref([]) + /** 当前用户拥有的权限点编码(s_user_power ∩ s_power 启用行) */ + const grantedPowerCodes = ref([]) + + /** 进行中的请求,供并发复用 */ + let loadingPromise: Promise | null = null + let loadingPrincipalKey = '' + /** 请求版本号:主体切换时自增,使在途的旧请求结果作废 */ + let loadVersion = 0 + + const moduleByCode = computed( + () => new Map(modules.value.map((row) => [String(row.b_id || ''), row])), + ) + const grantedPowerSet = computed(() => new Set(grantedPowerCodes.value)) + + /** 当前账号是管理员(g3soft):提权放行一切判断 */ + const isG3soft = computed( + () => + String(userStore.loginInfo.user?.account || '') + .trim() + .toLowerCase() === 'g3soft', + ) + + /** 当前登录主体键:机构码 + 用户 id */ + function principalKey(): string { + const userId = String(userStore.loginInfo.user?.id || '') + return `${String(userStore.userInfo.orgId || '')}:${userId}` + } + + /** + * 加载权限数据;重复调用对同一主体去重,并发调用共享同一请求。 + * + * 授权表在部分机构库可能尚未建立:失败时按无授权处理,不阻塞登录主流程。 + */ + async function load(): Promise { + const currentPrincipal = principalKey() + // 未登录(无用户 id)时不加载:此时任何判定都应为 false + const userId = String(userStore.loginInfo.user?.id || '') + if (!userId) return + + // 管理员提权:只加载全部启用模块,不查授权表 + if (isG3soft.value) { + const elevatedKey = `g3soft:${currentPrincipal}` + if (loadedPrincipalKey.value === elevatedKey) return + loadingPromise = null + loadingPrincipalKey = '' + + const requestVersion = ++loadVersion + const request = loadDataApi('s_module', 'b_canuse = 1', 'b_id ASC') + .then((rows) => { + if (requestVersion !== loadVersion) return + modules.value = rows || [] + grantedPowerCodes.value = [] + loadedPrincipalKey.value = elevatedKey + }) + .finally(() => { + if (requestVersion !== loadVersion) return + loadingPromise = null + loadingPrincipalKey = '' + }) + loadingPromise = request + return request + } + + if (loadedPrincipalKey.value === currentPrincipal) return + if (loadingPromise && loadingPrincipalKey === currentPrincipal) return loadingPromise + + const requestVersion = ++loadVersion + loadingPrincipalKey = currentPrincipal + + // b_id 是字符串业务键,拼进 SQL 需按 SQL Server 字面量转义(条件直接进 WHERE) + const quotedUserId = `N'${userId.replaceAll("'", "''")}'` + + const request = Promise.all([ + loadDataApi('s_module', 'b_canuse = 1', 'b_id ASC'), + loadDataApi('s_power', 'b_canuse = 1', 'b_id ASC').catch(failSafe), + loadDataApi( + 's_user_power', + `b_user_id = ${quotedUserId} AND b_canuse = 1`, + 'b_power_id ASC', + ).catch(failSafe), + ]) + .then(([moduleRows, powerRows, userPowerRows]) => { + if (requestVersion !== loadVersion) return + // 只有 s_power 中启用的权限点、且用户被授权才算数 + const activePowers = new Set((powerRows || []).map((row) => String(row.b_id || ''))) + modules.value = moduleRows || [] + grantedPowerCodes.value = (userPowerRows || []) + .map((row) => String(row.b_power_id || '')) + .filter((code) => code && activePowers.has(code)) + loadedPrincipalKey.value = currentPrincipal + }) + .finally(() => { + if (requestVersion !== loadVersion) return + loadingPromise = null + loadingPrincipalKey = '' + }) + + loadingPromise = request + return request + } + + /** + * 判断当前用户是否有某模块的使用权限(管理员恒为 true)。 + * 白名单语义:没有 module.{模块编码} 授权即不可访问。 + */ + function canAccess(moduleCode: string): boolean { + if (isG3soft.value || !moduleCode) return true + if (!moduleByCode.value.has(String(moduleCode))) return false + return grantedPowerSet.value.has(buildModulePowerCode(moduleCode) as string) + } + + /** + * 判断当前用户是否有某模块的功能点权限。 + * 标准操作(read / create / update / delete / export)由模块使用权限统一表达, + * 有 module.* 即允许;业务动作(audit 等)要求单独的 action.{模块}.{动作} 授权。 + */ + function canPower(scopeCode: string, action: string): boolean { + if (isG3soft.value) return true + const code = String(scopeCode || '') + const actionCode = String(action || '') + if (!code || !actionCode) return false + if (!canAccess(code)) return false + if (isStandardModuleAction(actionCode)) return true + return grantedPowerSet.value.has(buildActionPowerCode(code, actionCode) as string) + } + + /** + * 判断当前用户能否进入某个菜单页面(侧栏渲染与路由入口共用)。 + * 白名单语义:没有 menu.{菜单编码} 授权即不可见、不可进入。 + */ + function canEnterMenu(menuCode: string): boolean { + if (isG3soft.value || !menuCode) return true + return grantedPowerSet.value.has(buildMenuPowerCode(menuCode) as string) + } + + /** 清空已加载的权限数据 */ + function clear(): void { + // 仅当「进行中的请求属于别的主体」时才作废它(如切换账号), + // 否则保留进行中请求、只清空已加载数据,交由后续 load 结果填充。 + // 这避免登录(setSession 后守卫立即 load)被自身的 watch(clear) 误作废,导致菜单空白。 + const currentPrincipal = principalKey() + if (loadingPrincipalKey && loadingPrincipalKey !== currentPrincipal) { + loadVersion++ + loadingPromise = null + loadingPrincipalKey = '' + } + loadedPrincipalKey.value = '' + modules.value = [] + grantedPowerCodes.value = [] + } + + // 登录主体变化(退出 / 切换账号)时清空权限缓存 + watch( + () => [ + userStore.userInfo.orgId, + userStore.loginInfo.user?.id, + userStore.loginInfo.user?.account, + userStore.loginInfo.token, + ], + clear, + ) + + return { + load, + canAccess, + canPower, + canEnterMenu, + clear, + modules, + grantedPowerCodes, + isG3soft, + } +}) diff --git a/code/g3soft-erp/web/src/stores/user.ts b/code/g3soft-erp/web/src/stores/user.ts index 1295aa14..be963baa 100644 --- a/code/g3soft-erp/web/src/stores/user.ts +++ b/code/g3soft-erp/web/src/stores/user.ts @@ -2,38 +2,115 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' /** - * 用户与会话状态。骨架阶段只有最小实现: - * 登录接口与权限码校验等用户模块落地后接入。 + * 用户与会话状态。 + * + * 存储语义对照 fms-vue 的 src/stores/auth.js,刻意分成两份、分别落不同存储: + * + * loginInfo(token + 会话期用户详情)→ sessionStorage + * 凭证是【会话级】数据:关掉标签页/浏览器就该失效,不该长期留在磁盘上。 + * + * userInfo(机构码 + 账号)→ localStorage,跨会话保留 + * 它只是登录页的「回显数据」(下次打开自动填好上次登录的机构与账号), + * 不属于会话。因此 clearSession() **刻意不清它** —— 退出登录后回登录页, + * 机构码/账号仍然填着,这是有意的体验设计,不是遗漏。 + * + * 登录返回结构(后端 AuthService.login): + * { token, orgid, user: { id, account, name } } */ -export interface UserInfo { - userId: string - userName: string - realName?: string +/** 会话期用户详情:来自登录接口返回的 user。 */ +export interface SessionUser { + /** 用户主键,即账号(后端 b_user.b_id) */ + id: string + /** 登录账号 */ + account: string + /** 用户姓名 */ + name?: string } -const TOKEN_KEY = 'g3erp:token' +/** 登录页回显用的用户信息(跨会话保留)。 */ +export interface LastLoginInfo { + /** 机构码 */ + orgId: string + /** 登录账号 */ + account: string +} -export const useUserStore = defineStore('user', () => { - const token = ref(localStorage.getItem(TOKEN_KEY) ?? '') - const userInfo = ref(null) +/** 登录接口返回的原始结构。 */ +export interface LoginResult { + token?: string + access_token?: string + orgid?: string + user?: SessionUser +} - const isLoggedIn = computed(() => Boolean(token.value)) +export const useUserStore = defineStore( + 'user', + () => { + /** 会话信息(凭证 + 用户详情):退出登录时清空,持久化到 sessionStorage */ + const loginInfo = ref<{ token: string; user: SessionUser | null }>({ + token: '', + user: null, + }) - function setToken(value: string): void { - token.value = value - localStorage.setItem(TOKEN_KEY, value) - } + /** 用户信息(机构码 + 账号):跨会话长存,登录页回显用,退出登录刻意不清 */ + const userInfo = ref({ orgId: '', account: '' }) - function setUserInfo(info: UserInfo | null): void { - userInfo.value = info - } + /** 是否有有效会话凭证 */ + const isAuthenticated = computed(() => Boolean(loginInfo.value.token)) - function clear(): void { - token.value = '' - userInfo.value = null - localStorage.removeItem(TOKEN_KEY) - } + /** 当前登录 token(请求拦截器注入 Authorization 用) */ + const token = computed(() => loginInfo.value.token) - return { token, userInfo, isLoggedIn, setToken, setUserInfo, clear } -}) + /** 当前用户名,供顶栏展示 */ + const displayName = computed( + () => loginInfo.value.user?.name || loginInfo.value.user?.account || '未登录', + ) + + /** + * 写入会话。token 兼容 access_token 字段名(不同后端命名习惯), + * organizationId 作为 orgid 缺失时的兜底(如登录页已知机构码)。 + */ + function setSession(session: LoginResult, organizationId?: string): void { + loginInfo.value = { + token: session.token ?? session.access_token ?? '', + user: session.user ?? null, + } + userInfo.value.orgId = session.orgid ?? organizationId ?? '' + } + + /** + * 清空会话。 + * + * ⚠️ 故意不清 userInfo:机构码 / 账号是登录页的回显数据,不属于会话。 + * 与 fms-vue 的 clearSession 同一口径。 + */ + function clearSession(): void { + loginInfo.value = { token: '', user: null } + } + + /** 记住本次登录的机构码与账号,供下次登录页回显 */ + function saveLastLogin(orgId: string, account: string): void { + userInfo.value = { orgId: orgId ?? '', account: account ?? '' } + } + + return { + loginInfo, + userInfo, + token, + isAuthenticated, + displayName, + setSession, + clearSession, + saveLastLogin, + } + }, + { + persist: [ + // 会话凭证:关标签页 / 浏览器即失效 + { key: 'g3erp:login', storage: sessionStorage, pick: ['loginInfo'] }, + // 用户信息(机构码 / 账号):跨会话保留,供登录页回显 + { key: 'g3erp:user', storage: localStorage, pick: ['userInfo'] }, + ], + }, +) diff --git a/code/g3soft-erp/web/src/types/menu.ts b/code/g3soft-erp/web/src/types/menu.ts index 298b5d59..bb346b41 100644 --- a/code/g3soft-erp/web/src/types/menu.ts +++ b/code/g3soft-erp/web/src/types/menu.ts @@ -1,17 +1,20 @@ import type { Component } from 'vue' -/** 业务域标识。新增业务域时在此登记,菜单与路由据此组织。 */ -export type DomainKey = 'ocean' | 'air' | 'container' | 'finance' | 'crm' | 'system' - -/** 菜单项。菜单与路由共用同一份数据源,避免「加了路由忘了加菜单」。 */ +/** + * 菜单项 —— 菜单渲染的**统一视图模型**。 + * + * 它不再来自前端常量,而是由 s_menu 表行(MenuRow)映射而来 + * (见 @/router 的 toMenuItem):侧栏 / 顶栏 / 面包屑 / 混合菜单都消费这一种结构, + * 因此 s_menu 的列名(b_id / b_name / b_route / b_icon)不会扩散到各个布局组件。 + */ export interface MenuItem { - /** 唯一标识,用作路由 name 与菜单 key */ + /** 唯一标识,取 s_menu.b_id(同时是权限点 menu.{b_id} 的编码来源) */ key: string /** 显示名称 */ title: string /** 路由路径,同时用于菜单跳转与激活态判断 */ path: string - /** lucide 图标组件名,如 Ship / Plane / Container */ + /** lucide 图标名,如 Ship / Plane / Container */ icon?: string /** 子菜单 */ children?: MenuItem[] diff --git a/code/g3soft-erp/web/src/utils/powerCodes.ts b/code/g3soft-erp/web/src/utils/powerCodes.ts new file mode 100644 index 00000000..5889e3d1 --- /dev/null +++ b/code/g3soft-erp/web/src/utils/powerCodes.ts @@ -0,0 +1,57 @@ +/** + * 权限点编码与分类(对照 fms-vue 的 src/utils/powerCodes.js,口径与后端 SqlPermissionService 一致)。 + * + * 系统只有三类权限点: + * menu.{菜单编码} 菜单入口 —— 能否进入菜单页面 + * module.{模块编码} 模块使用 —— 标准操作(查看 / 新增 / 修改 / 删除 / 导出) + * action.{模块编码}.{业务动作} 业务动作 —— 审核 / 反审 / 结算 等 + * + * 标准操作不生成独立权限点(整模块一个使用权限点),因此 + * 「能否新增」等价于「能否使用该模块」;业务动作才需要单独授权。 + */ + +/** 标准操作:归入模块使用权限,不再按操作拆分 */ +export const STANDARD_MODULE_ACTIONS = ['read', 'create', 'update', 'delete', 'export'] + +/** 模块使用权限的固定动作(s_power.b_action) */ +export const MODULE_ACCESS_ACTION = 'access' + +/** 动作是否属于「模块使用权限」:access 与标准操作都算 */ +export function isStandardModuleAction(action: string): boolean { + const value = String(action || '').trim() + return value === MODULE_ACCESS_ACTION || STANDARD_MODULE_ACTIONS.includes(value) +} + +/** 权限点性质由动作派生:模块使用(access / 标准操作)→ module,其它业务动作 → action */ +export function typeOfAction(action: string): 'module' | 'action' { + return isStandardModuleAction(action) ? 'module' : 'action' +} + +/** + * 菜单入口权限编码:menu.{菜单编码}。 + * @returns 权限编码;编码为空时返回 null + */ +export function buildMenuPowerCode(menuCode: string): string | null { + const code = String(menuCode || '').trim() + return code ? `menu.${code}` : null +} + +/** + * 模块使用权限编码:module.{模块编码}(标准操作也归到它)。 + * @returns 权限编码;编码为空时返回 null + */ +export function buildModulePowerCode(moduleCode: string): string | null { + const code = String(moduleCode || '').trim() + return code ? `module.${code}` : null +} + +/** + * 业务动作权限编码:action.{模块编码}.{动作}。 + * @returns 权限编码;任一段为空时返回 null + */ +export function buildActionPowerCode(moduleCode: string, action: string): string | null { + const moduleId = String(moduleCode || '').trim() + const actionCode = String(action || '').trim() + if (!moduleId || !actionCode) return null + return `action.${moduleId}.${actionCode}` +} diff --git a/code/g3soft-erp/web/src/utils/tree.ts b/code/g3soft-erp/web/src/utils/tree.ts new file mode 100644 index 00000000..06feb305 --- /dev/null +++ b/code/g3soft-erp/web/src/utils/tree.ts @@ -0,0 +1,76 @@ +/** + * 按 b_parent_id 把扁平行构建为树,并按 b_xh / b_id 排序。 + * + * 对照 fms-vue 的 src/utils/tree.js,只保留菜单渲染所需的两个纯函数: + * 该文件还有一整套可编辑树的位置派生(applyTreePosition / computeTreeMove …), + * 属于「菜单管理 / 模块管理」编辑器的能力,本项目暂无对应页面,不预先搬运。 + */ + +/** 树节点:s_menu 行 + 运行时挂上的 children。 */ +export interface TreeRow { + [key: string]: unknown + children?: TreeRow[] +} + +/** + * 根据 parentKey 将一维数组构建为树。 + * + * ⚠️ 会**就地**往行对象上挂 children,因此调用方传入前应做浅拷贝 + * (否则会污染原始数据源,重复构建时会累积出重复子节点)。 + * + * @param list 原始列表 + * @param idKey 节点 ID 字段名 + * @param parentKey 父节点关联字段名 + */ +export function buildTree(list: TreeRow[], idKey = 'b_id', parentKey = 'b_parent_id'): TreeRow[] { + const map = new Map() + const roots: TreeRow[] = [] + + for (const node of list) { + map.set(String(node[idKey]), node) + } + + for (const node of list) { + const pid = node[parentKey] + if (pid != null && String(pid) !== '') { + const parent = map.get(String(pid)) + if (parent) { + if (!parent.children) parent.children = [] + parent.children.push(node) + continue + } + } + // 父级不存在(或自身就是根)时按根节点处理,避免脏数据把整棵子树丢掉 + roots.push(node) + } + + return roots +} + +/** 按 b_xh 升序、b_id 升序递归排序树(就地修改并返回原数组)。 */ +export function sortTree(tree: TreeRow[], xhKey = 'b_xh', idKey = 'b_id'): TreeRow[] { + tree.sort((a, b) => { + const diff = (Number(a[xhKey]) || 0) - (Number(b[xhKey]) || 0) + if (diff !== 0) return diff + return String(a[idKey] || '').localeCompare(String(b[idKey] || '')) + }) + for (const node of tree) { + if (node.children?.length) { + sortTree(node.children, xhKey, idKey) + } + } + return tree +} + +/** + * 从一维行构建已排序的树。 + * 内建浅拷贝:buildTree 会就地挂 children,直接构建会污染调用方的唯一数据源。 + */ +export function buildSortedTree( + list: TreeRow[], + idKey = 'b_id', + parentKey = 'b_parent_id', +): TreeRow[] { + const copied = (list || []).map((row) => ({ ...row })) + return sortTree(buildTree(copied, idKey, parentKey)) +} diff --git a/code/g3soft-erp/web/src/views/air/Awb.vue b/code/g3soft-erp/web/src/views/air/Awb.vue deleted file mode 100644 index 23212d0b..00000000 --- a/code/g3soft-erp/web/src/views/air/Awb.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/air/Booking.vue b/code/g3soft-erp/web/src/views/air/Booking.vue deleted file mode 100644 index 43551000..00000000 --- a/code/g3soft-erp/web/src/views/air/Booking.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/approval/Cc.vue b/code/g3soft-erp/web/src/views/approval/Cc.vue new file mode 100644 index 00000000..4e12a76b --- /dev/null +++ b/code/g3soft-erp/web/src/views/approval/Cc.vue @@ -0,0 +1,15 @@ + + + diff --git a/code/g3soft-erp/web/src/views/approval/Done.vue b/code/g3soft-erp/web/src/views/approval/Done.vue new file mode 100644 index 00000000..374dd385 --- /dev/null +++ b/code/g3soft-erp/web/src/views/approval/Done.vue @@ -0,0 +1,15 @@ + + + diff --git a/code/g3soft-erp/web/src/views/approval/Initiated.vue b/code/g3soft-erp/web/src/views/approval/Initiated.vue new file mode 100644 index 00000000..7d1b4f46 --- /dev/null +++ b/code/g3soft-erp/web/src/views/approval/Initiated.vue @@ -0,0 +1,18 @@ + + + diff --git a/code/g3soft-erp/web/src/views/approval/Todo.vue b/code/g3soft-erp/web/src/views/approval/Todo.vue new file mode 100644 index 00000000..aa3ec8cf --- /dev/null +++ b/code/g3soft-erp/web/src/views/approval/Todo.vue @@ -0,0 +1,18 @@ + + + diff --git a/code/g3soft-erp/web/src/views/auth/Login.vue b/code/g3soft-erp/web/src/views/auth/Login.vue index 9170413e..09d275c6 100644 --- a/code/g3soft-erp/web/src/views/auth/Login.vue +++ b/code/g3soft-erp/web/src/views/auth/Login.vue @@ -1,7 +1,7 @@ @@ -264,11 +325,14 @@ function onSubmit(): void {

+ + + @@ -507,6 +592,17 @@ function onSubmit(): void { color: var(--g3-color-text-secondary, #4e5969); } +/* ---- 会话失效提示:淡黄底 + 警示色文字,弱化但不至于被忽略 ---- */ +.login__hint { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.5; + color: var(--g3-color-warning-text, #b88230); + background: var(--g3-color-warning-bg, #fff7e8); + border-radius: 6px; +} + /* ---- 表单 ---- */ .login__fields { width: 100%; diff --git a/code/g3soft-erp/web/src/views/base/Contact.vue b/code/g3soft-erp/web/src/views/base/Contact.vue new file mode 100644 index 00000000..c5093279 --- /dev/null +++ b/code/g3soft-erp/web/src/views/base/Contact.vue @@ -0,0 +1,14 @@ + + + diff --git a/code/g3soft-erp/web/src/views/base/Othercompany.vue b/code/g3soft-erp/web/src/views/base/Othercompany.vue new file mode 100644 index 00000000..96f9985b --- /dev/null +++ b/code/g3soft-erp/web/src/views/base/Othercompany.vue @@ -0,0 +1,14 @@ + + + diff --git a/code/g3soft-erp/web/src/views/base/Otherdata.vue b/code/g3soft-erp/web/src/views/base/Otherdata.vue new file mode 100644 index 00000000..31747a10 --- /dev/null +++ b/code/g3soft-erp/web/src/views/base/Otherdata.vue @@ -0,0 +1,14 @@ + + + diff --git a/code/g3soft-erp/web/src/views/container/Ledger.vue b/code/g3soft-erp/web/src/views/container/Ledger.vue deleted file mode 100644 index da7a30f3..00000000 --- a/code/g3soft-erp/web/src/views/container/Ledger.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/container/Tracking.vue b/code/g3soft-erp/web/src/views/container/Tracking.vue deleted file mode 100644 index 28030053..00000000 --- a/code/g3soft-erp/web/src/views/container/Tracking.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/crm/Customer.vue b/code/g3soft-erp/web/src/views/crm/Customer.vue deleted file mode 100644 index 39bc8aa6..00000000 --- a/code/g3soft-erp/web/src/views/crm/Customer.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/crm/Lead.vue b/code/g3soft-erp/web/src/views/crm/Lead.vue deleted file mode 100644 index b07ea1b6..00000000 --- a/code/g3soft-erp/web/src/views/crm/Lead.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/dashboard/index.vue b/code/g3soft-erp/web/src/views/dashboard/index.vue new file mode 100644 index 00000000..838f66f4 --- /dev/null +++ b/code/g3soft-erp/web/src/views/dashboard/index.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/code/g3soft-erp/web/src/views/finance/Invoice.vue b/code/g3soft-erp/web/src/views/finance/Invoice.vue deleted file mode 100644 index 3b7d3b7e..00000000 --- a/code/g3soft-erp/web/src/views/finance/Invoice.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/finance/Payable.vue b/code/g3soft-erp/web/src/views/finance/Payable.vue deleted file mode 100644 index 53f60d0b..00000000 --- a/code/g3soft-erp/web/src/views/finance/Payable.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/finance/Receivable.vue b/code/g3soft-erp/web/src/views/finance/Receivable.vue deleted file mode 100644 index ba2bc6bc..00000000 --- a/code/g3soft-erp/web/src/views/finance/Receivable.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/finance/Statement.vue b/code/g3soft-erp/web/src/views/finance/Statement.vue deleted file mode 100644 index 3e785e2c..00000000 --- a/code/g3soft-erp/web/src/views/finance/Statement.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/ocean/Bill.vue b/code/g3soft-erp/web/src/views/ocean/Bill.vue deleted file mode 100644 index d11c551c..00000000 --- a/code/g3soft-erp/web/src/views/ocean/Bill.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/ocean/Schedule.vue b/code/g3soft-erp/web/src/views/ocean/Schedule.vue deleted file mode 100644 index 9190b647..00000000 --- a/code/g3soft-erp/web/src/views/ocean/Schedule.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/system/KeepAliveTest.vue b/code/g3soft-erp/web/src/views/system/KeepAliveTest.vue deleted file mode 100644 index f9fa497c..00000000 --- a/code/g3soft-erp/web/src/views/system/KeepAliveTest.vue +++ /dev/null @@ -1,80 +0,0 @@ - - - - - diff --git a/code/g3soft-erp/web/src/views/system/MenuConfig.vue b/code/g3soft-erp/web/src/views/system/MenuConfig.vue deleted file mode 100644 index 6585473b..00000000 --- a/code/g3soft-erp/web/src/views/system/MenuConfig.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/system/Role.vue b/code/g3soft-erp/web/src/views/system/Role.vue deleted file mode 100644 index 576401a6..00000000 --- a/code/g3soft-erp/web/src/views/system/Role.vue +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/code/g3soft-erp/web/src/views/system/User.vue b/code/g3soft-erp/web/src/views/system/User.vue index 6b50cb4a..ab5f753d 100644 --- a/code/g3soft-erp/web/src/views/system/User.vue +++ b/code/g3soft-erp/web/src/views/system/User.vue @@ -1,10 +1,14 @@ diff --git a/code/g3soft-erp/web/src/views/system/log/Audit.vue b/code/g3soft-erp/web/src/views/system/log/Audit.vue new file mode 100644 index 00000000..8bd7e330 --- /dev/null +++ b/code/g3soft-erp/web/src/views/system/log/Audit.vue @@ -0,0 +1,14 @@ + + + diff --git a/code/g3soft-erp/web/src/views/system/log/Login.vue b/code/g3soft-erp/web/src/views/system/log/Login.vue new file mode 100644 index 00000000..94ef9879 --- /dev/null +++ b/code/g3soft-erp/web/src/views/system/log/Login.vue @@ -0,0 +1,14 @@ + + + diff --git a/code/g3soft-erp/web/src/views/system/workflow/Index.vue b/code/g3soft-erp/web/src/views/system/workflow/Index.vue new file mode 100644 index 00000000..437f3181 --- /dev/null +++ b/code/g3soft-erp/web/src/views/system/workflow/Index.vue @@ -0,0 +1,14 @@ + + + diff --git a/code/g3soft-erp/web/src/views/ocean/Booking.vue b/code/g3soft-erp/web/src/views/table-test/index.vue similarity index 68% rename from code/g3soft-erp/web/src/views/ocean/Booking.vue rename to code/g3soft-erp/web/src/views/table-test/index.vue index bd3d0c2c..b8f3239f 100644 --- a/code/g3soft-erp/web/src/views/ocean/Booking.vue +++ b/code/g3soft-erp/web/src/views/table-test/index.vue @@ -4,7 +4,13 @@ import { G3Message, G3Table } from '@g3soft/ui' import type { G3TableColumn, TableExpose, TableRow } from '@g3soft/ui' /** - * 订舱单(海运)—— G3Table 行内编辑的**完整用法范例**。 + * 表格测试页 —— G3Table 行内编辑的**完整用法范例**。 + * + * 本页由原「海运 → 订舱单」(views/ocean/Booking.vue)整页搬迁而来, + * 作为根菜单下的独立测试页保留,与海运业务解耦:菜单编码改为 table_test, + * 路由改为 /table-test,组件名改为 TableTest。 + * 表头的业务列(订舱号 / 船名 / 起运港 …)原样保留 —— 它们只是演示数据, + * 换成通用列反而会丢掉「贴近真实业务密度」这一点。 * * 覆盖的能力(按「配置一处、演示一处」组织,方便照抄): * 1. 编辑器类型:text / number / select / checkbox @@ -13,6 +19,19 @@ import type { G3TableColumn, TableExpose, TableRow } from '@g3soft/ui' * 4. 只读列:没配 editor 的列在编辑期自动灰底 * 5. 当前行高亮:受控 activeRowKey + @row-click(与 fms-vue 的 FmsTable 同款写法) * 6. 提交流程:保存时整表校验,未通过则定位到首个错误格 + * 7. **左右冻结列**:左冻结「序号 / 订舱号 / 客户」,右冻结「备注」,横向滚动时两侧不动 + * + * 关于冻结列(第 7 条),配置只有列上的一个 `fixed`,但有三条硬约束: + * · `fixed: 'left'` 的列要排在 columns **最前面**、`'right'` 的排在**最后面**; + * · 固定列**必须写 `width`**。左侧固定列的吸附位置由「它左边那些 left 固定列的 + * 宽度累加」算出来(底座 `useGetFixedColPosition`),没写宽度就没有吸附位移; + * · 想看到效果,**总列宽必须超过容器宽**(否则整表都看得见,固定与不固定一个样)—— + * 本页刻意加到 21 列业务列来制造横向溢出。 + * 序号列(`seq`)由组件内部注入、业务侧不用配,它**本身也是左固定的** + * (G3Table 注入时写了 `fixed: 'left'`,与选择列一致),所以左冻结区依次是 + * 「序号 → 订舱号 → 客户」,横向滚动时行号不会跟着丢。 + * 阴影开关 `fixed-col-shadow` 建议打开:冻结列与滚过去的列同为白底,没有这层 + * 渐变就看不出边界在哪。 * * **本页始终处于可编辑状态**(没有「编辑明细」开关按钮): * 进去就能直接点任意单元格改,`editable` 用组件默认值 true。 @@ -21,7 +40,7 @@ import type { G3TableColumn, TableExpose, TableRow } from '@g3soft/ui' * 数据为本地假数据,不请求接口。 */ -defineOptions({ name: 'OceanBooking' }) +defineOptions({ name: 'TableTest' }) interface BookingRow extends TableRow { id: number @@ -30,8 +49,30 @@ interface BookingRow extends TableRow { pol: string pod: string vessel: string - etd: string + /** 航次 */ + voyage: string + /** 船公司代码(对应 CARRIER_OPTIONS 的 value) */ + carrier: string + /** 委托号(客户给我们的) */ + soNo: string + /** 提单号,未放单时为空 */ + blNo: string + /** 客户参考号 */ + refNo: string + /** 箱型(对应 CONTAINER_TYPE_OPTIONS 的 value) */ + containerType: string containerCount: number + /** 标准箱量 TEU:40 / 45 尺算 2 个,与箱量联动 */ + teu: number + /** 毛重,单位 KGS */ + grossWeight: number + /** 体积,单位 CBM */ + volume: number + /** 业务员(对应 SALESMAN_OPTIONS 的 value) */ + salesman: string + etd: string + /** 截单日 */ + docClose: string status: number enabled: number remark: string @@ -57,6 +98,35 @@ const STATUS_OPTIONS = [ { label: '已取消', value: 3 }, ] +/** 船公司:value 是提单上用的承运人代码 */ +const CARRIER_OPTIONS = [ + { label: '中远海运', value: 'COSCO' }, + { label: '马士基', value: 'MAERSK' }, + { label: '东方海外', value: 'OOCL' }, + { label: '长荣海运', value: 'EMC' }, + { label: '赫伯罗特', value: 'HPL' }, + { label: '达飞轮船', value: 'CMA' }, +] + +/** 箱型:40 / 45 尺按 2 个 TEU 折算(见 makeRow 的 teu 计算) */ +const CONTAINER_TYPE_OPTIONS = [ + { label: '20GP', value: '20GP' }, + { label: '40GP', value: '40GP' }, + { label: '40HQ', value: '40HQ' }, + { label: '45HQ', value: '45HQ' }, + { label: '20RF', value: '20RF' }, + { label: '40RF', value: '40RF' }, +] + +/** 业务员 */ +const SALESMAN_OPTIONS = [ + { label: '张伟', value: 'zhangwei' }, + { label: '李娜', value: 'lina' }, + { label: '王强', value: 'wangqiang' }, + { label: '赵敏', value: 'zhaomin' }, + { label: '陈晨', value: 'chenchen' }, +] + const CUSTOMERS = ['中远海运', '东方海外', '马士基', '长荣海运', '赫伯罗特', '达飞轮船'] const VESSELS = ['EVER GIVEN', 'MSC OSCAR', 'COSCO SHIPPING', 'ONE APOLLO', 'HMM ALGECIRAS'] @@ -88,6 +158,20 @@ function makeRow(index: number): BookingRow { const isCustomerEmpty = index % 5 === 2 const isQtySmall = index % 3 === 0 + const containerType = pick(CONTAINER_TYPE_OPTIONS, index * 9.7).value + const containerCount = isQtyEven + ? 0 + : isQtySmall + ? Math.floor(seeded(index * 5.9) * 5) + 2 + : Math.floor(seeded(index * 5.9) * 90) + 10 + /** + * 标准箱量由箱量 × 箱型折算(40 / 45 尺算 2 个 TEU)。 + * + * 刻意**不**独立随机:这两列并排显示,肉眼一眼就能核对;若各自随机, + * 演示时看到「40HQ 但有 3 个 TEU」会分不清是假数据离谱还是渲染串列。 + */ + const teu = containerCount * (/^(40|45)/.test(containerType) ? 2 : 1) + return { id: index + 1, bookingNo: `BK${String(20260000 + index + 1)}`, @@ -95,12 +179,21 @@ function makeRow(index: number): BookingRow { pol: pick(PORT_OPTIONS, index * 2.3).value, pod: pick(PORT_OPTIONS, index * 3.1).value, vessel: pick(VESSELS, index * 4.7), + voyage: `${String(Math.floor(seeded(index * 8.1) * 90) + 10)}E`, + carrier: pick(CARRIER_OPTIONS, index * 6.1).value, + soNo: `SO${String(20260000 + index * 3 + 7)}`, + // 每第 8 行还没放单:空白单元格正好用来验证「空值列」在冻结区里也正常 + blNo: index % 8 === 5 ? '' : `G3${String(202600000 + index * 7)}`, + refNo: `REF-${String(index + 1).padStart(5, '0')}`, + containerType, + containerCount, + teu, + // 毛重按箱量估:20 尺约 12~19 吨,40 尺按 2 倍箱量已经在 containerCount 里计入 + grossWeight: containerCount * (12 + Math.floor(seeded(index * 11.3) * 8)), + volume: containerCount * (18 + Math.floor(seeded(index * 13.7) * 12)), + salesman: pick(SALESMAN_OPTIONS, index * 12.9).value, etd: `2026-${m}-${d}`, - containerCount: isQtyEven - ? 0 - : isQtySmall - ? Math.floor(seeded(index * 5.9) * 5) + 2 - : Math.floor(seeded(index * 5.9) * 90) + 10, + docClose: `2026-${String((index % 12) + 1).padStart(2, '0')}-${String(Math.max(1, Number(d) - 3)).padStart(2, '0')}`, status, enabled: index % 3 === 0 ? 0 : 1, remark: index % 6 === 0 ? '' : pick(['已归档', '待复核', '加急', '客户指定船期'], index * 2.1), @@ -175,23 +268,35 @@ const warnQtySmall = (value: unknown) => { * 原因:G3Table 的 props 是按 `TableRow` 声明的,没有暴露泛型参数, * 而函数类型参数是**逆变**的,带具体行类型的 `editor.options((row: BookingRow) => ...)` * 无法赋给它(会报 TS2322)。行数据的类型安全改由函数体内断言承担。 + * + * 列数是 21:加上组件内部注入的序号列共 22 列,**越过了横向虚拟滚动的门控阈值** + * (G3Table 的 `MIN_VIRTUAL_X_COLUMN_COUNT = 20`,`> 20` 才开启),所以本页实测的是 + * 「横向虚拟 + 左右冻结列」的组合。代价是: + * · 横向虚拟会把没写 `width` 的列**钉成 100px**,本页因此每列都显式写了 width; + * · 冻结列在横向虚拟下由库特殊处理(固定列始终渲染、不参与虚拟窗口), + * 行为与不虚拟时一致 —— 这一点靠实测确认,不靠推断。 + * 想看「不横向虚拟」这条朴素路径,把任意两列注释掉即可回到 ≤ 20 列。 */ const columns = ref[]>([ + /* ---------------- 左冻结区(fixed: 'left',必须挨着排在最前面) ---------------- */ { dataIndex: 'bookingNo', title: '订舱号', width: 130, + fixed: 'left', // 没配 editor:编辑期该列自动灰底,一眼看出不可改 }, { dataIndex: 'customer', title: '客户(必填)', width: 150, + fixed: 'left', editor: { type: 'text', validate: validateCustomer, }, }, + /* ---------------- 中间滚动区 ---------------- */ { dataIndex: 'pol', title: '起运港', @@ -218,11 +323,41 @@ const columns = ref[]>([ editor: { type: 'text' }, }, { - dataIndex: 'etd', - title: '开航日', - width: 110, + dataIndex: 'voyage', + title: '航次', + width: 90, editor: { type: 'text' }, }, + { + dataIndex: 'carrier', + title: '船公司', + width: 120, + editor: { type: 'select', options: CARRIER_OPTIONS }, + }, + { + dataIndex: 'soNo', + title: '委托号', + width: 130, + editor: { type: 'text' }, + }, + { + dataIndex: 'blNo', + title: '提单号', + width: 130, + editor: { type: 'text' }, + }, + { + dataIndex: 'refNo', + title: '客户参考号', + width: 130, + editor: { type: 'text' }, + }, + { + dataIndex: 'containerType', + title: '箱型', + width: 90, + editor: { type: 'select', options: CONTAINER_TYPE_OPTIONS }, + }, { dataIndex: 'containerCount', title: '箱量', @@ -236,6 +371,45 @@ const columns = ref[]>([ validateWarning: warnQtySmall, }, }, + { + dataIndex: 'teu', + title: '标准箱量', + width: 100, + align: 'right', + editor: { type: 'number' }, + }, + { + dataIndex: 'grossWeight', + title: '毛重(KGS)', + width: 110, + align: 'right', + editor: { type: 'number' }, + }, + { + dataIndex: 'volume', + title: '体积(CBM)', + width: 100, + align: 'right', + editor: { type: 'number' }, + }, + { + dataIndex: 'etd', + title: '开航日', + width: 110, + editor: { type: 'text' }, + }, + { + dataIndex: 'docClose', + title: '截单日', + width: 110, + editor: { type: 'text' }, + }, + { + dataIndex: 'salesman', + title: '业务员', + width: 100, + editor: { type: 'select', options: SALESMAN_OPTIONS }, + }, { dataIndex: 'enabled', title: '启用', @@ -254,10 +428,12 @@ const columns = ref[]>([ disabled: (row: TableRow) => (row as BookingRow).status === 2, }, }, + /* ---------------- 右冻结区(fixed: 'right',必须排在最后) ---------------- */ { dataIndex: 'remark', title: '备注', width: 130, + fixed: 'right', editor: { type: 'text' }, }, ]) @@ -373,7 +549,7 @@ function onRefresh() {