From 2c70387acee97804a104347ea04d9b83dcfb00d7 Mon Sep 17 00:00:00 2001 From: PIGCLOUD Date: Wed, 18 Mar 2026 23:57:10 +0800 Subject: [PATCH 1/6] docs(flow): add starter withdraw design --- docs/plans/2026-03-18-flow-withdraw-design.md | 431 ++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 docs/plans/2026-03-18-flow-withdraw-design.md diff --git a/docs/plans/2026-03-18-flow-withdraw-design.md b/docs/plans/2026-03-18-flow-withdraw-design.md new file mode 100644 index 000000000..9f2c57e16 --- /dev/null +++ b/docs/plans/2026-03-18-flow-withdraw-design.md @@ -0,0 +1,431 @@ +# Flow Starter Withdraw Design + +## Scope + +This design covers the starter-side "withdraw process" capability across: + +- `pigx/pigx-flow` +- `pigx-ui-pro/src/views/flow` + +The goal is to add a clear "withdraw" action for starters without conflating it with the existing "terminate process" capability. + +## Problem Statement + +The current starter-facing page [started.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/task/started.vue) exposes `终止流程`, but the backend implementation is a force-stop semantic: + +- API: `POST /task/task/stopProcessInstance` +- Service: `TaskServiceImpl.stopProcessInstance` +- End reason: `finishReason=9` + +This is not the same as business-level "withdraw": + +- `withdraw` means the starter takes back a just-submitted process before the first approval has been consumed +- `terminate` means a stronger administrative stop on an in-flight process + +If the project keeps reusing terminate for starter withdraw, UI labels, audit semantics, business status sync, and future permission rules will remain mixed. + +## Confirmed Product Decisions + +### Withdraw Semantics + +Use product option `B`: + +- the starter can withdraw only when the first approval stage has not been processed yet + +### Starter-Side Operation Set + +On the starter side: + +- remove the generic `终止流程` button +- keep `撤回` as the only starter-side stop action + +### Business Visibility + +Business pages such as leave requests must explicitly show `已撤回`, not collapse it into `已结束`. + +## Goals + +1. Add a clear starter-only `撤回` action +2. Keep `撤回` and `终止` as separate backend semantics +3. Compute withdraw availability server-side and expose it to the UI +4. Sync withdraw result to business tables through the existing flow status event chain +5. Avoid duplicating withdraw logic in each business form page + +## Non-Goals + +1. Do not redesign admin-side force-stop permissions in this iteration +2. Do not add withdraw-reason input in V1 +3. Do not add business-form-specific withdraw buttons in every detail page in V1 + +## Recommended Placement + +### Primary Entry + +Use the starter list page: + +- [started.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/task/started.vue) + +Reason: + +- it already represents the "我发起的" mental model +- it already groups starter-only actions such as `重新提交` +- it keeps withdraw as a platform capability instead of pushing it into each business page + +### Secondary Entry + +Add the same withdraw action in the detail drawer header opened from `started.vue`. + +Reason: + +- users often inspect details before acting +- it avoids forcing the user back to the list page + +### Not Recommended for V1 + +Do not make business detail pages such as [detail.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/oaLeave/detail.vue) the primary entry. + +Reason: + +- this would duplicate logic across each business form +- withdraw is a workflow-center capability, not a business-form-specific capability + +## User Interaction Design + +### Starter List Display Rules + +On [started.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/task/started.vue): + +- show `撤回` only when `canWithdraw === true` +- show `重新提交` when `rejectToStarter === true` +- do not show the old `终止流程` button for normal starters +- show `已撤回` when `finishReason === '3'` + +### Detail Drawer Display Rules + +In the detail drawer header: + +- show `撤回` when `canWithdraw === true` +- optionally show disabled state plus `withdrawDisabledReason` when not allowed + +### Confirmation Copy + +Use a confirmation dialog before submit: + +- title: `确认撤回流程?` +- content: `仅首个审批节点尚未处理时可撤回。撤回后流程将结束,且不可恢复。` + +## Approach Comparison + +### Option 1: Reuse Existing Stop API + +Description: + +- keep calling `stopProcessInstance` +- only change button label from `终止流程` to `撤回` + +Pros: + +- lowest implementation cost + +Cons: + +- backend semantic remains wrong +- audit and business sync still treat starter withdraw as terminate +- impossible to distinguish `已撤回` from `已终止` + +Conclusion: + +- reject + +### Option 2: New Withdraw API with Shared Internal Termination Path + +Description: + +- add a new starter-facing withdraw API +- keep terminate as a separate API +- internally share low-level engine shutdown logic + +Pros: + +- clear product semantics +- supports distinct UI labels and business status mapping +- keeps future admin permission expansion clean + +Cons: + +- needs small backend refactor + +Conclusion: + +- recommend + +### Option 3: Business-Form-Specific Withdraw Entry First + +Description: + +- add withdraw directly into each business detail page + +Pros: + +- strong local context + +Cons: + +- duplicated logic across forms +- weak platform consistency + +Conclusion: + +- reject for V1 + +## Backend Design + +### New API + +Add a new endpoint in [ProcessTaskController.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessTaskController.java): + +```http +POST /task/task/withdrawProcessInstance +``` + +Request body: + +```json +{ + "processInstanceId": "xxx" +} +``` + +Use existing DTO [TaskParamDto.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/dto/TaskParamDto.java) in V1. + +### Service Contract + +Add method to [ITaskService.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/ITaskService.java): + +```java +R withdrawProcessInstance(TaskParamDto taskParamDto); +``` + +Implement in [TaskServiceImpl.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImpl.java). + +### Withdraw Eligibility Rule + +Do not try to hard-parse "first approval node" from the process graph for V1. + +Instead, use the current persistence model: + +1. query `process_instance_record` +2. require: + - current user is starter + - `status == 1` + - no final `finishReason` +3. reject when runtime variable `rejectToStarter == true` +4. inspect `process_node_record_assign_user` +5. if any record exists for the same `processInstanceId` where: + - `nodeId != 'root'` + - and the task has already been processed + then withdraw is not allowed + +Suggested definition of "already processed": + +- `end_time is not null`, or +- `status != JXZ` + +This rule aligns with the current audit model because completed-task reads already rely on [ProcessNodeRecordAssignUser.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/entity/ProcessNodeRecordAssignUser.java). + +### Shared Internal Termination Logic + +Refactor the current terminate implementation into a shared internal method, for example: + +```java +private R terminateProcessInstance(String processInstanceId, String finishReason) +``` + +Use it from: + +- `stopProcessInstance(..., finishReason='9')` +- `withdrawProcessInstance(..., finishReason='3')` + +This keeps engine-level stop logic shared while preserving product-level semantics. + +### Persistence Update Rules + +On successful withdraw: + +1. end the Flowable runtime instance +2. update `process_instance_record` + - `status = 2` + - `finish_reason = '3'` + - `end_time = now` +3. close in-progress `process_node_record` +4. close in-progress `process_node_record_assign_user` + +The main requirement is that no runtime or local audit record remains in a "进行中" state after withdraw. + +## Frontend Design + +### API Layer + +Add a new API in [src/api/flow/task/index.ts](/Users/lengleng/work/dev1227/pigx-ui-pro/src/api/flow/task/index.ts): + +```ts +export function withdrawProcessInstance(param: { processInstanceId: string }) { ... } +``` + +### Starter List Data + +Extend [ProcessInstanceRecordVo.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/ProcessInstanceRecordVo.java) with: + +- `Boolean canWithdraw` +- `String withdrawDisabledReason` + +Populate both in: + +- `queryMineStarted` +- `detail(processInstanceId)` + +This keeps all action availability server-driven. + +### Starter Page Behavior + +Update [started.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/task/started.vue): + +1. remove starter-facing `终止流程` +2. add `撤回` +3. keep `重新提交` unchanged for `rejectToStarter` +4. add `已撤回` tag for `finishReason === '3'` +5. add withdraw action in drawer header as secondary entry + +## Flow Status and Business Status Design + +### Flow-Level Final States + +Keep `process_instance_record.status` as coarse state: + +- `1` 进行中 +- `2` 已结束 + +Use `finish_reason` for final meaning: + +- `1` 通过 +- `0` 拒绝 +- `3` 撤回 +- `9` 终止 + +### Business Table Mapping + +For leave requests in [BpmOaLeaveEntity.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/entity/BpmOaLeaveEntity.java), the current `leaveStatus` values are too coarse. + +Recommended mapping: + +- `0` 待审批 +- `1` 进行中 +- `2` 已通过 +- `3` 已撤回 +- `4` 已拒绝 +- `5` 已终止 + +If schema change must stay minimal, a fallback is adding a separate finish-reason field. That is not recommended for V1 because UI filtering and display would become more fragmented. + +### Event-Driven Sync + +Continue using the existing status event chain: + +- [FlowProcessEventListener.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/listeners/FlowProcessEventListener.java) +- [NodeTaskCompleteNotify.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/listeners/NodeTaskCompleteNotify.java) +- [IProcessInstanceStatusEventService.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/event/IProcessInstanceStatusEventService.java) + +Update [BpmOaLeaveServiceImpl.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/BpmOaLeaveServiceImpl.java) so `handleStatusEvent` maps based on: + +- `status` +- `finishReason` + +not just `status`. + +This design should become the standard pattern for other business tables too. + +## Detail Response Contract + +The current [ProcessInstanceServiceImpl.java](/Users/lengleng/work/dev1227/pigx/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessInstanceServiceImpl.java) `detail(processInstanceId)` response should be extended with: + +- `canWithdraw` +- `withdrawDisabledReason` +- current `finishReason` + +This lets the drawer header and later business detail integrations share one source of truth. + +## Validation and Error Handling + +### Backend Errors + +Suggested failure messages: + +- `流程不存在或已结束` +- `仅发起人可撤回流程` +- `驳回待提交状态不可撤回` +- `首个审批节点已处理,无法撤回` + +### Frontend Behavior + +On withdraw failure: + +- show backend error message directly when available +- refresh list after success +- close detail drawer after success if triggered there + +## Acceptance Criteria + +### UI Acceptance + +1. `我发起的` page shows `撤回` only for eligible in-progress instances +2. `重新提交` remains available only for reject-to-starter instances +3. starter-facing `终止流程` no longer appears +4. withdrawn instances show `已撤回` +5. detail drawer header stays consistent with list action availability + +### Backend Acceptance + +1. withdraw is rejected once any non-root approval record has been processed +2. withdraw ends the runtime instance cleanly +3. `process_instance_record.finish_reason='3'` after withdraw +4. no in-progress node or assign-user record remains after withdraw +5. existing terminate path still yields `finishReason='9'` + +### Business Acceptance + +1. leave request business status explicitly displays `已撤回` +2. leave request no longer collapses withdraw into generic `已结束` + +## Risks + +1. `ProcessNodeRecordAssignUser.status` comments and actual usage are not fully consistent, so withdraw implementation should rely on current persisted behavior, not comments alone +2. some business modules may currently map only `status` and ignore `finishReason`, causing partial UI mismatch until each listener implementation is updated +3. parallel first-approval scenarios must use "any processed non-root approval record" as the blocking condition + +## Rollout Order + +1. add backend withdraw API and shared termination helper +2. add `finishReason='3'` support in flow list/detail responses +3. update starter page actions and status tags +4. update leave business status mapping and display +5. verify happy path and blocked path with real process instances + +## Implementation Outline + +The `writing-plans` skill referenced by the brainstorming workflow is not available in this workspace. As a fallback, use this execution order: + +1. Backend + - add `withdrawProcessInstance` controller/service method + - extract shared terminate helper + - return `canWithdraw` and `withdrawDisabledReason` from starter list and detail +2. Frontend + - add withdraw API client + - replace starter-side terminate button with withdraw + - add withdrawn status tag and detail-header action +3. Business sync + - extend leave status mapping for withdrawn + - update leave list/detail labels and filters if needed +4. Verification + - test starter can withdraw before first approval action + - test withdraw blocked after any first-stage approval processing + - test business page shows `已撤回` From b553787cfd8a9c28e067d92447b9bdd228de37bf Mon Sep 17 00:00:00 2001 From: PIGCLOUD Date: Thu, 19 Mar 2026 08:39:53 +0800 Subject: [PATCH 2/6] docs(flow): add withdraw resubmit design --- ...026-03-19-flow-withdraw-resubmit-design.md | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 docs/plans/2026-03-19-flow-withdraw-resubmit-design.md diff --git a/docs/plans/2026-03-19-flow-withdraw-resubmit-design.md b/docs/plans/2026-03-19-flow-withdraw-resubmit-design.md new file mode 100644 index 000000000..014067af5 --- /dev/null +++ b/docs/plans/2026-03-19-flow-withdraw-resubmit-design.md @@ -0,0 +1,377 @@ +# Flow Withdraw Resubmit Design + +## Scope + +This design covers starter-side "resubmit after withdraw" behavior for: + +- `pigx-ui-pro/src/views/flow/task/started.vue` +- dynamic flow forms +- custom business forms + +The capability is limited to processes already withdrawn by the starter. It does not reopen the old process instance. + +## Problem Statement + +The current flow center supports: + +- withdraw for starter-side in-progress instances +- resubmit for `rejectToStarter` + +But it does not support the common case where the starter withdraws because they found mistakes in the form and wants to: + +1. edit the old submitted data +2. keep the withdrawn record as history +3. start a brand new process instance from the corrected data + +This must work for both: + +- dynamic flow forms +- custom business forms + +## Confirmed Product Decisions + +1. After withdraw, resubmission must create a new process instance +2. The old withdrawn record must remain as a separate historical record +3. The entry point should be a single user-facing capability: `重新编辑提交` +4. The implementation path should split by form type: + - dynamic flow form: edit in flow center drawer + - business form: edit in a page/container path +5. Previous manually selected approvers / copy recipients should be carried as defaults and remain editable + +## Goals + +1. Add `重新编辑提交` for withdrawn starter records +2. Let users correct form mistakes before submitting again +3. Preserve withdrawn process history +4. Reuse existing form loading and start-process capabilities where possible +5. Support both dynamic and business forms in the same product capability + +## Non-Goals + +1. Do not reopen the old withdrawn Flowable instance +2. Do not merge new submission into the old withdrawn record +3. Do not copy approval history from the old instance to the new one +4. Do not introduce a dedicated backend "withdraw-resubmit" API in V1 + +## Recommended Approach + +### Option 1: Unified Entry, Split by Form Type + +Description: + +- show one action named `重新编辑提交` +- branch internally by form type +- dynamic flow forms open an editable drawer in flow center +- business forms go through a common restart container page + +Pros: + +- unified user mental model +- minimal duplication +- works for both form types +- avoids forcing every business form page to implement workflow-side participant editing on its own + +Cons: + +- needs a common restart context loader +- needs one new common business-form restart container page + +Conclusion: + +- recommend + +### Option 2: Always Jump to a Full Page + +Description: + +- both dynamic forms and business forms always jump to a dedicated page + +Pros: + +- interaction form is consistent + +Cons: + +- duplicates current dynamic-form drawer flow +- larger change surface + +Conclusion: + +- not recommended for V1 + +### Option 3: Always Stay in started.vue Drawer + +Description: + +- both dynamic forms and business forms are handled inside `started.vue` + +Pros: + +- entry is centralized + +Cons: + +- business forms often depend on route/page context +- harder to keep complex custom forms maintainable + +Conclusion: + +- reject + +## Product Behavior + +### Starter List + +On [started.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/task/started.vue): + +- records with process status `已撤回` should display `重新编辑提交` +- records with `rejectToStarter` should keep the current `重新提交` +- these are different capabilities and must remain separate + +### Old Record Handling + +After successful resubmission: + +- the old record remains `已撤回` +- a new record appears as `进行中` + +### Button Naming + +Use a single starter-facing action name: + +- `重新编辑提交` + +Reason: + +- it makes the user intent explicit +- it distinguishes this flow from `驳回待提交 -> 重新提交` + +## Data Flow Design + +### Shared Source Data + +When the user clicks `重新编辑提交`, frontend should load: + +1. old instance snapshot via `detail(processInstanceId)` +2. current flow definition via `getFlowDetail(flowId)` + +The old instance snapshot provides: + +- previous form data +- previous manually selected participant values stored in submitted process params + +The current flow definition provides: + +- current form type +- current form component path / dynamic form schema +- current selectable participant nodes + +### Participant Default Value Strategy + +Only carry forward values the user explicitly selected before. + +Use old submitted process params such as: + +- `nodeId_assignee_select` + +Do not carry forward: + +- historical approval results +- historical comments +- runtime-derived approval outcomes + +If a previously selected node no longer exists in the current flow definition: + +- ignore the old value + +If a new required selectable node is present in the current definition: + +- require the user to select it again before submit + +## Form-Type Split + +### Dynamic Flow Form + +When `formType=10`: + +- stay in [started.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/task/started.vue) +- open an editable restart drawer +- left side reuses dynamic form rendering +- right side reuses [FlowNodeFormatData.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/form/tools/FlowNodeFormatData.vue) +- old participant selections are injected into `nodeUser` +- submit starts a new process instance + +### Business Form + +When `formType=20`: + +- do not jump directly to each existing business create page as the primary architecture +- add a common restart container page under flow task module +- the container page is responsible for: + - loading the business form create component + - loading the current flow definition + - rendering the right-side participant panel + - orchestrating final submit as a new process start + +Reason: + +- existing business pages usually do not own a common workflow participant panel +- a common restart container keeps workflow behavior centralized and reusable + +## Frontend Component Boundary + +### started.vue + +Responsibilities: + +- expose `重新编辑提交` entry for withdrawn records +- decide route vs drawer by form type +- continue handling current starter list concerns + +### New Restart Context Loader + +Add a shared loader/composable responsible for: + +- fetching old instance detail +- fetching current flow detail +- building initial form values +- building initial participant defaults +- exposing the resolved restart mode + +### FlowNodeFormatData.vue + +Needs a small enhancement: + +- allow external default `nodeUser` injection for restart scenarios + +### New Business Restart Container Page + +Responsibilities: + +- render the business create component dynamically +- render `FlowNodeFormatData` +- merge edited business form data and participant selections +- start a new process instance + +## Existing Capabilities Reused + +### Frontend + +- [dynamicComponent.ts](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/workflow/utils/dynamicComponent.ts) +- [useTaskForm.ts](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/task/composables/useTaskForm.ts) +- [FlowNodeFormatData.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/form/tools/FlowNodeFormatData.vue) +- [startFlow](/Users/lengleng/work/dev1227/pigx-ui-pro/src/api/flow/flow/index.ts) + +### Backend + +V1 should reuse existing APIs: + +- `detail(processInstanceId)` +- `getFlowDetail(flowId)` +- `startProcessInstance / startFlow` + +No dedicated backend restart API is required in V1. + +## Business Form Compatibility + +For business forms to participate in V1: + +- their create component must support loading old data by `processInstanceId` + +Example: + +- [create.vue](/Users/lengleng/work/dev1227/pigx-ui-pro/src/views/flow/oaLeave/create.vue) already supports querying old data with `processInstanceId` + +If some business form cannot load old data this way: + +- it is out of V1 support unless adapted + +## Submit Semantics + +### Dynamic Form Submit + +Payload: + +- edited dynamic form data +- participant selections from `FlowNodeFormatData` + +Behavior: + +- call process start API +- create a new process instance + +### Business Form Submit + +Payload: + +- edited business form data from the business create component +- participant selections from `FlowNodeFormatData` + +Behavior: + +- call business-form submit path that starts a new process instance +- create a new process instance + +## Success and Failure UX + +### Success + +Message: + +- `已重新发起流程` + +Behavior: + +- close restart drawer or leave restart page +- return to starter list +- refresh list + +### Failure + +If current flow definition is unavailable: + +- `当前流程已不可发起` + +If business form component path is invalid: + +- `业务表单不可用,无法重新发起` + +If old participant values are no longer valid: + +- do not block opening +- require reselection at validation time + +## Acceptance Criteria + +1. Withdrawn starter records show `重新编辑提交` +2. Dynamic flow forms open editable restart drawer with old form values +3. Business forms open common restart container with old form values +4. Previous manually selected approvers/copy users are prefilled when possible +5. User can change both form data and participant selections before submit +6. Submit creates a new process instance +7. Old withdrawn record remains unchanged +8. New record appears independently in starter list + +## Risks + +1. Business forms vary in how much they depend on route context +2. Some business create components may not yet support restart-container integration cleanly +3. Participant default values rely on old submitted explicit selections, not on all runtime assignment types + +## Recommended V1 Rollout + +1. Dynamic flow forms first +2. Common restart container for business forms +3. Verify `oaLeave` as the first business-form example +4. Expand to additional business forms that already support `processInstanceId`-based reload + +## Implementation Outline + +The `writing-plans` skill referenced by brainstorming is unavailable in this workspace. As a fallback, implement in this order: + +1. Add withdrawn-record `重新编辑提交` action to starter list +2. Add restart context loader and split logic by form type +3. Add dynamic-form restart drawer path +4. Add business-form restart container page +5. Enhance participant panel to accept default values +6. Verify `oaLeave` and one dynamic form end to end From f759865202ff5f0d885f9a308a01b7f0300a9165 Mon Sep 17 00:00:00 2001 From: PIGCLOUD Date: Thu, 19 Mar 2026 08:41:55 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(pigx-flow-biz):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E6=92=A4=E5=9B=9E=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 ITaskService 接口中添加 withdrawProcessInstance 方法用于撤回流程实例 - 实现 TaskServiceImpl 中的撤回逻辑,包括权限校验和流程状态更新 - 新增 terminateProcessInstance 私有方法统一处理流程终止和撤回操作 - 在 ProcessInstanceRecordVo 中添加撤回相关字段 canWithdraw 和 withdrawDisabledReason - 优化 ProcessInstanceServiceImpl 查询逻辑,批量处理驳回状态和撤回状态判断 - 在 ProcessTaskController 中添加撤回流程实例的接口端点 - 定义流程撤回相关的常量,包括结束原因和执行人状态 --- .../constant/ProcessInstanceConstant.java | 30 +++ .../pigx/flow/vo/ProcessInstanceRecordVo.java | 10 + .../controller/ProcessTaskController.java | 11 + .../pigx/flow/service/ITaskService.java | 8 + .../impl/ProcessInstanceServiceImpl.java | 197 +++++++++++++++--- .../flow/service/impl/TaskServiceImpl.java | 160 ++++++++++---- 6 files changed, 354 insertions(+), 62 deletions(-) diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/constant/ProcessInstanceConstant.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/constant/ProcessInstanceConstant.java index 2ab6f372c..73bad9241 100644 --- a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/constant/ProcessInstanceConstant.java +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/constant/ProcessInstanceConstant.java @@ -60,6 +60,36 @@ public class ProcessInstanceConstant { */ public static final String REJECT_TO_STARTER_VAR = "rejectToStarter"; + /** + * 流程结束原因:拒绝 + */ + public static final String FINISH_REASON_REFUSE = "0"; + + /** + * 流程结束原因:通过 + */ + public static final String FINISH_REASON_PASS = "1"; + + /** + * 流程结束原因:撤回 + */ + public static final String FINISH_REASON_WITHDRAW = "3"; + + /** + * 流程结束原因:终止 + */ + public static final String FINISH_REASON_TERMINATE = "9"; + + /** + * 流程节点执行人状态:撤回 + */ + public static final int ASSIGN_USER_STATUS_WITHDRAW = 3; + + /** + * 流程节点执行人状态:终止 + */ + public static final int ASSIGN_USER_STATUS_TERMINATE = 5; + /** * 多人审批模式:会签 所有审批人都必须同意才能通过,任何一人拒绝则不通过 */ diff --git a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/ProcessInstanceRecordVo.java b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/ProcessInstanceRecordVo.java index 2d7057c62..361bc7a9c 100644 --- a/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/ProcessInstanceRecordVo.java +++ b/pigx-flow/pigx-flow-api/src/main/java/com/pig4cloud/pigx/flow/vo/ProcessInstanceRecordVo.java @@ -44,4 +44,14 @@ public class ProcessInstanceRecordVo extends ProcessInstanceRecord { */ private Boolean hasPrintTemplate; + /** + * 是否允许撤回 + */ + private Boolean canWithdraw; + + /** + * 不可撤回原因 + */ + private String withdrawDisabledReason; + } diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessTaskController.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessTaskController.java index 94f0116d7..efc88cf45 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessTaskController.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/controller/ProcessTaskController.java @@ -144,6 +144,17 @@ public class ProcessTaskController { } + /** + * 撤回流程实例 + * + * @param taskParamDto 撤回参数,包含流程实例ID + * @return R 统一响应对象,操作成功返回撤回信息 + */ + @PostMapping("withdrawProcessInstance") + public R withdrawProcessInstance(@RequestBody TaskParamDto taskParamDto) { + return taskService.withdrawProcessInstance(taskParamDto); + } + /** * 退回任务 *

diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/ITaskService.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/ITaskService.java index b15de35a5..982b7f308 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/ITaskService.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/ITaskService.java @@ -87,6 +87,14 @@ public interface ITaskService { */ R stopProcessInstance(TaskParamDto taskParamDto); + /** + * 撤回流程实例 + * + * @param taskParamDto 任务参数,包含流程实例ID + * @return 操作结果 + */ + R withdrawProcessInstance(TaskParamDto taskParamDto); + /** * 退回任务 *

diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessInstanceServiceImpl.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessInstanceServiceImpl.java index 1b8331bf0..d9a3c917d 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessInstanceServiceImpl.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/ProcessInstanceServiceImpl.java @@ -18,11 +18,10 @@ import com.pig4cloud.pigx.common.security.service.PigxUser; import com.pig4cloud.pigx.common.security.util.SecurityUtils; import com.pig4cloud.pigx.flow.constant.NodeStatusEnum; import com.pig4cloud.pigx.flow.constant.NodeUserTypeEnum; +import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant; import com.pig4cloud.pigx.flow.dto.*; +import com.pig4cloud.pigx.flow.entity.*; import com.pig4cloud.pigx.flow.entity.Process; -import com.pig4cloud.pigx.flow.entity.ProcessCopy; -import com.pig4cloud.pigx.flow.entity.ProcessInstanceRecord; -import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord; import com.pig4cloud.pigx.flow.service.*; import com.pig4cloud.pigx.flow.support.utils.NodeFormatUtil; import com.pig4cloud.pigx.flow.support.utils.NodeUtil; @@ -42,6 +41,7 @@ import org.flowable.engine.history.HistoricActivityInstanceQuery; import org.flowable.engine.runtime.ProcessInstance; import org.flowable.task.api.Task; import org.flowable.task.api.TaskQuery; +import org.flowable.variable.api.history.HistoricVariableInstance; import org.springframework.stereotype.Service; import java.time.ZoneId; @@ -82,6 +82,8 @@ public class ProcessInstanceServiceImpl implements IProcessInstanceService { private final IProcessNodeRecordService processNodeRecordService; + private final IProcessNodeRecordAssignUserService processNodeRecordAssignUserService; + private final ObjectMapper objectMapper; /** @@ -374,36 +376,59 @@ public class ProcessInstanceServiceImpl implements IProcessInstanceService { .collect(Collectors.toSet()); List processList = CollUtil.isEmpty(flowIds) ? Collections.emptyList() : processService.lambdaQuery().in(Process::getFlowId, flowIds).list(); + Set processInstanceIds = voList.stream() + .map(ProcessInstanceRecordVo::getProcessInstanceId) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toSet()); + Map> assignUserMap = queryAssignUserMap(processInstanceIds); + + // 批量查询进行中流程的 rejectToStarter 变量,避免 N+1 + Set runningInstanceIds = voList.stream() + .filter(vo -> Objects.equals(vo.getStatus(), NodeStatusEnum.JXZ.getCode())) + .map(ProcessInstanceRecordVo::getProcessInstanceId) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toSet()); + + Set rejectToStarterIds = batchQueryRejectToStarter(runningInstanceIds); + + // 批量查询驳回待提交的 root 任务,避免 N+1 + Map resubmitTaskMap = Collections.emptyMap(); + if (!rejectToStarterIds.isEmpty()) { + resubmitTaskMap = taskService.createTaskQuery() + .processInstanceIdIn(rejectToStarterIds) + .taskDefinitionKey("root") + .list() + .stream() + .collect(Collectors.toMap(Task::getProcessInstanceId, Task::getId, (a, b) -> a)); + } + + // 构建打印模板 Map,避免循环内 stream 遍历 + Map processMap = processList.stream() + .collect(Collectors.toMap(p -> p.getFlowId().toLowerCase(), p -> p, (a, b) -> a)); for (ProcessInstanceRecordVo vo : voList) { - try { - // 从 Flowable 变量中获取 rejectToStarter 状态 - Object rejectVar = runtimeService.getVariable( - vo.getProcessInstanceId(), - "rejectToStarter" - ); - vo.setRejectToStarter(Boolean.TRUE.equals(rejectVar)); + boolean rejectToStarter = false; + if (Objects.equals(vo.getStatus(), NodeStatusEnum.JXZ.getCode())) { + rejectToStarter = rejectToStarterIds.contains(vo.getProcessInstanceId()); + vo.setRejectToStarter(rejectToStarter); - // 如果是驳回状态,获取 root 任务 ID 用于重新提交 - if (Boolean.TRUE.equals(vo.getRejectToStarter())) { - Task task = taskService.createTaskQuery() - .processInstanceId(vo.getProcessInstanceId()) - .taskDefinitionKey("root") - .singleResult(); - if (task != null) { - vo.setResubmitTaskId(task.getId()); - } + if (rejectToStarter) { + vo.setResubmitTaskId(resubmitTaskMap.get(vo.getProcessInstanceId())); } - } catch (Exception e) { - // 流程可能已结束,设置为 false + } else { vo.setRejectToStarter(false); } - // 检查是否配置了打印模板(不依赖运行时变量,无需放在 try-catch 内) - processList.stream() - .filter(process -> process.getFlowId().equalsIgnoreCase(vo.getFlowId())) - .findFirst() - .ifPresent(process -> vo.setHasPrintTemplate(StrUtil.isNotBlank(process.getPrintTemplate()))); + WithdrawState withdrawState = buildWithdrawState(vo, vo.getRejectToStarter(), + assignUserMap.getOrDefault(vo.getProcessInstanceId(), Collections.emptyList())); + vo.setCanWithdraw(withdrawState.canWithdraw); + vo.setWithdrawDisabledReason(withdrawState.disabledReason); + + // 检查是否配置了打印模板 + Process matchedProcess = processMap.get(vo.getFlowId().toLowerCase()); + if (matchedProcess != null) { + vo.setHasPrintTemplate(StrUtil.isNotBlank(matchedProcess.getPrintTemplate())); + } } // 构建返回的分页对象 @@ -549,10 +574,128 @@ public class ProcessInstanceServiceImpl implements IProcessInstanceService { Node nodeDto = objectMapper.readValue(process, Node.class); Map formPerms1 = nodeDto.getFormPerms(); + boolean rejectToStarter = Objects.equals(processInstanceRecord.getStatus(), NodeStatusEnum.JXZ.getCode()) + && isRejectToStarter(processInstanceId); + WithdrawState withdrawState = buildWithdrawState(processInstanceRecord, rejectToStarter, + queryAssignUserMap(Collections.singleton(processInstanceId)) + .getOrDefault(processInstanceId, Collections.emptyList())); - Dict set = Dict.create().set(ProcessInstanceRecord.Fields.processInstanceId, processInstanceId).set(ProcessInstanceRecord.Fields.status, processInstanceRecord.getStatus()).set("process", oaForms.getProcess()).set("formItems", oaForms.getFormItems()).set("formData", formData).set("formConfig", oaForms.getFormConfig()).set("formPerms", formPerms1); + Dict set = Dict.create() + .set(ProcessInstanceRecord.Fields.processInstanceId, processInstanceId) + .set(ProcessInstanceRecord.Fields.status, processInstanceRecord.getStatus()) + .set("finishReason", processInstanceRecord.getFinishReason()) + .set("rejectToStarter", rejectToStarter) + .set("canWithdraw", withdrawState.canWithdraw) + .set("withdrawDisabledReason", withdrawState.disabledReason) + .set("process", oaForms.getProcess()) + .set("formItems", oaForms.getFormItems()) + .set("formData", formData) + .set("formConfig", oaForms.getFormConfig()) + .set("formPerms", formPerms1); return R.ok(set); } + private Map> queryAssignUserMap(Set processInstanceIds) { + if (CollUtil.isEmpty(processInstanceIds)) { + return Collections.emptyMap(); + } + + return processNodeRecordAssignUserService.lambdaQuery() + .in(ProcessNodeRecordAssignUser::getProcessInstanceId, processInstanceIds) + .ne(ProcessNodeRecordAssignUser::getNodeId, "root") + .list() + .stream() + .collect(Collectors.groupingBy(ProcessNodeRecordAssignUser::getProcessInstanceId)); + } + + private boolean isRejectToStarter(String processInstanceId) { + try { + Object rejectVar = runtimeService.getVariable(processInstanceId, + ProcessInstanceConstant.REJECT_TO_STARTER_VAR); + return Boolean.TRUE.equals(rejectVar); + } catch (Exception e) { + log.debug("查询驳回待提交状态失败: processInstanceId={}, error={}", processInstanceId, e.getMessage()); + return false; + } + } + + /** + * 批量查询流程实例的驳回到发起人状态 + * + * @param processInstanceIds 流程实例ID集合 + * @return 处于驳回待提交状态的流程实例ID集合 + */ + private Set batchQueryRejectToStarter(Set processInstanceIds) { + if (CollUtil.isEmpty(processInstanceIds)) { + return Collections.emptySet(); + } + try { + return historyService.createHistoricVariableInstanceQuery() + .processInstanceIds(processInstanceIds) + .variableName(ProcessInstanceConstant.REJECT_TO_STARTER_VAR) + .list() + .stream() + .filter(v -> Boolean.TRUE.equals(v.getValue())) + .map(HistoricVariableInstance::getProcessInstanceId) + .collect(Collectors.toSet()); + } catch (Exception e) { + log.warn("批量查询驳回状态失败,降级为逐条查询: {}", e.getMessage()); + return processInstanceIds.stream() + .filter(this::isRejectToStarter) + .collect(Collectors.toSet()); + } + } + + private WithdrawState buildWithdrawState(ProcessInstanceRecord processInstanceRecord, Boolean rejectToStarter, + List assignUsers) { + if (!Objects.equals(processInstanceRecord.getStatus(), NodeStatusEnum.JXZ.getCode())) { + return new WithdrawState(false, getFinishedProcessReason(processInstanceRecord.getFinishReason())); + } + + if (Boolean.TRUE.equals(rejectToStarter)) { + return new WithdrawState(false, "驳回待提交状态不可撤回"); + } + + boolean hasProcessedApproval = assignUsers.stream().anyMatch(this::isProcessedAssignUser); + if (hasProcessedApproval) { + return new WithdrawState(false, "首个审批节点已处理,无法撤回"); + } + + return new WithdrawState(true, null); + } + + private boolean isProcessedAssignUser(ProcessNodeRecordAssignUser assignUser) { + return assignUser.getEndTime() != null + || !Objects.equals(assignUser.getStatus(), NodeStatusEnum.JXZ.getCode()); + } + + private String getFinishedProcessReason(String finishReason) { + if (StrUtil.equals(finishReason, ProcessInstanceConstant.FINISH_REASON_WITHDRAW)) { + return "流程已撤回"; + } + if (StrUtil.equals(finishReason, ProcessInstanceConstant.FINISH_REASON_TERMINATE)) { + return "流程已终止"; + } + if (StrUtil.equals(finishReason, ProcessInstanceConstant.FINISH_REASON_PASS)) { + return "流程已通过"; + } + if (StrUtil.equals(finishReason, ProcessInstanceConstant.FINISH_REASON_REFUSE)) { + return "流程已拒绝"; + } + return "流程已结束"; + } + + private static final class WithdrawState { + + private final boolean canWithdraw; + + private final String disabledReason; + + private WithdrawState(boolean canWithdraw, String disabledReason) { + this.canWithdraw = canWithdraw; + this.disabledReason = disabledReason; + } + } + } diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImpl.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImpl.java index 6886c467f..ee7a251d9 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImpl.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/service/impl/TaskServiceImpl.java @@ -10,14 +10,13 @@ import com.pig4cloud.pigx.common.core.util.R; import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder; import com.pig4cloud.pigx.common.security.util.SecurityUtils; import com.pig4cloud.pigx.flow.constant.NodeStatusEnum; +import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant; import com.pig4cloud.pigx.flow.dto.IndexPageStatistics; import com.pig4cloud.pigx.flow.dto.Node; import com.pig4cloud.pigx.flow.dto.TaskParamDto; import com.pig4cloud.pigx.flow.dto.TaskResultDto; +import com.pig4cloud.pigx.flow.entity.*; import com.pig4cloud.pigx.flow.entity.Process; -import com.pig4cloud.pigx.flow.entity.ProcessCopy; -import com.pig4cloud.pigx.flow.entity.ProcessInstanceRecord; -import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser; import com.pig4cloud.pigx.flow.service.*; import com.pig4cloud.pigx.flow.support.utils.NodeUtil; import lombok.RequiredArgsConstructor; @@ -67,6 +66,8 @@ public class TaskServiceImpl implements ITaskService { private final IProcessInstanceRecordService processInstanceRecordService; + private final IProcessNodeRecordService processNodeRecordService; + private final IFlowableEngineService flowableEngineService; private final TaskService taskService; @@ -476,39 +477,60 @@ public class TaskServiceImpl implements ITaskService { */ @Override public R stopProcessInstance(TaskParamDto taskParamDto) { - String reqProcessInstanceId = taskParamDto.getProcessInstanceId(); - List allStopProcessInstanceIdList = getAllStopProcessInstanceIdList(reqProcessInstanceId); - CollUtil.reverse(allStopProcessInstanceIdList); - allStopProcessInstanceIdList.add(reqProcessInstanceId); - allStopProcessInstanceIdList.forEach(processInstanceId -> { - // 查询流程实例 - ProcessInstance processInstance = runtimeService.createProcessInstanceQuery() - .processInstanceId(processInstanceId) - .processInstanceTenantId(TenantContextHolder.getTenantId().toString()) - .singleResult(); - if (Optional.ofNullable(processInstance).isPresent()) { - // 查询执行实例 - List executionIds = runtimeService.createExecutionQuery() - .parentId(processInstanceId) - .list() - .stream() - .map(Execution::getId) - .toList(); - // 更改活动状态为结束 - runtimeService.createChangeActivityStateBuilder() - .moveExecutionsToSingleActivityId(executionIds, "end") - .changeState(); - } - }); + return terminateProcessInstance(taskParamDto.getProcessInstanceId(), + ProcessInstanceConstant.FINISH_REASON_TERMINATE); + } - // 停止流程实例后,更新流程实例记录状态 - processInstanceRecordService.lambdaUpdate() - .set(ProcessInstanceRecord::getStatus, NodeStatusEnum.YJS.getCode()) - .set(ProcessInstanceRecord::getFinishReason, String.valueOf(NodeStatusEnum.YZZ.getCode())) - .set(ProcessInstanceRecord::getEndTime, new Date()) - .eq(ProcessInstanceRecord::getProcessInstanceId, reqProcessInstanceId) - .update(new ProcessInstanceRecord()); - return R.ok(); + /** + * 撤回流程 + * + * @param taskParamDto 任务参数,包含流程实例ID + * @return R 操作结果 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public R withdrawProcessInstance(TaskParamDto taskParamDto) { + String processInstanceId = taskParamDto.getProcessInstanceId(); + if (StrUtil.isBlank(processInstanceId)) { + return R.failed("流程实例ID不能为空"); + } + + long userId = SecurityUtils.getUser().getId(); + ProcessInstanceRecord processInstanceRecord = processInstanceRecordService.lambdaQuery() + .eq(ProcessInstanceRecord::getProcessInstanceId, processInstanceId) + .one(); + if (processInstanceRecord == null + || !Objects.equals(processInstanceRecord.getStatus(), NodeStatusEnum.JXZ.getCode())) { + return R.failed("流程不存在或已结束"); + } + + if (!Objects.equals(processInstanceRecord.getUserId(), userId)) { + return R.failed("仅发起人可撤回流程"); + } + + try { + Object rejectToStarter = runtimeService.getVariable(processInstanceId, + ProcessInstanceConstant.REJECT_TO_STARTER_VAR); + if (Boolean.TRUE.equals(rejectToStarter)) { + return R.failed("驳回待提交状态不可撤回"); + } + } catch (Exception e) { + log.warn("查询撤回状态变量失败: processInstanceId={}, error={}", processInstanceId, e.getMessage()); + } + + boolean hasProcessedApproval = processNodeRecordAssignUserService.lambdaQuery() + .eq(ProcessNodeRecordAssignUser::getProcessInstanceId, processInstanceId) + .ne(ProcessNodeRecordAssignUser::getNodeId, "root") + .and(wrapper -> wrapper + .isNotNull(ProcessNodeRecordAssignUser::getEndTime) + .or() + .ne(ProcessNodeRecordAssignUser::getStatus, NodeStatusEnum.JXZ.getCode())) + .exists(); + if (hasProcessedApproval) { + return R.failed("首个审批节点已处理,无法撤回"); + } + + return terminateProcessInstance(processInstanceId, ProcessInstanceConstant.FINISH_REASON_WITHDRAW); } /** @@ -626,4 +648,72 @@ public class TaskServiceImpl implements ITaskService { return collect; } + private R terminateProcessInstance(String reqProcessInstanceId, String finishReason) { + if (StrUtil.isBlank(reqProcessInstanceId)) { + return R.failed("流程实例ID不能为空"); + } + + List allStopProcessInstanceIdList = getAllStopProcessInstanceIdList(reqProcessInstanceId); + CollUtil.reverse(allStopProcessInstanceIdList); + allStopProcessInstanceIdList.add(reqProcessInstanceId); + + Date now = new Date(); + int assignUserStatus = Objects.equals(finishReason, ProcessInstanceConstant.FINISH_REASON_WITHDRAW) + ? ProcessInstanceConstant.ASSIGN_USER_STATUS_WITHDRAW + : ProcessInstanceConstant.ASSIGN_USER_STATUS_TERMINATE; + + // 先更新本地记录,确保流程结束通知能拿到准确的 finishReason + processInstanceRecordService.lambdaUpdate() + .set(ProcessInstanceRecord::getStatus, NodeStatusEnum.YJS.getCode()) + .set(ProcessInstanceRecord::getFinishReason, finishReason) + .set(ProcessInstanceRecord::getEndTime, now) + .in(ProcessInstanceRecord::getProcessInstanceId, allStopProcessInstanceIdList) + .update(new ProcessInstanceRecord()); + + processNodeRecordService.lambdaUpdate() + .set(ProcessNodeRecord::getStatus, NodeStatusEnum.YJS.getCode()) + .set(ProcessNodeRecord::getEndTime, now) + .in(ProcessNodeRecord::getProcessInstanceId, allStopProcessInstanceIdList) + .eq(ProcessNodeRecord::getStatus, NodeStatusEnum.JXZ.getCode()) + .update(new ProcessNodeRecord()); + + processNodeRecordAssignUserService.lambdaUpdate() + .set(ProcessNodeRecordAssignUser::getStatus, assignUserStatus) + .set(ProcessNodeRecordAssignUser::getEndTime, Convert.toLocalDateTime(now)) + .set(ProcessNodeRecordAssignUser::getTaskType, + Objects.equals(finishReason, ProcessInstanceConstant.FINISH_REASON_WITHDRAW) + ? "WITHDRAW" + : "TERMINATE") + .in(ProcessNodeRecordAssignUser::getProcessInstanceId, allStopProcessInstanceIdList) + .eq(ProcessNodeRecordAssignUser::getStatus, NodeStatusEnum.JXZ.getCode()) + .update(new ProcessNodeRecordAssignUser()); + + allStopProcessInstanceIdList.forEach(processInstanceId -> { + ProcessInstance processInstance = runtimeService.createProcessInstanceQuery() + .processInstanceId(processInstanceId) + .processInstanceTenantId(TenantContextHolder.getTenantId().toString()) + .singleResult(); + if (processInstance == null) { + return; + } + + List executionIds = runtimeService.createExecutionQuery() + .parentId(processInstanceId) + .list() + .stream() + .map(Execution::getId) + .toList(); + if (CollUtil.isEmpty(executionIds)) { + log.warn("流程实例缺少可迁移执行流,跳过状态迁移: processInstanceId={}", processInstanceId); + return; + } + + runtimeService.createChangeActivityStateBuilder() + .moveExecutionsToSingleActivityId(executionIds, "end") + .changeState(); + }); + + return R.ok(); + } + } From fe64bf38c22a23c99472c3af53b044c3fe725e91 Mon Sep 17 00:00:00 2001 From: PIGCLOUD Date: Fri, 20 Mar 2026 11:27:26 +0800 Subject: [PATCH 4/6] =?UTF-8?q?refactor(pigx-common-datasource):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=8A=A8=E6=80=81=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 DruidDataSourceProperties 配置类,直接使用 DruidDataSourceWrapper - 移除冗余的配置属性和创建逻辑,提升代码简洁性 --- .../DynamicDataSourceAutoConfiguration.java | 43 +++++++++---------- .../config/DruidDataSourceProperties.java | 41 ------------------ .../config/JdbcDynamicDataSourceProvider.java | 16 ++++--- .../config/MasterDataSourceProvider.java | 27 +++--------- 4 files changed, 36 insertions(+), 91 deletions(-) delete mode 100644 pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/DruidDataSourceProperties.java diff --git a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/DynamicDataSourceAutoConfiguration.java b/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/DynamicDataSourceAutoConfiguration.java index f86cbae6c..2f973d413 100644 --- a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/DynamicDataSourceAutoConfiguration.java +++ b/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/DynamicDataSourceAutoConfiguration.java @@ -17,6 +17,7 @@ package com.pig4cloud.pigx.common.datasource; +import com.alibaba.druid.spring.boot3.autoconfigure.DruidDataSourceWrapper; import com.baomidou.dynamic.datasource.creator.DataSourceCreator; import com.baomidou.dynamic.datasource.creator.DefaultDataSourceCreator; import com.baomidou.dynamic.datasource.creator.druid.DruidDataSourceCreator; @@ -29,9 +30,10 @@ import com.pig4cloud.pigx.common.datasource.config.*; import lombok.RequiredArgsConstructor; import org.jasypt.encryption.StringEncryptor; import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -50,40 +52,41 @@ import java.util.List; @RequiredArgsConstructor @Import(DynamicLogConfiguration.class) @AutoConfigureAfter(DataSourceAutoConfiguration.class) -@EnableConfigurationProperties(DruidDataSourceProperties.class) public class DynamicDataSourceAutoConfiguration { + @Value("${spring.datasource.druid.query-ds-sql:select * from gen_datasource_conf where del_flag = ?}") + private String queryDsSql; + + /** + * 主数据源,直接使用 DruidDataSourceWrapper 绑定 spring.datasource.druid 的完整配置 + */ + @Bean + @ConfigurationProperties("spring.datasource.druid") + public DruidDataSourceWrapper masterDruidDataSource() { + return new DruidDataSourceWrapper(); + } + /** * 获取动态数据源提供者 - * - * @param defaultDataSourceCreator 默认数据源创建器 - * @param stringEncryptor 字符串加密器 - * @param properties 数据源属性 - * @return 动态数据源提供者 */ @Bean public DynamicDataSourceProvider dynamicDataSourceProvider(DefaultDataSourceCreator defaultDataSourceCreator, - StringEncryptor stringEncryptor, DruidDataSourceProperties properties) { - return new JdbcDynamicDataSourceProvider(defaultDataSourceCreator, stringEncryptor, properties); + StringEncryptor stringEncryptor, + DruidDataSourceWrapper masterDruidDataSource) { + return new JdbcDynamicDataSourceProvider(defaultDataSourceCreator, stringEncryptor, masterDruidDataSource, queryDsSql); } /** * 主数据源提供程序 - * - * @param defaultDataSourceCreator 默认数据源创建者 - * @param properties 性能 - * @return {@link DynamicDataSourceProvider } */ @Bean - public DynamicDataSourceProvider masterDataSourceProvider(DefaultDataSourceCreator defaultDataSourceCreator, DruidDataSourceProperties properties) { - return new MasterDataSourceProvider(defaultDataSourceCreator, properties); + public DynamicDataSourceProvider masterDataSourceProvider(DefaultDataSourceCreator defaultDataSourceCreator, + DruidDataSourceWrapper masterDruidDataSource) { + return new MasterDataSourceProvider(defaultDataSourceCreator, masterDruidDataSource); } /** * 获取默认数据源创建器 - * - * @param druidDataSourceCreator Druid数据源创建器 - * @return 默认数据源创建器 */ @Bean public DefaultDataSourceCreator defaultDataSourceCreator(DruidDataSourceCreator druidDataSourceCreator) { @@ -96,8 +99,6 @@ public class DynamicDataSourceAutoConfiguration { /** * 获取数据源处理器 - * - * @return 数据源处理器 */ @Bean public DsProcessor dsProcessor(BeanFactory beanFactory) { @@ -114,8 +115,6 @@ public class DynamicDataSourceAutoConfiguration { /** * 获取清除TTL数据源过滤器 - * - * @return 清除TTL数据源过滤器 */ @Bean public ClearTtlDataSourceFilter clearTtlDsFilter() { diff --git a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/DruidDataSourceProperties.java b/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/DruidDataSourceProperties.java deleted file mode 100644 index 88c388559..000000000 --- a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/DruidDataSourceProperties.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.pig4cloud.pigx.common.datasource.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** - * @author lengleng - * @date 2019-05-14 - *

- * 参考DruidDataSourceWrapper - */ -@Data -@ConfigurationProperties("spring.datasource.druid") -public class DruidDataSourceProperties { - - /** - * 数据源用户名 - */ - private String username; - - /** - * 数据源密码 - */ - private String password; - - /** - * jdbcurl - */ - private String url; - - /** - * 数据源驱动 - */ - private String driverClassName; - - /** - * 查询数据源的SQL - */ - private String queryDsSql = "select * from gen_datasource_conf where del_flag = '0'"; - -} diff --git a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/JdbcDynamicDataSourceProvider.java b/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/JdbcDynamicDataSourceProvider.java index ace487ed5..7b1b7bb3a 100644 --- a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/JdbcDynamicDataSourceProvider.java +++ b/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/JdbcDynamicDataSourceProvider.java @@ -17,6 +17,7 @@ package com.pig4cloud.pigx.common.datasource.config; +import com.alibaba.druid.spring.boot3.autoconfigure.DruidDataSourceWrapper; import com.baomidou.dynamic.datasource.creator.DataSourceProperty; import com.baomidou.dynamic.datasource.creator.DefaultDataSourceCreator; import com.baomidou.dynamic.datasource.creator.druid.DruidConfig; @@ -46,16 +47,19 @@ public class JdbcDynamicDataSourceProvider extends AbstractJdbcDataSourceProvide private static final String SQL_LOG_FILTER = "sqlLogFilter"; - private final DruidDataSourceProperties properties; + private static final String DEFAULT_QUERY_DS_SQL = "select * from gen_datasource_conf where del_flag = ?"; + + private final String queryDsSql; private final StringEncryptor stringEncryptor; public JdbcDynamicDataSourceProvider(DefaultDataSourceCreator defaultDataSourceCreator, - StringEncryptor stringEncryptor, DruidDataSourceProperties properties) { - super(defaultDataSourceCreator, properties.getDriverClassName(), properties.getUrl(), properties.getUsername(), - properties.getPassword()); + StringEncryptor stringEncryptor, DruidDataSourceWrapper masterDataSource, + String queryDsSql) { + super(defaultDataSourceCreator, masterDataSource.getDriverClassName(), masterDataSource.getUrl(), + masterDataSource.getUsername(), masterDataSource.getPassword()); this.stringEncryptor = stringEncryptor; - this.properties = properties; + this.queryDsSql = queryDsSql != null ? queryDsSql : DEFAULT_QUERY_DS_SQL; } /** @@ -72,7 +76,7 @@ public class JdbcDynamicDataSourceProvider extends AbstractJdbcDataSourceProvide try { // 使用PreparedStatement防止SQL注入,并添加租户隔离 - PreparedStatement pstmt = statement.getConnection().prepareStatement(properties.getQueryDsSql()); + PreparedStatement pstmt = statement.getConnection().prepareStatement(queryDsSql); pstmt.setString(1, YesNoEnum.NO.getCode()); // del_flag参数 ResultSet rs = pstmt.executeQuery(); diff --git a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/MasterDataSourceProvider.java b/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/MasterDataSourceProvider.java index b93319c53..9262ec627 100644 --- a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/MasterDataSourceProvider.java +++ b/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/MasterDataSourceProvider.java @@ -17,9 +17,7 @@ package com.pig4cloud.pigx.common.datasource.config; -import com.baomidou.dynamic.datasource.creator.DataSourceProperty; import com.baomidou.dynamic.datasource.creator.DefaultDataSourceCreator; -import com.baomidou.dynamic.datasource.creator.druid.DruidConfig; import com.baomidou.dynamic.datasource.provider.AbstractDataSourceProvider; import javax.sql.DataSource; @@ -32,23 +30,17 @@ import static com.pig4cloud.pigx.common.datasource.support.DataSourceConstants.D * @author lengleng * @date 2025/1/14 *

- * 保证原有的 druid 配置有效性,只需要增加其他扩展数据源即可 + * 直接使用 DruidDataSourceWrapper 作为主数据源,保持 spring.datasource.druid 配置的完整性 */ public class MasterDataSourceProvider extends AbstractDataSourceProvider { - private static final String SQL_LOG_FILTER = "sqlLogFilter"; + private final DataSource masterDataSource; - private final DruidDataSourceProperties properties; - - private final DefaultDataSourceCreator defaultDataSourceCreator; - - public MasterDataSourceProvider(DefaultDataSourceCreator defaultDataSourceCreator, DruidDataSourceProperties properties) { + public MasterDataSourceProvider(DefaultDataSourceCreator defaultDataSourceCreator, DataSource masterDataSource) { super(defaultDataSourceCreator); - this.properties = properties; - this.defaultDataSourceCreator = defaultDataSourceCreator; + this.masterDataSource = masterDataSource; } - /** * 加载所有数据源 * @@ -57,16 +49,7 @@ public class MasterDataSourceProvider extends AbstractDataSourceProvider { @Override public Map loadDataSources() { Map map = new HashMap<>(); - // 添加默认主数据源 - DataSourceProperty property = new DataSourceProperty(); - property.setUsername(properties.getUsername()); - property.setPassword(properties.getPassword()); - property.setUrl(properties.getUrl()); - - DruidConfig druidConfig = new DruidConfig(); - druidConfig.setProxyFilters(SQL_LOG_FILTER); - property.setDruid(druidConfig); - map.put(DS_MASTER, defaultDataSourceCreator.createDataSource(property)); + map.put(DS_MASTER, masterDataSource); return map; } } From 9d198266d74a25d259c5be987c1f2f1c2f67707d Mon Sep 17 00:00:00 2001 From: PIGCLOUD Date: Thu, 19 Mar 2026 18:05:54 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(db):=20=E6=9B=B4=E6=96=B0=20config=5Fin?= =?UTF-8?q?fo=20=E8=A1=A8=E4=B8=AD=E7=9A=84=E9=85=8D=E7=BD=AE=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=92=8C=20MD5=20=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/6pigxx_config.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/6pigxx_config.sql b/db/6pigxx_config.sql index 54ea84a5b..c522ac131 100644 --- a/db/6pigxx_config.sql +++ b/db/6pigxx_config.sql @@ -36,8 +36,8 @@ INSERT INTO `config_info` (`id`, `data_id`, `group_id`, `content`, `md5`, `gmt_c `src_ip`, `app_name`, `tenant_id`, `c_desc`, `c_use`, `effect`, `type`, `c_schema`, `encrypted_data_key`) VALUES (1, 'application-dev.yml', 'DEFAULT_GROUP', - '# 配置文件加密根密码\njasypt:\n encryptor:\n password: pigx\n algorithm: PBEWithMD5AndDES\n iv-generator-classname: org.jasypt.iv.NoIvGenerator\n\nserver:\n undertow:\n max-http-post-size: 100MB\n\nspring:\n data:\n redis:\n host: pigx-redis\n servlet:\n multipart:\n max-file-size: ${server.undertow.max-http-post-size}\n max-request-size: ${server.undertow.max-http-post-size}\n cloud:\n sentinel:\n eager: true\n transport:\n dashboard: pigx-sentinel:5020\n openfeign:\n sentinel:\n enabled: true\n okhttp:\n enabled: true\n httpclient:\n enabled: false\n connection-timeout: 20000\n compression:\n request:\n enabled: true\n response:\n enabled: true\n\n# 端点对外暴露\nmanagement:\n endpoints:\n web:\n exposure:\n include: \' *\' \n endpoint:\n restart:\n access: unrestricted\n health:\n show-details: ALWAYS\n\n#开启灰度\ngray:\n rule:\n enabled: true\n\n# mybatis-plus 配置\nmybatis-plus:\n tenant-enable: ture\n mapper-locations: classpath:/mapper/*Mapper.xml\n global-config:\n capitalMode: true\n banner: false\n db-config:\n id-type: auto\n where-strategy: not_empty\n insert-strategy: not_empty\n update-strategy: not_null\n type-handlers-package: com.pig4cloud.pigx.common.data.handler\n configuration:\n jdbc-type-for-null: \' null\'\n call-setters-on-nulls: true\n shrink-whitespaces-in-sql: true\nmybatis-plus-join:\n banner: false #关闭连表查询组件banner', - '90942d7d4856ccba068fe5847643737d', '2022-12-16 10:44:25', '2023-08-14 11:44:19', 'nacos', '127.0.0.1', '', + '# 配置文件加密根密码\njasypt:\n encryptor:\n password: pigx\n algorithm: PBEWithMD5AndDES\n iv-generator-classname: org.jasypt.iv.NoIvGenerator\n\nserver:\n undertow:\n max-http-post-size: 100MB\n\nspring:\n data:\n redis:\n host: pigx-redis\n servlet:\n multipart:\n max-file-size: ${server.undertow.max-http-post-size}\n max-request-size: ${server.undertow.max-http-post-size}\n cloud:\n sentinel:\n eager: true\n transport:\n dashboard: pigx-sentinel:5020\n openfeign:\n sentinel:\n enabled: true\n okhttp:\n enabled: true\n httpclient:\n enabled: false\n connection-timeout: 20000\n compression:\n request:\n enabled: true\n response:\n enabled: true\n\n# 端点对外暴露\nmanagement:\n endpoints:\n web:\n exposure:\n include: \' *\' \n endpoint:\n restart:\n access: unrestricted\n health:\n show-details: ALWAYS\n\n#开启灰度\ngray:\n rule:\n enabled: true\n\n# mybatis-plus 配置\nmybatis-plus:\n mapper-locations: classpath:/mapper/*Mapper.xml\n global-config:\n capitalMode: true\n banner: false\n db-config:\n id-type: auto\n where-strategy: not_empty\n insert-strategy: not_empty\n update-strategy: not_null\n type-handlers-package: com.pig4cloud.pigx.common.data.handler\n configuration:\n jdbc-type-for-null: \'null\'\n call-setters-on-nulls: true\n shrink-whitespaces-in-sql: true\nmybatis-plus-join:\n banner: false #关闭连表查询组件banner', + 'a7def187f0e0b8a790cc19d9e4e94b38', '2022-12-16 10:44:25', '2023-08-14 11:44:19', 'nacos', '127.0.0.1', '', 'public', '', '', '', 'yaml', '', ''); INSERT INTO `config_info` (`id`, `data_id`, `group_id`, `content`, `md5`, `gmt_create`, `gmt_modified`, `src_user`, `src_ip`, `app_name`, `tenant_id`, `c_desc`, `c_use`, `effect`, `type`, `c_schema`, `encrypted_data_key`) VALUES (2, 'pigx-auth-dev.yml', 'DEFAULT_GROUP', '# 数据源\nspring:\n freemarker:\n allow-request-override: false\n allow-session-override: false\n cache: true\n charset: UTF-8\n check-template-location: true\n content-type: text/html\n enabled: true\n expose-request-attributes: false\n expose-session-attributes: false\n expose-spring-macro-helpers: true\n prefer-file-system-access: true\n suffix: .ftl\n template-loader-path: classpath:/templates/\n\n# 前端登录验证码\nsecurity:\n encodeKey: \'pigxpigxpigxpigx\'\n# 验证码相关配置参考: http://t.cn/A647jEdu\naj:\n captcha:\n cache-type: redis\n water-mark: pig4cloud', 'bb6bd9b9e21afdec3d6c88636387df96', '2022-12-16 10:44:25', '2024-04-20 22:47:21', 'nacos', '127.0.0.1', '', 'public', '', '', '', 'yaml', '', ''); INSERT INTO `config_info` (`id`, `data_id`, `group_id`, `content`, `md5`, `gmt_create`, `gmt_modified`, `src_user`, `src_ip`, `app_name`, `tenant_id`, `c_desc`, `c_use`, `effect`, `type`, `c_schema`, `encrypted_data_key`) VALUES (3, 'pigx-codegen-dev.yml', 'DEFAULT_GROUP', '# 数据源\nspring:\n datasource:\n type: com.alibaba.druid.pool.DruidDataSource\n druid:\n driver-class-name: com.mysql.cj.jdbc.Driver\n username: ${MYSQL_USER:root}\n password: ${MYSQL_PWD:root}\n url: jdbc:mysql://${MYSQL_HOST:pigx-mysql}:${MYSQL_PORT:3306}/${MYSQL_DB:pigxx_codegen}?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8&allowMultiQueries=true&allowPublicKeyRetrieval=true\n resources:\n static-locations: classpath:/static/,classpath:/views/\n', 'ed6de06e34e2634c28a24e8a1db99b06', '2022-12-16 10:44:25', '2024-02-22 15:08:09', 'nacos', '127.0.0.1', '', 'public', '', '', '', 'yaml', '', ''); From 66e55f32587be994a0dbe00b7bf7268f8b03d38b Mon Sep 17 00:00:00 2001 From: PIGCLOUD Date: Fri, 20 Mar 2026 11:32:13 +0800 Subject: [PATCH 6/6] =?UTF-8?q?refactor(pigx-common-datasource):=20?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E5=8A=A8=E6=80=81=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 DEFAULT_QUERY_DS_SQL 常量定义 - 直接使用传入的 queryDsSql 参数,简化条件判断逻辑 - 优化构造函数参数初始化方式 --- .../datasource/config/JdbcDynamicDataSourceProvider.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/JdbcDynamicDataSourceProvider.java b/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/JdbcDynamicDataSourceProvider.java index 7b1b7bb3a..4d6d9a1f8 100644 --- a/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/JdbcDynamicDataSourceProvider.java +++ b/pigx-common/pigx-common-datasource/src/main/java/com/pig4cloud/pigx/common/datasource/config/JdbcDynamicDataSourceProvider.java @@ -47,8 +47,6 @@ public class JdbcDynamicDataSourceProvider extends AbstractJdbcDataSourceProvide private static final String SQL_LOG_FILTER = "sqlLogFilter"; - private static final String DEFAULT_QUERY_DS_SQL = "select * from gen_datasource_conf where del_flag = ?"; - private final String queryDsSql; private final StringEncryptor stringEncryptor; @@ -59,7 +57,7 @@ public class JdbcDynamicDataSourceProvider extends AbstractJdbcDataSourceProvide super(defaultDataSourceCreator, masterDataSource.getDriverClassName(), masterDataSource.getUrl(), masterDataSource.getUsername(), masterDataSource.getPassword()); this.stringEncryptor = stringEncryptor; - this.queryDsSql = queryDsSql != null ? queryDsSql : DEFAULT_QUERY_DS_SQL; + this.queryDsSql = queryDsSql; } /**