From 5b8bd2797acf211f3697b2624533faa938533520 Mon Sep 17 00:00:00 2001 From: geht <2947093423@qq.com> Date: Tue, 9 Jun 2026 17:52:33 +0800 Subject: [PATCH] =?UTF-8?q?=E9=92=89=E9=92=89=E5=9B=9E=E8=B0=83=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jeecg-module-xslmes/doc/代码修改日志 | 8 + .../MesXslApprovalFlowController.java | 17 +- .../advice/ApprovalTraceResponseAdvice.java | 258 +++++++++++++ .../MesXslApprovalTraceController.java | 19 + .../engine/IntegrationActionConfigHelper.java | 45 ++- .../engine/IntegrationOrchestrator.java | 14 +- .../IntegrationRevertTargetResolver.java | 180 +++++++++ .../engine/RegistryStageFieldHelper.java | 32 -- .../executor/RegistryStageRevertExecutor.java | 45 +-- .../executor/RegistryStageSyncExecutor.java | 40 +- .../entity/MesXslBizDocRegistry.java | 28 +- .../service/IApprovalTraceSyncService.java | 4 +- .../service/IMesXslApprovalTraceService.java | 8 + .../service/IntegrationPlanGenerator.java | 62 ++-- .../impl/ApprovalTraceSyncServiceImpl.java | 92 ++--- .../impl/MesXslApprovalTraceServiceImpl.java | 24 ++ .../impl/MesXslBizDocRegistryServiceImpl.java | 6 - .../MesXslDingCallbackLogController.java | 133 +++++++ .../entity/MesXslDingCallbackLog.java | 111 ++++++ .../mapper/MesXslDingCallbackLogMapper.java | 13 + .../xml/MesXslDingCallbackLogMapper.xml | 4 + .../IMesXslDingCallbackLogService.java | 13 + .../MesXslDingCallbackLogServiceImpl.java | 17 + .../DingApprovalReconcileScheduler.java | 194 ++++++++++ .../stream/DingBpmsEventProcessor.java | 177 ++++++++- .../stream/DingStreamCallbackLogHelper.java | 152 ++++++++ .../dingtalk/stream/DingTalkStreamClient.java | 146 +++++++- .../stream/DingTalkStreamHealthMonitor.java | 60 +++ .../stream/DingTalkStreamLeaderElection.java | 111 ++++++ .../stream/DingTalkStreamProperties.java | 32 ++ .../stream/DingTalkStreamSdkRunner.java | 351 ++++++++++++------ .../src/main/resources/application-dev.yml | 9 + ....2_142__mes_xsl_registry_list_api_path.sql | 5 + ...__mes_xsl_registry_drop_by_time_fields.sql | 28 ++ .../V3.9.2_144__mes_xsl_ding_callback_log.sql | 89 +++++ .../src/hooks/system/useListPage.ts | 11 + .../flow/components/NodeConfigDrawer.vue | 4 + .../integration/MesXslBizDocRegistry.data.ts | 50 +-- .../components/GenerateDefaultPlanModal.vue | 12 + .../components/MesXslBizDocRegistryModal.vue | 7 +- .../MesXslIntegrationActionDrawer.vue | 7 +- .../components/RegistryMenuSelect.vue | 120 ++++++ .../components/VisualActionEditor.vue | 98 ++++- .../approval/integration/traceColumns.ts | 24 ++ .../approval/integration/useTraceTable.ts | 16 + .../MesXslDingCallbackLog.api.ts | 64 ++++ .../MesXslDingCallbackLog.data.ts | 158 ++++++++ .../MesXslDingCallbackLogList.vue | 107 ++++++ .../components/MesXslDingCallbackLogModal.vue | 52 +++ .../MesXslMixerPsCompile.data.ts | 32 +- 50 files changed, 2861 insertions(+), 428 deletions(-) create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/advice/ApprovalTraceResponseAdvice.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationRevertTargetResolver.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/controller/MesXslDingCallbackLogController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/entity/MesXslDingCallbackLog.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/mapper/MesXslDingCallbackLogMapper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/mapper/xml/MesXslDingCallbackLogMapper.xml create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/service/IMesXslDingCallbackLogService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/service/impl/MesXslDingCallbackLogServiceImpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingApprovalReconcileScheduler.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingStreamCallbackLogHelper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamLeaderElection.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_142__mes_xsl_registry_list_api_path.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_143__mes_xsl_registry_drop_by_time_fields.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_144__mes_xsl_ding_callback_log.sql create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/RegistryMenuSelect.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/traceColumns.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/useTraceTable.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLog.api.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLog.data.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLogList.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/dingCallbackLog/components/MesXslDingCallbackLogModal.vue diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 index e57fbdca..d229d783 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 @@ -859,6 +859,14 @@ jeecgboot-vue3/src/views/xslmes/approval/integration/components/DingApprovalFore jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.data.ts +-- author:GHT---date:20260609--for: 【钉钉Stream集群】Redis选主单节点建连+存活监控 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamLeaderElection.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamSdkRunner.java +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml + -- author:cursor---date:20260608--for: 【XSLMES-20260608-A01】混炼示方新增状态字段及列表查询条件 ----- jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_141__mes_xsl_mixing_spec_status.sql jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslMixingSpec.java diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java index fa29d2e1..8c61df44 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java @@ -390,9 +390,10 @@ public class MesXslApprovalFlowController extends JeecgController> parseRegistryStages(String table) { List> stages = new ArrayList<>(); @@ -402,25 +403,23 @@ public class MesXslApprovalFlowController extends JeecgController enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages()); String[][] ordered = new String[][]{ - {ApprovalStageResolver.STAGE_PROOFREAD, "校对", registry.getProofreadByField()}, - {ApprovalStageResolver.STAGE_AUDIT, "审核", registry.getAuditByField()}, - {ApprovalStageResolver.STAGE_APPROVE, "批准", registry.getApproveByField()}, + {ApprovalStageResolver.STAGE_PROOFREAD, "校对"}, + {ApprovalStageResolver.STAGE_AUDIT, "审核"}, + {ApprovalStageResolver.STAGE_APPROVE, "批准"}, }; for (String[] item : ordered) { - String stageKey = item[0]; - if (!enabled.contains(stageKey) || oConvertUtils.isEmpty(item[2])) { + if (!enabled.contains(item[0])) { continue; } Map stage = new LinkedHashMap<>(); - stage.put("stageKey", stageKey); + stage.put("stageKey", item[0]); stage.put("stageName", item[1]); stage.put("nodeType", "approver"); - stage.put("field", item[2]); - stage.put("fieldComment", item[1] + "人"); stages.add(stage); } return stages; } + //update-end---author:GHT ---date:20260609 for:【审批注册中心】移除 byField 引用,操作人由痕迹表承载----------- //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】从审批注册中心解析启用环节----- /** 按业务表+租户查找审批流(取最近一条) */ diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/advice/ApprovalTraceResponseAdvice.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/advice/ApprovalTraceResponseAdvice.java new file mode 100644 index 00000000..f86b83fd --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/advice/ApprovalTraceResponseAdvice.java @@ -0,0 +1,258 @@ +package org.jeecg.modules.xslmes.approval.integration.advice; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslApprovalTraceService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 审批痕迹自动注入增强器 + * + *

当审批注册中心配置了 listApiPath 后,拦截匹配 URL 的列表响应, + * 自动 LEFT JOIN mes_xsl_approval_trace,将痕迹字段(traceProofreadBy 等) + * 注入到每条记录中,无需修改业务代码。 + * + * @author GHT + * @date 2026-06-08 for:【XSLMES-20260608-TRACE】审批痕迹响应自动注入 + */ +//update-begin---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】审批痕迹响应自动注入----------- +@ControllerAdvice +@Slf4j +@SuppressWarnings({"unchecked", "rawtypes"}) +public class ApprovalTraceResponseAdvice implements ResponseBodyAdvice { + + @Autowired + private IMesXslBizDocRegistryService registryService; + + @Autowired + private IMesXslApprovalTraceService traceService; + + /** 路径缓存条目 */ + private static class CacheEntry { + final String tableName; + /** enabledStages 集合,如 {"proofread","audit","approve"} */ + final java.util.Set enabledStages; + CacheEntry(String tableName, java.util.Set enabledStages) { + this.tableName = tableName; + this.enabledStages = enabledStages; + } + } + + /** path → CacheEntry 缓存(1 分钟 TTL)*/ + private volatile Map pathToEntryCache = Collections.emptyMap(); + private volatile long cacheLoadTime = 0L; + private static final long CACHE_TTL_MS = 60_000L; + + @Override + public boolean supports(MethodParameter returnType, + Class> converterType) { + return Result.class.isAssignableFrom(returnType.getParameterType()); + } + + @Override + public Object beforeBodyWrite(Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response) { + if (!(body instanceof Result)) { + return body; + } + String path = extractServletPath(request); + CacheEntry entry = resolveEntry(path); + if (entry == null) { + return body; + } + + Result result = (Result) body; + Object data = result.getResult(); + + List records = null; + IPage page = null; + if (data instanceof IPage) { + page = (IPage) data; + records = page.getRecords(); + } else if (data instanceof List) { + records = (List) data; + } + + if (records == null || records.isEmpty()) { + return body; + } + + List ids = extractIds(records); + Map traceMap = Collections.emptyMap(); + if (!ids.isEmpty()) { + try { + traceMap = traceService.batchQueryByBizIds(entry.tableName, ids); + } catch (Exception e) { + log.warn("[审批痕迹注入] 批量查询失败 table={} path={}: {}", entry.tableName, path, e.getMessage()); + } + } + + List> enriched = enrichRecords(records, traceMap, entry.enabledStages); + + if (page != null) { + ((Page) page).setRecords(enriched); + } else { + result.setResult(enriched); + } + return body; + } + + private String extractServletPath(ServerHttpRequest request) { + if (request instanceof ServletServerHttpRequest) { + HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); + String path = servletRequest.getServletPath(); + return oConvertUtils.isNotEmpty(path) ? path : request.getURI().getPath(); + } + return request.getURI().getPath(); + } + + private CacheEntry resolveEntry(String path) { + if (oConvertUtils.isEmpty(path)) { + return null; + } + ensureCacheLoaded(); + return pathToEntryCache.get(path); + } + + private void ensureCacheLoaded() { + long now = System.currentTimeMillis(); + if (now - cacheLoadTime > CACHE_TTL_MS) { + synchronized (this) { + if (now - cacheLoadTime > CACHE_TTL_MS) { + reloadCache(); + cacheLoadTime = now; + } + } + } + } + + private void reloadCache() { + try { + List registries = registryService.lambdaQuery() + .eq(MesXslBizDocRegistry::getEnabled, 1) + .isNotNull(MesXslBizDocRegistry::getListApiPath) + .list(); + Map map = new HashMap<>(); + for (MesXslBizDocRegistry reg : registries) { + if (oConvertUtils.isEmpty(reg.getListApiPath()) || oConvertUtils.isEmpty(reg.getTableName())) { + continue; + } + java.util.Set stages = parseStages(reg.getEnabledStages()); + CacheEntry entry = new CacheEntry(reg.getTableName(), stages); + for (String p : reg.getListApiPath().split(",")) { + String trimmed = p.trim(); + if (oConvertUtils.isNotEmpty(trimmed)) { + map.put(trimmed, entry); + } + } + } + pathToEntryCache = map; + log.debug("[审批痕迹注入] 路径缓存已刷新,共 {} 条路径映射", map.size()); + } catch (Exception e) { + log.warn("[审批痕迹注入] 路径缓存刷新失败: {}", e.getMessage()); + } + } + + private java.util.Set parseStages(String enabledStages) { + java.util.Set set = new java.util.LinkedHashSet<>(); + if (oConvertUtils.isEmpty(enabledStages)) { + return set; + } + for (String s : enabledStages.split(",")) { + String t = s.trim(); + if (oConvertUtils.isNotEmpty(t)) { + set.add(t); + } + } + return set; + } + + private List extractIds(List records) { + List ids = new ArrayList<>(records.size()); + for (Object r : records) { + if (r == null) { + continue; + } + Object id = null; + if (r instanceof Map) { + id = ((Map) r).get("id"); + } else { + try { + id = r.getClass().getMethod("getId").invoke(r); + } catch (Exception ignored) { + // 无 getId 方法时跳过 + } + } + if (id != null) { + String idStr = String.valueOf(id); + if (oConvertUtils.isNotEmpty(idStr)) { + ids.add(idStr); + } + } + } + return ids; + } + + private List> enrichRecords(List records, + Map traceMap, + java.util.Set enabledStages) { + List> enriched = new ArrayList<>(records.size()); + for (Object r : records) { + if (r == null) { + continue; + } + Map map; + if (r instanceof Map) { + map = new LinkedHashMap<>((Map) r); + } else { + // 实体类转 Map(保留序列化配置如 @JsonFormat) + map = new LinkedHashMap<>(JSON.parseObject(JSON.toJSONString(r), Map.class)); + } + Object idObj = map.get("id"); + MesXslApprovalTrace trace = (idObj != null) ? traceMap.get(String.valueOf(idObj)) : null; + // 对每个启用的环节,始终注入字段(无痕迹时为 null),使前端能感知注册了哪些列 + if (enabledStages.contains("proofread")) { + map.put("traceProofreadBy", trace != null ? trace.getProofreadBy() : null); + map.put("traceProofreadTime", trace != null ? trace.getProofreadTime() : null); + } + if (enabledStages.contains("audit")) { + map.put("traceAuditBy", trace != null ? trace.getAuditBy() : null); + map.put("traceAuditTime", trace != null ? trace.getAuditTime() : null); + } + if (enabledStages.contains("approve")) { + map.put("traceApproveBy", trace != null ? trace.getApproveBy() : null); + map.put("traceApproveTime", trace != null ? trace.getApproveTime() : null); + } + enriched.add(map); + } + return enriched; + } +} +//update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】审批痕迹响应自动注入----------- diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java index b4198fcb..da99e829 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java @@ -20,10 +20,15 @@ import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessForecastVO; import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessInstanceFlowVO; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; +import java.util.Map; + /** * 审批痕迹明细 * @@ -62,6 +67,20 @@ public class MesXslApprovalTraceController extends JeecgController> batchByBizIds( + @RequestParam String bizTable, + @RequestBody List bizDataIds) { + if (oConvertUtils.isEmpty(bizTable) || bizDataIds == null || bizDataIds.isEmpty()) { + return Result.error("bizTable 与 bizDataIds 不能为空"); + } + return Result.OK(traceService.batchQueryByBizIds(bizTable, bizDataIds)); + } + //update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】批量查询痕迹供前端关联展示----------- + @Operation(summary = "审批痕迹-按业务表与单据ID查询(供业务页关联展示)") @RequiresPermissions("xslmes:mes_xsl_approval_trace:list") @GetMapping("/queryByBiz") diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationActionConfigHelper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationActionConfigHelper.java index c9da34c7..271f6c38 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationActionConfigHelper.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationActionConfigHelper.java @@ -56,21 +56,54 @@ public final class IntegrationActionConfigHelper { return RegistryStageFieldHelper.defaultExpectedFrom(stage); } + //update-begin---author:GHT ---date:20260609 for:【审批环节同步】通过后状态与审批环节解耦,业务表状态由 statusAfter 控制----------- + /** + * 解析环节通过后业务表应写入的状态值。 + * 未配置时回退为审批环节码(兼容旧数据)。 + */ + public static String resolveStatusAfter(MesXslIntegrationAction action, String stage) { + if (action != null && oConvertUtils.isNotEmpty(action.getActionConfig())) { + try { + JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); + if (cfg.containsKey("statusAfter")) { + String v = cfg.getString("statusAfter"); + return oConvertUtils.isEmpty(v) ? null : v.trim(); + } + JSONObject registryStage = cfg.getJSONObject("registryStage"); + if (registryStage != null && registryStage.containsKey("statusAfter")) { + String v = registryStage.getString("statusAfter"); + return oConvertUtils.isEmpty(v) ? null : v.trim(); + } + } catch (Exception ignored) { + // fallback + } + } + return oConvertUtils.isNotEmpty(stage) ? stage.trim() : null; + } + //update-end---author:GHT ---date:20260609 for:【审批环节同步】通过后状态与审批环节解耦,业务表状态由 statusAfter 控制----------- + + //update-begin---author:GHT ---date:20260609 for:【驳回回退】targetStage 按 containsKey 解析字典键值(含 0)----------- + /** + * 解析驳回回退目标:取动作配置中「回退目标」下拉所选的字典 item_value,原样写入业务表 status。 + */ public static String resolveTargetStage(MesXslIntegrationAction action) { if (action != null && oConvertUtils.isNotEmpty(action.getActionConfig())) { try { JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); - if (oConvertUtils.isNotEmpty(cfg.getString("targetStage"))) { - return cfg.getString("targetStage").trim(); + if (cfg.containsKey("targetStage")) { + String v = cfg.getString("targetStage"); + return oConvertUtils.isEmpty(v) ? null : v.trim(); } JSONObject registryStage = cfg.getJSONObject("registryStage"); - if (registryStage != null && oConvertUtils.isNotEmpty(registryStage.getString("targetStage"))) { - return registryStage.getString("targetStage").trim(); + if (registryStage != null && registryStage.containsKey("targetStage")) { + String v = registryStage.getString("targetStage"); + return oConvertUtils.isEmpty(v) ? null : v.trim(); } } catch (Exception ignored) { - // fallback compile + // fallback null } } - return "compile"; + return null; } + //update-end---author:GHT ---date:20260609 for:【驳回回退】targetStage 按 containsKey 解析字典键值(含 0)----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java index 56faa152..207a503a 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java @@ -66,6 +66,8 @@ public class IntegrationOrchestrator { private JdbcTemplate jdbcTemplate; @Autowired private List executors; + @Autowired + private IntegrationRevertTargetResolver revertTargetResolver; // ==================== 外部入口 ==================== @@ -398,7 +400,17 @@ public class IntegrationOrchestrator { } private String resolveRevertTargetStage(MesXslIntegrationAction action) { - return IntegrationActionConfigHelper.resolveTargetStage(action); + String target = IntegrationActionConfigHelper.resolveTargetStage(action); + if (oConvertUtils.isNotEmpty(target)) { + return target; + } + if (action != null && oConvertUtils.isNotEmpty(action.getPlanId())) { + MesXslIntegrationPlan plan = planService.getById(action.getPlanId()); + if (plan != null && oConvertUtils.isNotEmpty(plan.getSourceTable())) { + return revertTargetResolver.resolveRevertTarget(plan.getSourceTable()); + } + } + return ""; } private String readSourceStatus(IntegrationContext ctx) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationRevertTargetResolver.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationRevertTargetResolver.java new file mode 100644 index 00000000..435cc439 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationRevertTargetResolver.java @@ -0,0 +1,180 @@ +package org.jeecg.modules.xslmes.approval.integration.engine; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationActionService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationPlanService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * 驳回回退目标解析:优先读取已发布 onReject 集成方案中的 REGISTRY_STAGE_REVERT 配置。 + */ +@Slf4j +@Component +public class IntegrationRevertTargetResolver { + + private static final Pattern DICT_IN_COMMENT = Pattern.compile("字典[:\\s]?([a-zA-Z][a-zA-Z0-9_]*)"); + + private static final Map TABLE_STATUS_DICT_FALLBACK = Map.of( + "mes_xsl_mixer_ps_compile", "xslmes_mixer_ps_status", + "mes_xsl_formula_spec", "xslmes_formula_spec_status", + "mes_xsl_raw_material_entry", "xslmes_entry_status" + ); + + @Autowired + private IMesXslIntegrationPlanService planService; + @Autowired + private IMesXslIntegrationActionService actionService; + @Autowired + private IMesXslBizDocRegistryService registryService; + @Autowired + private JdbcTemplate jdbcTemplate; + + //update-begin---author:GHT ---date:20260609 for:【驳回回退】从已发布 onReject 集成方案解析回退目标----------- + /** + * 解析业务表驳回时应回退到的 status 值。 + * 优先级:已发布 onReject 方案 REGISTRY_STAGE_REVERT.targetStage → 注册中心状态字典初始态 → compile。 + */ + public String resolveRevertTarget(String sourceTable) { + if (oConvertUtils.isEmpty(sourceTable)) { + return "compile"; + } + String fromPlan = resolveFromPublishedRejectPlan(sourceTable); + if (oConvertUtils.isNotEmpty(fromPlan)) { + return fromPlan; + } + String fromRegistry = resolveInitialStatusFromRegistry(sourceTable); + if (oConvertUtils.isNotEmpty(fromRegistry)) { + log.info("[集成引擎] 表 {} 未配置 onReject 回退目标,使用注册中心初始态={}", sourceTable, fromRegistry); + return fromRegistry; + } + log.warn("[集成引擎] 表 {} 未解析到回退目标,回退 compile", sourceTable); + return "compile"; + } + + private String resolveFromPublishedRejectPlan(String sourceTable) { + List plans = planService.lambdaQuery() + .eq(MesXslIntegrationPlan::getSourceTable, sourceTable) + .eq(MesXslIntegrationPlan::getTriggerPhase, "onReject") + .eq(MesXslIntegrationPlan::getStatus, "1") + .orderByAsc(MesXslIntegrationPlan::getCreateTime) + .list(); + for (MesXslIntegrationPlan plan : plans) { + List actions = actionService.listByPlanId(plan.getId()); + for (MesXslIntegrationAction action : actions) { + if (!"REGISTRY_STAGE_REVERT".equals(action.getActionType())) { + continue; + } + String target = IntegrationActionConfigHelper.resolveTargetStage(action); + if (oConvertUtils.isNotEmpty(target)) { + return target; + } + } + } + return null; + } + + private String resolveInitialStatusFromRegistry(String sourceTable) { + MesXslBizDocRegistry registry = registryService.findActiveByTableName(sourceTable); + if (registry == null) { + return null; + } + List chain = loadStatusChain(registry); + if (chain.isEmpty()) { + return null; + } + List enabledStages = orderedEnabledStages(registry.getEnabledStages()); + return resolveInitialStatus(chain, enabledStages); + } + + private List orderedEnabledStages(String enabledStages) { + Set enabled = ApprovalStageResolver.parseEnabledStages(enabledStages); + List ordered = new ArrayList<>(); + for (String key : new String[]{ + ApprovalStageResolver.STAGE_PROOFREAD, + ApprovalStageResolver.STAGE_AUDIT, + ApprovalStageResolver.STAGE_APPROVE}) { + if (enabled.contains(key)) { + ordered.add(key); + } + } + return ordered; + } + + private String resolveInitialStatus(List chain, List enabledStages) { + Set enabledSet = new LinkedHashSet<>(enabledStages); + int firstStageIdx = -1; + for (int i = 0; i < chain.size(); i++) { + if (enabledSet.contains(chain.get(i).value)) { + firstStageIdx = i; + break; + } + } + if (firstStageIdx > 0) { + return chain.get(firstStageIdx - 1).value; + } + for (StatusDictItem item : chain) { + if (!enabledSet.contains(item.value)) { + return item.value; + } + } + return chain.get(0).value; + } + + private List loadStatusChain(MesXslBizDocRegistry registry) { + String dictCode = resolveStatusDictCode(registry); + if (oConvertUtils.isEmpty(dictCode)) { + return List.of(); + } + List> rows = jdbcTemplate.queryForList( + "SELECT item_value AS value, item_text AS label, sort_order AS sortOrder " + + "FROM sys_dict_item WHERE dict_id=(SELECT id FROM sys_dict WHERE dict_code=?) " + + "AND status=1 ORDER BY sort_order ASC, item_value ASC", + dictCode); + List chain = new ArrayList<>(); + for (Map row : rows) { + chain.add(new StatusDictItem(String.valueOf(row.get("value")), String.valueOf(row.get("label")))); + } + return chain; + } + + private String resolveStatusDictCode(MesXslBizDocRegistry registry) { + String statusField = oConvertUtils.isEmpty(registry.getStatusField()) ? "status" : registry.getStatusField(); + String table = registry.getTableName(); + if (!table.matches("^[a-z][a-z0-9_]{0,63}$")) { + return TABLE_STATUS_DICT_FALLBACK.getOrDefault(table, null); + } + try { + List comments = jdbcTemplate.queryForList( + "SELECT COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?", + String.class, table, statusField); + if (!comments.isEmpty()) { + Matcher m = DICT_IN_COMMENT.matcher(comments.get(0)); + if (m.find()) { + return m.group(1); + } + } + } catch (Exception e) { + log.warn("[集成引擎] 读取状态字典注释失败 table={} field={}", table, statusField, e); + } + return TABLE_STATUS_DICT_FALLBACK.getOrDefault(table, null); + } + + private record StatusDictItem(String value, String label) { + } + //update-end---author:GHT ---date:20260609 for:【驳回回退】从已发布 onReject 集成方案解析回退目标----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java index e5d30b78..f8b3d590 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java @@ -15,38 +15,6 @@ public final class RegistryStageFieldHelper { return oConvertUtils.isEmpty(registry.getStatusField()) ? "status" : registry.getStatusField(); } - public static String byField(MesXslBizDocRegistry registry, String stage) { - if (registry == null || oConvertUtils.isEmpty(stage)) { - return null; - } - switch (stage) { - case ApprovalStageResolver.STAGE_PROOFREAD: - return registry.getProofreadByField(); - case ApprovalStageResolver.STAGE_AUDIT: - return registry.getAuditByField(); - case ApprovalStageResolver.STAGE_APPROVE: - return registry.getApproveByField(); - default: - return null; - } - } - - public static String timeField(MesXslBizDocRegistry registry, String stage) { - if (registry == null || oConvertUtils.isEmpty(stage)) { - return null; - } - switch (stage) { - case ApprovalStageResolver.STAGE_PROOFREAD: - return registry.getProofreadTimeField(); - case ApprovalStageResolver.STAGE_AUDIT: - return registry.getAuditTimeField(); - case ApprovalStageResolver.STAGE_APPROVE: - return registry.getApproveTimeField(); - default: - return null; - } - } - /** 环节默认前置状态:proofread←compile, audit←proofread, approve←audit */ public static String defaultExpectedFrom(String stage) { switch (stage) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java index 5393baf9..df113bc0 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java @@ -14,7 +14,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; /** - * 审批驳回回退:按注册中心配置将源单 status 回退并清空环节痕迹(默认回 compile)。 + * 审批驳回回退:按集成方案 targetStage 将源单 status 回退并清空环节痕迹。 */ @Slf4j @Component @@ -26,7 +26,6 @@ public class RegistryStageRevertExecutor implements IIntegrationActionExecutor { private IApprovalTraceSyncService approvalTraceSyncService; @Autowired private JdbcTemplate jdbcTemplate; - @Override public String supportActionType() { return "REGISTRY_STAGE_REVERT"; @@ -46,34 +45,23 @@ public class RegistryStageRevertExecutor implements IIntegrationActionExecutor { throw new IllegalStateException("业务表未在审批注册中心启用: " + bizTable); } + //update-begin---author:GHT ---date:20260609 for:【驳回回退】仅使用动作配置中「回退目标」所选字典键值----------- String targetStage = IntegrationActionConfigHelper.resolveTargetStage(action); + if (oConvertUtils.isEmpty(targetStage)) { + throw new IllegalStateException( + "驳回回退动作未配置「回退目标」,请在集成方案动作编辑器中选择状态字典项并保存(actionConfig.targetStage)"); + } + //update-end---author:GHT ---date:20260609 for:【驳回回退】仅使用动作配置中「回退目标」所选字典键值----------- String statusField = RegistryStageFieldHelper.statusField(registry); RegistryStageFieldHelper.assertIdentifier(statusField); RegistryStageFieldHelper.assertIdentifier(bizTable); - StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `") - .append(statusField).append("`=?"); - java.util.List params = new java.util.ArrayList<>(); - params.add(targetStage); - - clearField(sql, params, registry.getProofreadByField()); - clearField(sql, params, registry.getProofreadTimeField()); - clearField(sql, params, registry.getAuditByField()); - clearField(sql, params, registry.getAuditTimeField()); - clearField(sql, params, registry.getApproveByField()); - clearField(sql, params, registry.getApproveTimeField()); - - if ("compile".equals(targetStage)) { - // 已全部清空 - } else if ("proofread".equals(targetStage)) { - // 保留 proofread,清空 audit/approve — 上面已全清,需按目标环节保留(简化:compile 场景为主) - } - - sql.append(" WHERE id=?"); - params.add(bizId); - - int affected = jdbcTemplate.update(sql.toString(), params.toArray()); + //update-begin---author:GHT ---date:20260609 for:【审批注册中心】回退只重置业务表状态,操作人/时间由痕迹表承载----------- + int affected = jdbcTemplate.update( + "UPDATE `" + bizTable + "` SET `" + statusField + "`=? WHERE id=?", + targetStage, bizId); + //update-end---author:GHT ---date:20260609 for:【审批注册中心】回退只重置业务表状态,操作人/时间由痕迹表承载----------- if (affected == 0) { throw new IllegalStateException("源单不存在或回退失败 id=" + bizId); } @@ -82,14 +70,5 @@ public class RegistryStageRevertExecutor implements IIntegrationActionExecutor { log.info("[集成引擎][REGISTRY_STAGE_REVERT] table={} id={} targetStage={}", bizTable, bizId, targetStage); return "环节回退成功: " + targetStage; } - - private void clearField(StringBuilder sql, java.util.List params, String field) { - if (oConvertUtils.isEmpty(field)) { - return; - } - RegistryStageFieldHelper.assertIdentifier(field); - sql.append(", `").append(field).append("`=?"); - params.add(null); - } //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批注册中心环节回退执行器----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java index f7ce248b..9dbf1847 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java @@ -56,17 +56,16 @@ public class RegistryStageSyncExecutor implements IIntegrationActionExecutor { throw new IllegalStateException(stageErr); } + //update-begin---author:GHT ---date:20260609 for:【审批环节同步】审批环节仅写痕迹,业务表状态由 statusAfter 控制----------- + String statusAfter = resolveStatusAfter(action, stage); + if (oConvertUtils.isEmpty(statusAfter)) { + throw new IllegalArgumentException("动作未配置通过后状态(statusAfter),且无法从审批环节推断"); + } + //update-end---author:GHT ---date:20260609 for:【审批环节同步】审批环节仅写痕迹,业务表状态由 statusAfter 控制----------- + String expectedFrom = resolveExpectedFrom(action, stage); String statusField = RegistryStageFieldHelper.statusField(registry); - String byField = RegistryStageFieldHelper.byField(registry, stage); - String timeField = RegistryStageFieldHelper.timeField(registry, stage); RegistryStageFieldHelper.assertIdentifier(statusField); - if (oConvertUtils.isNotEmpty(byField)) { - RegistryStageFieldHelper.assertIdentifier(byField); - } - if (oConvertUtils.isNotEmpty(timeField)) { - RegistryStageFieldHelper.assertIdentifier(timeField); - } String operator = resolveOperator(ctx); //update-begin---author:GHT ---date:20260608 for:【审批注册中心】环节同步使用实例tasks最新完成时间----------- @@ -83,20 +82,13 @@ public class RegistryStageSyncExecutor implements IIntegrationActionExecutor { } } + //update-begin---author:GHT ---date:20260609 for:【审批注册中心】业务表只写状态,操作人/时间统一由痕迹表承载----------- StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `") - .append(statusField).append("`=?"); + .append(statusField).append("`=? WHERE id=?"); java.util.List params = new java.util.ArrayList<>(); - params.add(stage); - if (oConvertUtils.isNotEmpty(byField)) { - sql.append(", `").append(byField).append("`=?"); - params.add(operator); - } - if (oConvertUtils.isNotEmpty(timeField)) { - sql.append(", `").append(timeField).append("`=?"); - params.add(now); - } - sql.append(" WHERE id=?"); + params.add(statusAfter); params.add(bizId); + //update-end---author:GHT ---date:20260609 for:【审批注册中心】业务表只写状态,操作人/时间统一由痕迹表承载----------- int affected = jdbcTemplate.update(sql.toString(), params.toArray()); if (affected == 0) { @@ -104,9 +96,9 @@ public class RegistryStageSyncExecutor implements IIntegrationActionExecutor { } approvalTraceSyncService.syncStage(bizTable, bizId, stage, operator, now); - log.info("[集成引擎][REGISTRY_STAGE_SYNC] table={} id={} stage={} operator={}", - bizTable, bizId, stage, operator); - return "环节同步成功: " + ApprovalStageResolver.stageLabel(stage); + log.info("[集成引擎][REGISTRY_STAGE_SYNC] table={} id={} stage={} statusAfter={} operator={}", + bizTable, bizId, stage, statusAfter, operator); + return "环节同步成功: " + ApprovalStageResolver.stageLabel(stage) + " → 状态=" + statusAfter; } private String resolveStage(IntegrationContext ctx, MesXslIntegrationAction action) { @@ -121,6 +113,10 @@ public class RegistryStageSyncExecutor implements IIntegrationActionExecutor { return IntegrationActionConfigHelper.resolveExpectedFrom(action, stage); } + private String resolveStatusAfter(MesXslIntegrationAction action, String stage) { + return IntegrationActionConfigHelper.resolveStatusAfter(action, stage); + } + private String resolveOperator(IntegrationContext ctx) { ApprovalCallbackContext ac = ctx.getApprovalCtx(); if (ac != null && oConvertUtils.isNotEmpty(ac.getOperatorName())) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java index 927b503a..2f9d1fac 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java @@ -47,27 +47,17 @@ public class MesXslBizDocRegistry extends JeecgEntity implements Serializable { @TableField(updateStrategy = FieldStrategy.ALWAYS) private String enabledStages; - @Schema(description = "业务状态字段名") + @Schema(description = "业务状态字段名,默认 status") private String statusField; - - @Schema(description = "校对人字段名") - private String proofreadByField; - - @Schema(description = "校对时间字段名") - private String proofreadTimeField; - - @Schema(description = "审核人字段名") - private String auditByField; - - @Schema(description = "审核时间字段名") - private String auditTimeField; - - @Schema(description = "批准人字段名") - private String approveByField; - - @Schema(description = "批准时间字段名") - private String approveTimeField; //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批环节与字段映射配置----------- + //update-begin---author:GHT ---date:20260609 for:【审批注册中心】移除操作人字段配置,操作人/时间统一由痕迹表承载,业务表只需 statusField----------- + // proofreadByField / proofreadTimeField / auditByField / auditTimeField / approveByField / approveTimeField 已移除 + //update-end---author:GHT ---date:20260609 for:【审批注册中心】移除操作人字段配置,操作人/时间统一由痕迹表承载,业务表只需 statusField----------- + + //update-begin---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】列表接口路径,配置后自动注入审批痕迹字段----------- + @Schema(description = "列表接口路径(多个逗号分隔),配置后自动注入审批痕迹字段到响应") + private String listApiPath; + //update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】列表接口路径,配置后自动注入审批痕迹字段----------- @Schema(description = "备注") private String remark; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java index 9cd17c4e..6a406122 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java @@ -20,7 +20,7 @@ public interface IApprovalTraceSyncService { /** * 逆向回退时同步清空高于目标环节的痕迹字段 * - * @param targetStage compile / proofread / audit + * @param targetStage 审批环节码(compile/proofread/audit)或业务 status 字典值(如 0) */ void revertToStage(String bizTable, String bizDataId, String targetStage); @@ -33,7 +33,7 @@ public interface IApprovalTraceSyncService { //update-begin---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止时清空源单与痕迹操作人----------- /** - * 驳回/终止后回退到编制态:清空源单操作人/时间字段并清空痕迹明细 + * 驳回/终止后按 onReject 集成方案回退目标重置业务表 status 并清空痕迹(兼容旧方法名) */ void revertToCompile(String bizTable, String bizDataId); //update-end---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止时清空源单与痕迹操作人----------- diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java index 83bcffb2..cfa93404 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java @@ -9,6 +9,7 @@ import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessForecastVO; import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessInstanceFlowVO; import java.util.List; +import java.util.Map; /** * 审批痕迹明细 @@ -20,6 +21,13 @@ public interface IMesXslApprovalTraceService extends IService batchQueryByBizIds(String bizTable, List bizDataIds); + //update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】批量查询痕迹供响应增强器注入----------- + //update-begin---author:GHT ---date:20260608 for:【审批注册中心】明细列表补充钉钉审批实例ID----------- /** * 分页查询并补充钉钉审批实例ID diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java index b328930d..89271758 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java @@ -8,7 +8,6 @@ import org.jeecg.common.api.vo.Result; import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalStageResolver; -import org.jeecg.modules.xslmes.approval.integration.engine.RegistryStageFieldHelper; import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction; import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan; @@ -213,6 +212,10 @@ public class IntegrationPlanGenerator { node.put("triggerPhase", phase); node.put("expectedFrom", b.expectedFrom); node.put("expectedFromLabel", oConvertUtils.isNotEmpty(b.expectedFrom) ? labelOf(statusChain, b.expectedFrom) : "-"); + //update-begin---author:GHT ---date:20260609 for:【审批环节同步】预览与生成增加通过后状态----------- + node.put("statusAfter", b.statusAfter); + node.put("statusAfterLabel", oConvertUtils.isNotEmpty(b.statusAfter) ? labelOf(statusChain, b.statusAfter) : "-"); + //update-end---author:GHT ---date:20260609 for:【审批环节同步】预览与生成增加通过后状态----------- if (!b.stageConfigured && oConvertUtils.isNotEmpty(b.unconfiguredReason)) { node.put("unconfiguredReason", b.unconfiguredReason); } @@ -229,6 +232,9 @@ public class IntegrationPlanGenerator { actionConfig.put("visualType", "REGISTRY_STAGE_SYNC"); actionConfig.put("stage", b.stage); actionConfig.put("expectedFrom", b.expectedFrom); + if (oConvertUtils.isNotEmpty(b.statusAfter)) { + actionConfig.put("statusAfter", b.statusAfter); + } Map action = new LinkedHashMap<>(); action.put("actionName", b.stageLabel + "环节同步"); @@ -430,18 +436,33 @@ public class IntegrationPlanGenerator { } } bindings.add(new StageBinding( - node.name, node.nodeId, stage, stageLabel, null, configured, unconfiguredReason, suggestedStage)); + node.name, node.nodeId, stage, stageLabel, null, null, configured, unconfiguredReason, suggestedStage)); } for (int i = 0; i < bindings.size(); i++) { StageBinding b = bindings.get(i); String expectedFrom = b.stageConfigured ? resolveExpectedFromForBinding(bindings, i, statusChain, initialStatus) : null; - bindings.set(i, b.withExpectedFrom(expectedFrom)); + String statusAfter = b.stageConfigured + ? resolveStatusAfterForBinding(b, statusChain) + : null; + bindings.set(i, b.withExpectedFrom(expectedFrom).withStatusAfter(statusAfter)); } return bindings; } + //update-begin---author:GHT ---date:20260609 for:【审批环节同步】推断通过后业务状态(字典含环节码时自动填充)----------- + private String resolveStatusAfterForBinding(StageBinding binding, List statusChain) { + if (oConvertUtils.isEmpty(binding.stage)) { + return null; + } + if (indexOfValue(statusChain, binding.stage) >= 0) { + return binding.stage; + } + return null; + } + //update-end---author:GHT ---date:20260609 for:【审批环节同步】推断通过后业务状态(字典含环节码时自动填充)----------- + private String resolveStageFromNode(FlowNode node, MesXslBizDocRegistry registry, List enabledStages, int nodeIndex) { JSONObject props = node.props; @@ -465,19 +486,9 @@ public class IntegrationPlanGenerator { return null; } + //update-begin---author:GHT ---date:20260609 for:【审批注册中心】移除 byField 引用,操作人由痕迹表承载----------- private String mapFieldToStage(MesXslBizDocRegistry registry, String fieldName) { - if (oConvertUtils.isEmpty(fieldName) || registry == null) { - return null; - } - if (fieldName.equals(registry.getProofreadByField())) { - return ApprovalStageResolver.STAGE_PROOFREAD; - } - if (fieldName.equals(registry.getAuditByField())) { - return ApprovalStageResolver.STAGE_AUDIT; - } - if (fieldName.equals(registry.getApproveByField())) { - return ApprovalStageResolver.STAGE_APPROVE; - } + // byField 已移除,节点 fieldName 不再映射环节,由 stageKey 或节点名称推断 return null; } @@ -486,10 +497,7 @@ public class IntegrationPlanGenerator { return false; } Set enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages()); - if (!enabled.contains(stage)) { - return false; - } - return oConvertUtils.isNotEmpty(RegistryStageFieldHelper.byField(registry, stage)); + return enabled.contains(stage); } private String buildUnconfiguredReason(MesXslBizDocRegistry registry, String stage, List enabledStages) { @@ -500,11 +508,9 @@ public class IntegrationPlanGenerator { if (!enabled.contains(stage)) { return "环节「" + ApprovalStageResolver.stageLabel(stage) + "」未在注册中心启用"; } - if (oConvertUtils.isEmpty(RegistryStageFieldHelper.byField(registry, stage))) { - return "环节「" + ApprovalStageResolver.stageLabel(stage) + "」未配置操作人字段"; - } return "环节未完整配置"; } + //update-end---author:GHT ---date:20260609 for:【审批注册中心】移除 byField 引用,操作人由痕迹表承载----------- private String resolveExpectedFromForBinding(List bindings, int index, List statusChain, String initialStatus) { @@ -648,10 +654,16 @@ public class IntegrationPlanGenerator { } private record StageBinding(String nodeName, String nodeId, String stage, String stageLabel, - String expectedFrom, boolean stageConfigured, String unconfiguredReason, - String suggestedStage) { + String expectedFrom, String statusAfter, boolean stageConfigured, + String unconfiguredReason, String suggestedStage) { StageBinding withExpectedFrom(String expectedFrom) { - return new StageBinding(nodeName, nodeId, stage, stageLabel, expectedFrom, stageConfigured, unconfiguredReason, suggestedStage); + return new StageBinding(nodeName, nodeId, stage, stageLabel, expectedFrom, statusAfter, + stageConfigured, unconfiguredReason, suggestedStage); + } + + StageBinding withStatusAfter(String statusAfter) { + return new StageBinding(nodeName, nodeId, stage, stageLabel, expectedFrom, statusAfter, + stageConfigured, unconfiguredReason, suggestedStage); } } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java index a4dc5066..05622496 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor; import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.StageCompletion; +import org.jeecg.modules.xslmes.approval.integration.engine.IntegrationRevertTargetResolver; import org.jeecg.modules.xslmes.approval.integration.engine.RegistryStageFieldHelper; import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace; import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; @@ -54,6 +55,9 @@ public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { @Autowired private JdbcTemplate jdbcTemplate; + @Autowired + private IntegrationRevertTargetResolver revertTargetResolver; + @Override public String checkStageAllowed(String bizTable, String stage) { MesXslBizDocRegistry registry = findActiveRegistry(bizTable); @@ -109,7 +113,8 @@ public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { } LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(MesXslApprovalTrace::getId, trace.getId()); - if ("compile".equals(targetStage)) { + //update-begin---author:GHT ---date:20260609 for:【驳回回退】业务字典回退目标(如 0/待处理)清空全部环节痕迹----------- + if (isFullTraceClearTarget(targetStage)) { wrapper.set(MesXslApprovalTrace::getProofreadBy, null) .set(MesXslApprovalTrace::getProofreadTime, null) .set(MesXslApprovalTrace::getAuditBy, null) @@ -127,6 +132,7 @@ public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { } else { return; } + //update-end---author:GHT ---date:20260609 for:【驳回回退】业务字典回退目标(如 0/待处理)清空全部环节痕迹----------- traceMapper.update(null, wrapper); } @@ -178,76 +184,31 @@ public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { completions.stream().map(StageCompletion::getStage).reduce((a, b) -> a + "," + b).orElse("")); } - private void updateBizStageFields(MesXslBizDocRegistry registry, String bizTable, String bizDataId, StageCompletion completion) { - String stage = completion.getStage(); - String statusField = RegistryStageFieldHelper.statusField(registry); - String byField = RegistryStageFieldHelper.byField(registry, stage); - String timeField = RegistryStageFieldHelper.timeField(registry, stage); - RegistryStageFieldHelper.assertIdentifier(statusField); - if (oConvertUtils.isNotEmpty(byField)) { - RegistryStageFieldHelper.assertIdentifier(byField); - } - if (oConvertUtils.isNotEmpty(timeField)) { - RegistryStageFieldHelper.assertIdentifier(timeField); - } - StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `") - .append(statusField).append("`=?"); - List params = new ArrayList<>(); - params.add(stage); - if (oConvertUtils.isNotEmpty(byField)) { - sql.append(", `").append(byField).append("`=?"); - params.add(completion.getOperatorBy()); - } - if (oConvertUtils.isNotEmpty(timeField)) { - sql.append(", `").append(timeField).append("`=?"); - params.add(completion.getOperatorTime()); - } - sql.append(" WHERE id=?"); - params.add(bizDataId); - jdbcTemplate.update(sql.toString(), params.toArray()); - } //update-end---author:GHT ---date:20260608 for:【审批注册中心】按实例tasks反写审批痕迹明细----------- - //update-begin---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止时清空源单与痕迹操作人----------- + //update-begin---author:GHT ---date:20260609 for:【审批注册中心】拒绝/终止只重置业务表状态,操作人/时间由痕迹表承载----------- @Override @Transactional(rollbackFor = Exception.class) public void revertToCompile(String bizTable, String bizDataId) { if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) { return; } + //update-begin---author:GHT ---date:20260609 for:【驳回回退】补偿回退读取 onReject 集成方案 targetStage,不写死 compile----------- + String targetStage = revertTargetResolver.resolveRevertTarget(bizTable); MesXslBizDocRegistry registry = findActiveRegistry(bizTable); - if (registry == null) { - revertToStage(bizTable, bizDataId, "compile"); - return; + if (registry != null) { + String statusField = RegistryStageFieldHelper.statusField(registry); + RegistryStageFieldHelper.assertIdentifier(statusField); + RegistryStageFieldHelper.assertIdentifier(bizTable); + jdbcTemplate.update( + "UPDATE `" + bizTable + "` SET `" + statusField + "`=? WHERE id=?", + targetStage, bizDataId); } - String statusField = RegistryStageFieldHelper.statusField(registry); - RegistryStageFieldHelper.assertIdentifier(statusField); - RegistryStageFieldHelper.assertIdentifier(bizTable); - StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `") - .append(statusField).append("`=?"); - List params = new ArrayList<>(); - params.add("compile"); - appendClearField(sql, params, registry.getProofreadByField()); - appendClearField(sql, params, registry.getProofreadTimeField()); - appendClearField(sql, params, registry.getAuditByField()); - appendClearField(sql, params, registry.getAuditTimeField()); - appendClearField(sql, params, registry.getApproveByField()); - appendClearField(sql, params, registry.getApproveTimeField()); - sql.append(" WHERE id=?"); - params.add(bizDataId); - jdbcTemplate.update(sql.toString(), params.toArray()); - revertToStage(bizTable, bizDataId, "compile"); + revertToStage(bizTable, bizDataId, targetStage); + log.info("[审批痕迹回退] table={} id={} targetStage={}", bizTable, bizDataId, targetStage); + //update-end---author:GHT ---date:20260609 for:【驳回回退】补偿回退读取 onReject 集成方案 targetStage,不写死 compile----------- } - - private void appendClearField(StringBuilder sql, List params, String field) { - if (oConvertUtils.isEmpty(field)) { - return; - } - RegistryStageFieldHelper.assertIdentifier(field); - sql.append(", `").append(field).append("`=?"); - params.add(null); - } - //update-end---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止时清空源单与痕迹操作人----------- + //update-end---author:GHT ---date:20260609 for:【审批注册中心】拒绝/终止只重置业务表状态,操作人/时间由痕迹表承载----------- private MesXslApprovalTrace findTraceByBiz(String bizTable, String bizDataId) { if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) { @@ -298,4 +259,15 @@ public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { return stage; } } + + /** 回退到编制态或业务字典初始态时,清空全部审批环节痕迹 */ + private boolean isFullTraceClearTarget(String targetStage) { + if (oConvertUtils.isEmpty(targetStage)) { + return true; + } + return "compile".equals(targetStage) + || (!STAGE_PROOFREAD.equals(targetStage) + && !STAGE_AUDIT.equals(targetStage) + && !STAGE_APPROVE.equals(targetStage)); + } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java index 265f3921..b70b6cba 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java @@ -33,12 +33,14 @@ import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; /** @@ -73,6 +75,28 @@ public class MesXslApprovalTraceServiceImpl extends ServiceImpl batchQueryByBizIds(String bizTable, List bizDataIds) { + if (oConvertUtils.isEmpty(bizTable) || bizDataIds == null || bizDataIds.isEmpty()) { + return Collections.emptyMap(); + } + List ids = bizDataIds.stream() + .filter(oConvertUtils::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + if (ids.isEmpty()) { + return Collections.emptyMap(); + } + List traces = lambdaQuery() + .eq(MesXslApprovalTrace::getBizTable, bizTable) + .in(MesXslApprovalTrace::getBizDataId, ids) + .list(); + return traces.stream().collect( + Collectors.toMap(MesXslApprovalTrace::getBizDataId, Function.identity(), (a, b) -> a)); + } + //update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】批量查询痕迹供响应增强器注入----------- + @Override public MesXslApprovalTrace getByBiz(String bizTable, String bizDataId) { if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java index ed58a9af..8e812c86 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java @@ -25,12 +25,6 @@ public class MesXslBizDocRegistryServiceImpl extends ServiceImpl { + + @Autowired + private IMesXslDingCallbackLogService mesXslDingCallbackLogService; + + /** + * 分页列表查询 + */ + @Operation(summary = "钉钉回调日志-分页列表查询") + @GetMapping(value = "/list") + public Result> queryPageList(MesXslDingCallbackLog mesXslDingCallbackLog, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(mesXslDingCallbackLog, req.getParameterMap()); + Page page = new Page<>(pageNo, pageSize); + IPage pageList = mesXslDingCallbackLogService.page(page, queryWrapper); + return Result.OK(pageList); + } + + /** + * 添加 + */ + @AutoLog(value = "钉钉回调日志-添加") + @Operation(summary = "钉钉回调日志-添加") + @RequiresPermissions("xslmes:mes_xsl_ding_callback_log:add") + @PostMapping(value = "/add") + public Result add(@RequestBody MesXslDingCallbackLog mesXslDingCallbackLog) { + mesXslDingCallbackLogService.save(mesXslDingCallbackLog); + return Result.OK("添加成功!"); + } + + /** + * 编辑 + */ + @AutoLog(value = "钉钉回调日志-编辑") + @Operation(summary = "钉钉回调日志-编辑") + @RequiresPermissions("xslmes:mes_xsl_ding_callback_log:edit") + @RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST}) + public Result edit(@RequestBody MesXslDingCallbackLog mesXslDingCallbackLog) { + mesXslDingCallbackLogService.updateById(mesXslDingCallbackLog); + return Result.OK("编辑成功!"); + } + + /** + * 通过id删除 + */ + @AutoLog(value = "钉钉回调日志-通过id删除") + @Operation(summary = "钉钉回调日志-通过id删除") + @RequiresPermissions("xslmes:mes_xsl_ding_callback_log:delete") + @DeleteMapping(value = "/delete") + public Result delete(@RequestParam(name = "id", required = true) String id) { + mesXslDingCallbackLogService.removeById(id); + return Result.OK("删除成功!"); + } + + /** + * 批量删除 + */ + @AutoLog(value = "钉钉回调日志-批量删除") + @Operation(summary = "钉钉回调日志-批量删除") + @RequiresPermissions("xslmes:mes_xsl_ding_callback_log:deleteBatch") + @DeleteMapping(value = "/deleteBatch") + public Result deleteBatch(@RequestParam(name = "ids", required = true) String ids) { + this.mesXslDingCallbackLogService.removeByIds(Arrays.asList(ids.split(","))); + return Result.OK("批量删除成功!"); + } + + /** + * 通过id查询 + */ + @Operation(summary = "钉钉回调日志-通过id查询") + @GetMapping(value = "/queryById") + public Result queryById(@RequestParam(name = "id", required = true) String id) { + MesXslDingCallbackLog mesXslDingCallbackLog = mesXslDingCallbackLogService.getById(id); + if (mesXslDingCallbackLog == null) { + return Result.error("未找到对应数据"); + } + return Result.OK(mesXslDingCallbackLog); + } + + /** + * 导出excel + */ + @RequiresPermissions("xslmes:mes_xsl_ding_callback_log:exportXls") + @RequestMapping(value = "/exportXls") + public ModelAndView exportXls(HttpServletRequest request, MesXslDingCallbackLog mesXslDingCallbackLog) { + return super.exportXls(request, mesXslDingCallbackLog, MesXslDingCallbackLog.class, "钉钉回调日志"); + } + + /** + * 通过excel导入数据 + */ + @RequiresPermissions("xslmes:mes_xsl_ding_callback_log:importExcel") + @RequestMapping(value = "/importExcel", method = RequestMethod.POST) + public Result importExcel(HttpServletRequest request, HttpServletResponse response) { + return super.importExcel(request, response, MesXslDingCallbackLog.class); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/entity/MesXslDingCallbackLog.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/entity/MesXslDingCallbackLog.java new file mode 100644 index 00000000..a1872a12 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/entity/MesXslDingCallbackLog.java @@ -0,0 +1,111 @@ +package org.jeecg.modules.xslmes.dingtalk.callback.entity; + +import java.io.Serializable; +import java.util.Date; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.format.annotation.DateTimeFormat; +import org.jeecgframework.poi.excel.annotation.Excel; +import org.jeecg.common.aspect.annotation.Dict; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +/** + * @Description: 钉钉回调日志 + * @Author: jeecg-boot + * @Date: 2026-06-09 + * @Version: V1.0 + */ +@Data +@TableName("mes_xsl_ding_callback_log") +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = false) +@Schema(description = "钉钉回调日志") +public class MesXslDingCallbackLog implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键") + private String id; + + @Excel(name = "钉钉事件ID", width = 20) + @Schema(description = "钉钉事件ID") + private String eventId; + + @Excel(name = "事件类型", width = 25) + @Schema(description = "事件类型(bpms_instance_change/bpms_task_change)") + private String eventType; + + @Excel(name = "审批实例ID", width = 25) + @Schema(description = "审批实例ID") + private String processInstanceId; + + @Excel(name = "原始推送数据", width = 50) + @Schema(description = "原始推送数据JSON") + private String rawData; + + @Excel(name = "接收时间", width = 20, format = "yyyy-MM-dd HH:mm:ss") + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "接收时间") + private Date receivedTime; + + @Excel(name = "是否已处理", width = 10, dicCode = "yn") + @Dict(dicCode = "yn") + @Schema(description = "是否已处理集成方案(0否1是)") + private Integer processed; + + @Excel(name = "处理备注", width = 40) + @Schema(description = "处理备注") + private String processRemark; + + @Excel(name = "关联业务表", width = 20) + @Schema(description = "关联业务表") + private String bizTable; + + @Excel(name = "关联业务记录ID", width = 20) + @Schema(description = "关联业务记录ID") + private String bizDataId; + + @Excel(name = "关联审批台账ID", width = 20) + @Schema(description = "关联审批台账ID") + private String recordId; + + /**创建人*/ + @Schema(description = "创建人") + private String createBy; + + /**创建日期*/ + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "创建日期") + private Date createTime; + + /**更新人*/ + @Schema(description = "更新人") + private String updateBy; + + /**更新日期*/ + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "更新日期") + private Date updateTime; + + /**逻辑删除*/ + @TableLogic + @Schema(description = "逻辑删除 0正常 1删除") + private Integer delFlag; + + /**租户ID*/ + @Schema(description = "租户ID") + private Integer tenantId; + + /**所属部门*/ + @Schema(description = "所属部门") + private String sysOrgCode; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/mapper/MesXslDingCallbackLogMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/mapper/MesXslDingCallbackLogMapper.java new file mode 100644 index 00000000..d42f7b0f --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/mapper/MesXslDingCallbackLogMapper.java @@ -0,0 +1,13 @@ +package org.jeecg.modules.xslmes.dingtalk.callback.mapper; + +import org.jeecg.modules.xslmes.dingtalk.callback.entity.MesXslDingCallbackLog; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * @Description: 钉钉回调日志 + * @Author: jeecg-boot + * @Date: 2026-06-09 + * @Version: V1.0 + */ +public interface MesXslDingCallbackLogMapper extends BaseMapper { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/mapper/xml/MesXslDingCallbackLogMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/mapper/xml/MesXslDingCallbackLogMapper.xml new file mode 100644 index 00000000..5f3f1f9d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/mapper/xml/MesXslDingCallbackLogMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/service/IMesXslDingCallbackLogService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/service/IMesXslDingCallbackLogService.java new file mode 100644 index 00000000..cf687d01 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/service/IMesXslDingCallbackLogService.java @@ -0,0 +1,13 @@ +package org.jeecg.modules.xslmes.dingtalk.callback.service; + +import org.jeecg.modules.xslmes.dingtalk.callback.entity.MesXslDingCallbackLog; +import com.baomidou.mybatisplus.extension.service.IService; + +/** + * @Description: 钉钉回调日志 + * @Author: jeecg-boot + * @Date: 2026-06-09 + * @Version: V1.0 + */ +public interface IMesXslDingCallbackLogService extends IService { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/service/impl/MesXslDingCallbackLogServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/service/impl/MesXslDingCallbackLogServiceImpl.java new file mode 100644 index 00000000..8550d3da --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/callback/service/impl/MesXslDingCallbackLogServiceImpl.java @@ -0,0 +1,17 @@ +package org.jeecg.modules.xslmes.dingtalk.callback.service.impl; + +import org.jeecg.modules.xslmes.dingtalk.callback.entity.MesXslDingCallbackLog; +import org.jeecg.modules.xslmes.dingtalk.callback.mapper.MesXslDingCallbackLogMapper; +import org.jeecg.modules.xslmes.dingtalk.callback.service.IMesXslDingCallbackLogService; +import org.springframework.stereotype.Service; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + +/** + * @Description: 钉钉回调日志 + * @Author: jeecg-boot + * @Date: 2026-06-09 + * @Version: V1.0 + */ +@Service +public class MesXslDingCallbackLogServiceImpl extends ServiceImpl implements IMesXslDingCallbackLogService { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingApprovalReconcileScheduler.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingApprovalReconcileScheduler.java new file mode 100644 index 00000000..55d4d244 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingApprovalReconcileScheduler.java @@ -0,0 +1,194 @@ +package org.jeecg.modules.xslmes.dingtalk.stream; + +import com.alibaba.fastjson2.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.constant.ApprovalRecordConstants; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.List; + +/** + * 钉钉审批回调补偿扫描器。 + *

+ * Stream 模式仅保证"连接期间"的事件推送。服务重启、网络闪断或钉钉侧推送失败 + * 均可能导致事件静默丢失,造成审批台账长期停留在 RUNNING 状态。 + *

+ * 本定时任务每 {@link #SWEEP_INTERVAL_MS} 毫秒扫描一次 RUNNING 的钉钉台账, + * 主动调用钉钉 API 拉取最新实例状态: + *

    + *
  • 钉钉已终态(COMPLETED/TERMINATED)→ 构造合成事件调用 {@link DingBpmsEventProcessor#onInstanceChange}
  • + *
  • 钉钉仍 RUNNING 但中间节点已同意、MES 集成未执行 → 调用 {@link DingBpmsEventProcessor#reconcileIntermediateNodes}
  • + *
+ * 处理器内部已有幂等保护,重复调用安全。 + * + * @author GHT + * @date 2026-06-09 for:【钉钉Stream补偿扫描】漏推回调自动修复 + */ +@Slf4j +@Component +public class DingApprovalReconcileScheduler { + + private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG; + + /** 每次扫描最多处理的台账条数(避免单次扫描耗时过长 + DingTalk API 限速) */ + private static final int MAX_RECORDS_PER_SWEEP = 30; + + /** 两次扫描之间的间隔(毫秒),fixedDelay 保证上次扫描完成后再计时 */ + private static final long SWEEP_INTERVAL_MS = 3 * 60 * 1000L; + + /** + * 发起审批后的最短等待时间(毫秒),防止与 Stream 事件正常到达竞争。 + * 5 分钟内刚发起的审批不扫描,给 Stream 事件足够的到达时间。 + */ + private static final long MIN_AGE_MS = 5 * 60 * 1000L; + + /** DingTalk API 调用之间的间隔(毫秒),避免触发速率限制 */ + private static final long API_CALL_INTERVAL_MS = 300L; + + @Autowired + private IMesXslApprovalRecordService approvalRecordService; + + @Autowired + private DingTalkWorkflowService workflowService; + + @Autowired + private DingBpmsEventProcessor eventProcessor; + + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】漏推回调自动修复----- + @Scheduled(fixedDelay = SWEEP_INTERVAL_MS) + public void reconcile() { + long sweepStart = System.currentTimeMillis(); + Date cutoff = new Date(sweepStart - MIN_AGE_MS); + + List pendingRecords = approvalRecordService.list( + new LambdaQueryWrapper() + .eq(MesXslApprovalRecord::getStatus, ApprovalRecordConstants.STATUS_RUNNING) + .eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK) + .isNotNull(MesXslApprovalRecord::getExternalInstanceId) + .ne(MesXslApprovalRecord::getExternalInstanceId, "") + .lt(MesXslApprovalRecord::getApplyTime, cutoff) + .orderByAsc(MesXslApprovalRecord::getApplyTime) + .last("LIMIT " + MAX_RECORDS_PER_SWEEP)); + + if (pendingRecords.isEmpty()) { + log.debug("{} 补偿扫描:无待检查台账", LOG_TAG); + return; + } + + log.info("{} 补偿扫描开始,待检台账数={}", LOG_TAG, pendingRecords.size()); + int compensated = 0; + int nodeCompensated = 0; + int skipped = 0; + int failed = 0; + + for (MesXslApprovalRecord record : pendingRecords) { + String instanceId = record.getExternalInstanceId(); + try { + JSONObject instance = workflowService.getProcessInstance(instanceId); + if (instance == null) { + log.warn("{} 补偿扫描:拉取实例失败 instanceId={} recordId={}", + LOG_TAG, instanceId, record.getId()); + failed++; + continue; + } + + JSONObject syntheticEvent = buildSyntheticEvent(instance, instanceId); + if (syntheticEvent != null) { + log.info("{} 补偿扫描:检测到漏推终态事件,触发补偿 instanceId={} dingStatus={} dingResult={}", + LOG_TAG, instanceId, + instance.getString("status"), instance.getString("result")); + eventProcessor.onInstanceChange(syntheticEvent); + compensated++; + } else if ("RUNNING".equalsIgnoreCase(instance.getString("status"))) { + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】RUNNING态补中间节点集成----------- + int nodes = eventProcessor.reconcileIntermediateNodes(record, instance); + if (nodes > 0) { + nodeCompensated += nodes; + log.info("{} 补偿扫描:中间节点已补偿 instanceId={} recordId={} nodes={}", + LOG_TAG, instanceId, record.getId(), nodes); + } else { + log.debug("{} 补偿扫描:RUNNING且无待补中间节点 instanceId={}", LOG_TAG, instanceId); + skipped++; + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】RUNNING态补中间节点集成----------- + } else { + log.debug("{} 补偿扫描:非RUNNING且无法映射终态,跳过 instanceId={} dingStatus={}", + LOG_TAG, instanceId, instance.getString("status")); + skipped++; + } + + } catch (Exception e) { + log.error("{} 补偿扫描:处理异常 instanceId={} recordId={}: {}", + LOG_TAG, instanceId, record.getId(), e.getMessage(), e); + failed++; + } + + // 避免连续 API 调用触发 DingTalk 限速 + sleepQuietly(API_CALL_INTERVAL_MS); + } + + log.info("{} 补偿扫描完成 总数={} 终态补偿={} 中间节点补偿={} 仍跳过={} 失败={} costMs={}", + LOG_TAG, pendingRecords.size(), compensated, nodeCompensated, skipped, failed, + System.currentTimeMillis() - sweepStart); + } + + /** + * 将钉钉实例状态映射为 {@code onInstanceChange} 所需的合成事件。 + *
    + *
  • COMPLETED + agree → type=finish, result=agree
  • + *
  • COMPLETED + refuse → type=finish, result=refuse
  • + *
  • TERMINATED/CANCELED → type=terminate
  • + *
  • RUNNING 或未知 → null(不补偿)
  • + *
+ */ + private JSONObject buildSyntheticEvent(JSONObject instance, String processInstanceId) { + String dingStatus = instance.getString("status"); + String dingResult = instance.getString("result"); + + if ("RUNNING".equalsIgnoreCase(dingStatus)) { + return null; + } + + JSONObject event = new JSONObject(); + event.put("processInstanceId", processInstanceId); + + if ("COMPLETED".equalsIgnoreCase(dingStatus)) { + if ("agree".equalsIgnoreCase(dingResult)) { + event.put("type", "finish"); + event.put("result", "agree"); + } else if ("refuse".equalsIgnoreCase(dingResult)) { + event.put("type", "finish"); + event.put("result", "refuse"); + } else { + // redirect/转交等非终态结果,跳过(onInstanceChange 内部会跳过这类result) + event.put("type", "finish"); + event.put("result", oConvertUtils.isEmpty(dingResult) ? "unknown" : dingResult); + } + return event; + } + + if ("TERMINATED".equalsIgnoreCase(dingStatus) || "CANCELED".equalsIgnoreCase(dingStatus)) { + event.put("type", "terminate"); + return event; + } + + log.info("{} 补偿扫描:未知钉钉状态 dingStatus={} instanceId={}", LOG_TAG, dingStatus, processInstanceId); + return null; + } + + private void sleepQuietly(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】漏推回调自动修复----- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java index 87e41ccb..164013e1 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java @@ -14,7 +14,11 @@ import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord; import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService; import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService; import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor; +import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.NodePair; +import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.NodeTaskDecision; import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.StageCompletion; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationLog; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationLogService; import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; @@ -63,6 +67,12 @@ public class DingBpmsEventProcessor { @Autowired private ApprovalInstanceStageExtractor instanceStageExtractor; + @Autowired + private DingStreamCallbackLogHelper callbackLogHelper; + + @Autowired + private IMesXslIntegrationLogService integrationLogService; + // ==================== bpms_instance_change ==================== //update-begin---author:GHT ---date:20260604 for:【钉钉Stream回调】拉取实例详情后精准执行节点回调----- @@ -112,20 +122,28 @@ public class DingBpmsEventProcessor { log.info("{} bpms_instance_change 映射终态 instanceId={} mesStatus={} remark={}", LOG_TAG, processInstanceId, status, remark); - //update-begin---author:GHT ---date:2026-06-04 for:【20260604】钉钉回调幂等去重:finishByExternalInstance条件为status=RUNNING,0行更新即终态已处理----- + //update-begin---author:GHT ---date:20260609 for:【驳回回退】台账已是终态时仍补偿触发业务回调,避免集成未执行----------- try { boolean updated = approvalGateService.finishByExternalInstance( ApprovalRecordConstants.CHANNEL_DINGTALK, processInstanceId, status, remark); - if (!updated) { - log.info("{} bpms_instance_change 跳过:台账已是终态(重复事件) instanceId={}", LOG_TAG, processInstanceId); - return; + if (updated) { + log.info("{} 台账已更新 instanceId={} -> status={}", LOG_TAG, processInstanceId, status); + } else { + MesXslApprovalRecord existing = findRecord(processInstanceId); + if (existing != null && status.equals(existing.getStatus())) { + log.info("{} bpms_instance_change 台账已是终态({}),补偿触发业务/集成回调 instanceId={}", + LOG_TAG, status, processInstanceId); + } else { + log.info("{} bpms_instance_change 跳过:台账已是终态(状态不一致或重复事件) instanceId={}", + LOG_TAG, processInstanceId); + return; + } } - log.info("{} 台账已更新 instanceId={} -> status={}", LOG_TAG, processInstanceId, status); } catch (Exception e) { log.error("{} 台账更新失败 instanceId={}: {}", LOG_TAG, processInstanceId, e.getMessage(), e); return; } - //update-end---author:GHT ---date:2026-06-04 for:【20260604】钉钉回调幂等去重:finishByExternalInstance条件为status=RUNNING,0行更新即终态已处理----- + //update-end---author:GHT ---date:20260609 for:【驳回回退】台账已是终态时仍补偿触发业务/集成回调,避免集成未执行----------- //update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R5】TERMINATED时触发fireCancelled,允许业务回滚中间态----------- if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) { @@ -135,6 +153,9 @@ public class DingBpmsEventProcessor { logCallbackDispatch("fireCancelled", cancelCtx); try { callbackDispatcher.fireCancelled(cancelCtx); + //update-begin---author:GHT ---date:20260609 for:【钉钉回调日志】终止态回调已触发----------- + callbackLogHelper.markProcessed("审批终止,已触发 fireCancelled", cancelledRecord); + //update-end---author:GHT ---date:20260609 for:【钉钉回调日志】终止态回调已触发----------- } catch (Exception e) { log.error("{} fireCancelled 失败 instanceId={}: {}", LOG_TAG, processInstanceId, e.getMessage(), e); } @@ -250,6 +271,13 @@ public class DingBpmsEventProcessor { } //update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D2】用activityId替代mesNodes索引定位终态节点,支持条件分支场景----------- + //update-begin---author:GHT ---date:20260609 for:【钉钉回调日志】终态回调已触发----------- + String processRemark = ApprovalRecordConstants.STATUS_APPROVED.equals(status) + ? "审批通过,已触发 fireNodeApproved/fireApproved" + : "审批驳回,已触发 fireRejected"; + callbackLogHelper.markProcessed(processRemark, record); + //update-end---author:GHT ---date:20260609 for:【钉钉回调日志】终态回调已触发----------- + log.info("{} bpms_instance_change 完成 instanceId={} bizTable={} bizDataId={} mesStatus={}", LOG_TAG, processInstanceId, record.getBizTable(), record.getBizDataId(), status); } @@ -279,8 +307,19 @@ public class DingBpmsEventProcessor { LOG_TAG, type, processInstanceId); return; } + //update-begin---author:GHT ---date:20260609 for:【驳回回退】task_change拒绝时主动触发终态处理,不依赖 instance_change----------- + if ("refuse".equals(result)) { + log.info("{} bpms_task_change 收到拒绝,转交 onInstanceChange(instance_change 可能未推送) instanceId={}", + LOG_TAG, processInstanceId); + JSONObject instanceData = new JSONObject(data); + instanceData.put("type", "finish"); + instanceData.put("result", "refuse"); + onInstanceChange(instanceData); + return; + } + //update-end---author:GHT ---date:20260609 for:【驳回回退】task_change拒绝时主动触发终态处理,不依赖 instance_change----------- if (!"agree".equals(result)) { - log.info("{} bpms_task_change 跳过:result={} 非同意,refuse/redirect 由 bpms_instance_change 处理 instanceId={}", + log.info("{} bpms_task_change 跳过:result={} 非同意,redirect 由 bpms_instance_change 处理 instanceId={}", LOG_TAG, result, processInstanceId); return; } @@ -428,6 +467,9 @@ public class DingBpmsEventProcessor { .setActivityId(activityId); logCallbackDispatch("fireNodeApproved", ctx); callbackDispatcher.fireNodeApproved(ctx); + //update-begin---author:GHT ---date:20260609 for:【钉钉回调日志】节点通过回调已触发----------- + callbackLogHelper.markProcessed("节点审批通过,已触发 fireNodeApproved", record); + //update-end---author:GHT ---date:20260609 for:【钉钉回调日志】节点通过回调已触发----------- log.info("{} fireNodeApproved 成功 instanceId={} nodeName={}", LOG_TAG, processInstanceId, ctx.getNodeName()); } catch (Exception e) { log.error("{} fireNodeApproved 失败 instanceId={} nodeName={}: {}", @@ -440,6 +482,127 @@ public class DingBpmsEventProcessor { } //update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove----- + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】RUNNING态补中间节点集成----------- + /** + * 补偿钉钉侧已同意、但 MES 集成未执行的中间节点(审批仍为 RUNNING 时由定时扫描调用)。 + *

+ * 以集成日志为准判断是否需要补偿,避免 processed_op_count 虚高导致漏补。 + * + * @return 本次补偿触发的节点数 + */ + public int reconcileIntermediateNodes(MesXslApprovalRecord record, JSONObject instance) { + if (record == null || instance == null) { + return 0; + } + if (!ApprovalRecordConstants.STATUS_RUNNING.equals(record.getStatus())) { + return 0; + } + MesXslApprovalFlow flow = loadFlow(record.getFlowId()); + if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) { + return 0; + } + + List pairs = instanceStageExtractor.alignMesNodesWithTasks(instance, flow.getFlowConfig()); + if (pairs.isEmpty()) { + log.info("{} 中间节点补偿:无节点对齐 instanceId={} recordId={}", + LOG_TAG, record.getExternalInstanceId(), record.getId()); + return 0; + } + + int compensated = 0; + for (int nodeIndex = 0; nodeIndex < pairs.size(); nodeIndex++) { + NodePair pair = pairs.get(nodeIndex); + NodeTaskDecision decision = instanceStageExtractor.evaluateNodeTasks( + pair.getTaskList(), instanceStageExtractor.resolveApprovalMethod(pair.getMesNode())); + if (!decision.isAgreed()) { + continue; + } + + String planId = resolveNodeIntegrationPlanId(pair.getMesNode()); + if (oConvertUtils.isNotEmpty(planId) && hasIntegrationSuccess(record.getId(), planId)) { + continue; + } + if (oConvertUtils.isEmpty(planId)) { + approvalGateService.tryMarkNodeProcessed(record.getId(), nodeIndex); + continue; + } + + JSONObject mesNode = pair.getMesNode(); + String nodeName = mesNode != null ? mesNode.getString("name") : null; + log.info("{} 中间节点补偿:触发集成 instanceId={} recordId={} nodeIndex={} nodeName={} planId={}", + LOG_TAG, record.getExternalInstanceId(), record.getId(), nodeIndex, nodeName, planId); + + try { + fireCompensatedNodeApproved(record, instance, pair, nodeIndex, decision); + compensated++; + } catch (Exception e) { + log.error("{} 中间节点补偿失败 instanceId={} nodeIndex={} nodeName={}: {}", + LOG_TAG, record.getExternalInstanceId(), nodeIndex, nodeName, e.getMessage(), e); + } + } + return compensated; + } + + private void fireCompensatedNodeApproved(MesXslApprovalRecord record, JSONObject instance, + NodePair pair, int nodeIndex, NodeTaskDecision decision) { + JSONObject mesNode = pair.getMesNode(); + String dtUserId = null; + if (decision.getActorUserIds() != null && !decision.getActorUserIds().isEmpty()) { + dtUserId = decision.getActorUserIds().get(decision.getActorUserIds().size() - 1); + } + String token = workflowService.generateTokenByDtUserId(dtUserId); + + JSONObject nodeProps = mesNode != null ? mesNode.getJSONObject("props") : null; + String stageKey = nodeProps != null ? nodeProps.getString("stageKey") : null; + String actioner = "审批人"; + StageCompletion completion = instanceStageExtractor.extractActivityCompletion( + instance, pair.getActivityId(), mesNode); + if (completion != null && oConvertUtils.isNotEmpty(completion.getOperatorBy())) { + actioner = completion.getOperatorBy(); + } + + ApprovalCallbackContext ctx = buildContext(record, "钉钉节点补偿(" + actioner + ")", token) + .setOperatorName(actioner) + .setOperatorTime(decision.getOperatorTime()) + .setNodeId(mesNode != null ? mesNode.getString("id") : null) + .setNodeName(mesNode != null ? mesNode.getString("name") : null) + .setStageKey(stageKey) + .setActivityId(pair.getActivityId()); + + logCallbackDispatch("fireNodeApproved (中间节点补偿)", ctx); + callbackDispatcher.fireNodeApproved(ctx); + approvalGateService.tryMarkNodeProcessed(record.getId(), nodeIndex); + log.info("{} 中间节点补偿完成 instanceId={} nodeIndex={} nodeName={}", + LOG_TAG, record.getExternalInstanceId(), nodeIndex, ctx.getNodeName()); + } + + private String resolveNodeIntegrationPlanId(JSONObject mesNode) { + if (mesNode == null) { + return null; + } + JSONObject props = mesNode.getJSONObject("props"); + if (props == null) { + return null; + } + JSONObject plans = props.getJSONObject("integrationPlans"); + if (plans == null) { + return null; + } + return plans.getString("onNodeApprove"); + } + + private boolean hasIntegrationSuccess(String recordId, String planId) { + if (oConvertUtils.isEmpty(recordId) || oConvertUtils.isEmpty(planId)) { + return false; + } + return integrationLogService.lambdaQuery() + .eq(MesXslIntegrationLog::getRecordId, recordId) + .eq(MesXslIntegrationLog::getPlanId, planId) + .eq(MesXslIntegrationLog::getStatus, "success") + .count() > 0; + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】RUNNING态补中间节点集成----------- + // ==================== 内部辅助 ==================== private MesXslApprovalRecord findRecord(String processInstanceId) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingStreamCallbackLogHelper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingStreamCallbackLogHelper.java new file mode 100644 index 00000000..872d7f87 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingStreamCallbackLogHelper.java @@ -0,0 +1,152 @@ +package org.jeecg.modules.xslmes.dingtalk.stream; + +import com.alibaba.fastjson2.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord; +import org.jeecg.modules.xslmes.dingtalk.callback.entity.MesXslDingCallbackLog; +import org.jeecg.modules.xslmes.dingtalk.callback.service.IMesXslDingCallbackLogService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Date; + +/** + * 钉钉 Stream 回调日志落库辅助类。 + *

+ * 在 {@link DingTalkStreamSdkRunner} 入站时写入原始推送;在 {@link DingBpmsEventProcessor} + * 触发集成/业务回调后更新 processed 及关联业务字段。 + */ +@Slf4j +@Component +public class DingStreamCallbackLogHelper { + + private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG; + + @Autowired + private IMesXslDingCallbackLogService callbackLogService; + + private final ThreadLocal processingContext = new ThreadLocal<>(); + + //update-begin---author:GHT ---date:20260609 for:【钉钉回调日志】Stream入站原始推送落库----------- + /** + * 记录 Stream 入站事件(所有事件类型均落库)。 + * + * @return 日志主键,供后续更新处理结果 + */ + public String recordInbound(String eventId, String eventType, JSONObject data) { + MesXslDingCallbackLog row = new MesXslDingCallbackLog(); + row.setEventId(eventId); + row.setEventType(eventType); + row.setReceivedTime(new Date()); + row.setProcessed(0); + if (data != null) { + row.setProcessInstanceId(data.getString("processInstanceId")); + row.setRawData(data.toJSONString()); + } + try { + callbackLogService.save(row); + return row.getId(); + } catch (Exception e) { + log.error("{} 回调日志落库失败 eventId={} eventType={}: {}", + LOG_TAG, eventId, eventType, e.getMessage(), e); + return null; + } + } + + /** + * 绑定当前线程正在处理的日志 ID(由 SdkRunner 在调用 Processor 前设置)。 + */ + public void beginProcessing(String logId) { + if (oConvertUtils.isEmpty(logId)) { + processingContext.remove(); + return; + } + processingContext.set(new ProcessingContext(logId)); + } + + /** + * 标记已触发集成/业务回调。 + */ + public void markProcessed(String remark, MesXslApprovalRecord record) { + ProcessingContext ctx = processingContext.get(); + if (ctx == null || ctx.marked) { + return; + } + ctx.marked = true; + updateLog(ctx.logId, 1, remark, record); + } + + /** + * 标记明确跳过(非 BPMS、data 为空等 SdkRunner 层可判定场景)。 + */ + public void markSkipped(String remark) { + ProcessingContext ctx = processingContext.get(); + if (ctx == null) { + return; + } + if (!ctx.marked) { + ctx.marked = true; + updateLog(ctx.logId, 0, remark, null); + } + } + + /** + * 处理结束:若 Processor 未显式标记,则记为「已接收但未触发集成处理」。 + */ + public void finishProcessing() { + ProcessingContext ctx = processingContext.get(); + if (ctx != null && !ctx.marked) { + updateLog(ctx.logId, 0, "已接收但未触发集成处理", null); + } + processingContext.remove(); + } + + /** + * 处理异常时更新备注。 + */ + public void markError(String remark) { + ProcessingContext ctx = processingContext.get(); + if (ctx != null) { + updateLog(ctx.logId, 0, remark, null); + } + processingContext.remove(); + } + + private void updateLog(String logId, int processed, String remark, MesXslApprovalRecord record) { + if (oConvertUtils.isEmpty(logId)) { + return; + } + try { + MesXslDingCallbackLog row = new MesXslDingCallbackLog(); + row.setId(logId); + row.setProcessed(processed); + row.setProcessRemark(truncateRemark(remark)); + if (record != null) { + row.setBizTable(record.getBizTable()); + row.setBizDataId(record.getBizDataId()); + row.setRecordId(record.getId()); + } + callbackLogService.updateById(row); + } catch (Exception e) { + log.error("{} 回调日志更新失败 logId={}: {}", LOG_TAG, logId, e.getMessage(), e); + } + } + + private static String truncateRemark(String remark) { + if (remark == null) { + return null; + } + return remark.length() <= 500 ? remark : remark.substring(0, 500); + } + + private static final class ProcessingContext { + private final String logId; + private boolean marked; + + private ProcessingContext(String logId) { + this.logId = logId; + } + } + //update-end---author:GHT ---date:20260609 for:【钉钉回调日志】Stream入站原始推送落库----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java index c6b9cef2..e950b76e 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java @@ -13,6 +13,9 @@ import org.springframework.stereotype.Component; * 无需注册公网回调地址:应用主动建立长连接,钉钉通过该通道推送事件(如审批结果), * 官方 SDK 内部自动维护重连与心跳。 *

+ * 集群模式(默认开启):通过 {@link DingTalkStreamLeaderElection} Redis 选主, + * 仅 Leader 节点建连,避免多实例抢消息。 + *

* 启动时机:{@link SmartLifecycle}(phase=MAX-100)确保 Spring 上下文完全就绪后再建连。 * SDK 实际启动委托给 {@link DingTalkStreamSdkRunner},避免本类直接引用钉钉 SDK 类型。 * @@ -23,7 +26,9 @@ import org.springframework.stereotype.Component; @Component public class DingTalkStreamClient implements SmartLifecycle { + private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG; private static final String SDK_RUNNER_CLASS = "org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamSdkRunner"; + private static final String SDK_CLIENT_CLASS = "com.dingtalk.open.app.api.OpenDingTalkClient"; @Autowired private ThirdAppDingtalkServiceImpl dingtalkService; @@ -31,7 +36,17 @@ public class DingTalkStreamClient implements SmartLifecycle { @Autowired private DingBpmsEventProcessor bpmsEventProcessor; + @Autowired + private DingStreamCallbackLogHelper callbackLogHelper; + + @Autowired + private DingTalkStreamProperties streamProperties; + + @Autowired + private DingTalkStreamLeaderElection leaderElection; + private volatile boolean running = false; + private volatile Object streamClientRef; // ==================== SmartLifecycle ==================== @@ -45,7 +60,6 @@ public class DingTalkStreamClient implements SmartLifecycle { @Override public void start() { running = true; - // 在后台线程初始化,避免阻塞 Spring 上下文刷新 Thread t = new Thread(this::initSdkClient, "ding-stream"); t.setDaemon(true); t.start(); @@ -54,8 +68,11 @@ public class DingTalkStreamClient implements SmartLifecycle { @Override public void stop() { running = false; - // SDK 内部使用 daemon 线程,JVM 退出时自动终止 - log.info("{} Stream 客户端已停止", DingTalkStreamSdkRunner.LOG_TAG); + stopStreamClient(); + if (streamProperties.isClusterMode()) { + leaderElection.release(); + } + log.info("{} Stream 客户端已停止", LOG_TAG); } @Override @@ -66,27 +83,122 @@ public class DingTalkStreamClient implements SmartLifecycle { // ==================== SDK 初始化(反射委托,避免 LiteFlow 扫描期加载钉钉类)==================== - //update-begin---author:GHT ---date:2026-06-05 for:【钉钉Stream回调】反射调用SDK启动器,避免LiteFlow扫描触发DingTalkCredential加载失败----- + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream集群】Redis选主+心跳重连生命周期管理----- private void initSdkClient() { + String[] creds = resolveCredentials(); + if (creds == null) { + return; + } + + if (!streamProperties.isClusterMode()) { + log.info("{} 单实例模式(cluster-mode=false),本节点直接建立 Stream 连接", LOG_TAG); + try { + startStreamClient(creds); + } catch (ClassNotFoundException e) { + log.warn("{} Stream SDK 未在 classpath 中(dingtalk-stream),连接未启动", LOG_TAG); + } catch (Exception e) { + log.error("{} SDK 启动失败: {}", LOG_TAG, e.getMessage(), e); + } + return; + } + + if (!leaderElection.isRedisAvailable()) { + log.error("{} 集群模式已开启但 Redis 不可用,Stream 未启动。" + + "请检查 Redis 连接,或设置 jeecg.xslmes.dingtalk.stream.cluster-mode=false", LOG_TAG); + return; + } + + log.info("{} 集群模式已开启,通过 Redis 选主建立 Stream 连接 instanceId={}", + LOG_TAG, leaderElection.instanceId()); + + boolean streamActive = false; + while (running) { + try { + if (streamActive) { + if (leaderElection.renew()) { + sleepQuietly(streamProperties.getLeaderRenewIntervalMs()); + continue; + } + log.warn("{} Leader 锁续期失败,断开 Stream 并降级为 Follower instanceId={} currentLeader={}", + LOG_TAG, leaderElection.instanceId(), leaderElection.currentHolder()); + stopStreamClient(); + streamActive = false; + leaderElection.release(); + sleepQuietly(streamProperties.getFollowerRetryIntervalMs()); + continue; + } + + if (leaderElection.tryAcquire()) { + startStreamClient(creds); + streamActive = true; + sleepQuietly(streamProperties.getLeaderRenewIntervalMs()); + } else { + log.debug("{} 本节点为 Follower,等待 Leader 释放锁 instanceId={} currentLeader={}", + LOG_TAG, leaderElection.instanceId(), leaderElection.currentHolder()); + sleepQuietly(streamProperties.getFollowerRetryIntervalMs()); + } + } catch (Exception e) { + log.error("{} Stream 集群生命周期异常: {}", LOG_TAG, e.getMessage(), e); + stopStreamClient(); + streamActive = false; + leaderElection.release(); + sleepQuietly(streamProperties.getFollowerRetryIntervalMs()); + } + } + + if (streamActive) { + stopStreamClient(); + leaderElection.release(); + } + } + + private String[] resolveCredentials() { try { String[] creds = dingtalkService.getDingAppCredentials(); if (creds == null || oConvertUtils.isEmpty(creds[0]) || oConvertUtils.isEmpty(creds[1])) { log.warn("{} AppKey/AppSecret 未配置,Stream 连接未启动。" - + "请在【系统配置-第三方应用】中完成钉钉应用配置后重启服务。", DingTalkStreamSdkRunner.LOG_TAG); - return; + + "请在【系统配置-第三方应用】中完成钉钉应用配置后重启服务。", LOG_TAG); + return null; } - - Class runnerClass = Class.forName(SDK_RUNNER_CLASS); - runnerClass - .getMethod("start", String.class, String.class, DingBpmsEventProcessor.class) - .invoke(null, creds[0], creds[1], bpmsEventProcessor); - - } catch (ClassNotFoundException e) { - log.warn("{} Stream SDK 未在 classpath 中(dingtalk-stream),连接未启动。" - + "请执行 Maven 刷新/重新编译后重试。", DingTalkStreamSdkRunner.LOG_TAG); + return creds; } catch (Exception e) { - log.error("{} SDK 启动失败,请检查钉钉配置: {}", DingTalkStreamSdkRunner.LOG_TAG, e.getMessage(), e); + log.error("{} 读取钉钉凭证失败: {}", LOG_TAG, e.getMessage(), e); + return null; } } - //update-end---author:GHT ---date:2026-06-05 for:【钉钉Stream回调】反射调用SDK启动器,避免LiteFlow扫描触发DingTalkCredential加载失败----- + + private void startStreamClient(String[] creds) throws Exception { + Class runnerClass = Class.forName(SDK_RUNNER_CLASS); + Object client = runnerClass + .getMethod("start", String.class, String.class, DingBpmsEventProcessor.class, + DingStreamCallbackLogHelper.class) + .invoke(null, creds[0], creds[1], bpmsEventProcessor, callbackLogHelper); + streamClientRef = client; + } + + private void stopStreamClient() { + if (streamClientRef == null) { + return; + } + try { + Class runnerClass = Class.forName(SDK_RUNNER_CLASS); + Class clientClass = Class.forName(SDK_CLIENT_CLASS); + runnerClass.getMethod("stop", clientClass).invoke(null, streamClientRef); + } catch (ClassNotFoundException e) { + log.warn("{} Stream SDK 未在 classpath 中,跳过断开", LOG_TAG); + } catch (Exception e) { + log.warn("{} Stream 断开失败: {}", LOG_TAG, e.getMessage(), e); + } finally { + streamClientRef = null; + } + } + + private void sleepQuietly(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream集群】Redis选主+心跳重连生命周期管理----- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java new file mode 100644 index 00000000..97632f6b --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java @@ -0,0 +1,60 @@ +package org.jeecg.modules.xslmes.dingtalk.stream; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 钉钉 Stream 连接存活状态监控(定时日志)。 + */ +@Slf4j +@Component +public class DingTalkStreamHealthMonitor { + + private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG; + + @Autowired + private DingTalkStreamProperties properties; + + @Autowired + private DingTalkStreamLeaderElection leaderElection; + + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream监控】定时输出存活与心跳状态----------- + @Scheduled(fixedDelayString = "${jeecg.xslmes.dingtalk.stream.health-log-interval-ms:60000}") + public void reportHealth() { + DingTalkStreamSdkRunner.ConnectionSnapshot snap = DingTalkStreamSdkRunner.snapshot(); + long now = System.currentTimeMillis(); + + boolean clusterMode = properties.isClusterMode(); + String role; + String leaderHolder; + if (!clusterMode) { + role = "STANDALONE"; + leaderHolder = leaderElection.instanceId(); + } else if (leaderElection.isLeader()) { + role = "LEADER"; + leaderHolder = leaderElection.instanceId(); + } else { + role = "FOLLOWER"; + leaderHolder = leaderElection.currentHolder(); + } + + Long connectedSec = snap.connectedAtMs() > 0 ? (now - snap.connectedAtMs()) / 1000 : null; + Long lastEventAgoSec = snap.lastEventAtMs() > 0 ? (now - snap.lastEventAtMs()) / 1000 : null; + + log.info("{} Stream存活状态 role={} instanceId={} leaderHolder={} streamRunning={} connectedSec={} " + + "lastEventAgoSec={} totalEvents={} reconnectCount={}", + LOG_TAG, role, leaderElection.instanceId(), leaderHolder, + snap.streamRunning(), connectedSec, lastEventAgoSec, + snap.totalEventCount(), snap.reconnectCount()); + + if ("LEADER".equals(role) && snap.streamRunning() + && snap.lastEventAtMs() > 0 + && (now - snap.lastEventAtMs()) > properties.getIdleWarnSeconds() * 1000L) { + log.warn("{} Stream长时间无推送(可能业务空闲或连接异常)idleSec={} thresholdSec={}", + LOG_TAG, lastEventAgoSec, properties.getIdleWarnSeconds()); + } + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream监控】定时输出存活与心跳状态----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamLeaderElection.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamLeaderElection.java new file mode 100644 index 00000000..365a17c7 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamLeaderElection.java @@ -0,0 +1,111 @@ +package org.jeecg.modules.xslmes.dingtalk.stream; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.lang.management.ManagementFactory; +import java.net.InetAddress; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 钉钉 Stream 集群 Leader 选举(Redis 分布式锁)。 + *

+ * 同一 AppKey 在多个 JeecgBoot 实例上只能有一个活跃 Stream 连接, + * 避免消息被随机节点抢走且处理失败。 + */ +@Slf4j +@Component +public class DingTalkStreamLeaderElection { + + private static final String LOCK_KEY = "mes:xsl:dingtalk:stream:leader"; + private static final long LOCK_TTL_SECONDS = 30L; + + private final String instanceId; + private final RedisTemplate redisTemplate; + + @Autowired + public DingTalkStreamLeaderElection(@Autowired(required = false) RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.instanceId = buildInstanceId(); + } + + public String instanceId() { + return instanceId; + } + + public boolean isRedisAvailable() { + return redisTemplate != null; + } + + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream集群】Redis选主仅单节点建连----------- + /** + * 尝试成为 Leader。 + */ + public boolean tryAcquire() { + if (redisTemplate == null) { + return false; + } + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(LOCK_KEY, instanceId, LOCK_TTL_SECONDS, TimeUnit.SECONDS); + boolean ok = Boolean.TRUE.equals(acquired); + if (ok) { + log.info("{} 获得 Stream Leader 锁 instanceId={}", DingTalkStreamSdkRunner.LOG_TAG, instanceId); + } + return ok; + } + + /** + * 续期 Leader 锁。 + */ + public boolean renew() { + if (redisTemplate == null) { + return false; + } + Object current = redisTemplate.opsForValue().get(LOCK_KEY); + if (!instanceId.equals(String.valueOf(current))) { + return false; + } + return Boolean.TRUE.equals(redisTemplate.expire(LOCK_KEY, LOCK_TTL_SECONDS, TimeUnit.SECONDS)); + } + + /** + * 释放 Leader 锁(仅当前持有者有效)。 + */ + public void release() { + if (redisTemplate == null) { + return; + } + Object current = redisTemplate.opsForValue().get(LOCK_KEY); + if (instanceId.equals(String.valueOf(current))) { + redisTemplate.delete(LOCK_KEY); + log.info("{} 已释放 Stream Leader 锁 instanceId={}", DingTalkStreamSdkRunner.LOG_TAG, instanceId); + } + } + + public String currentHolder() { + if (redisTemplate == null) { + return null; + } + Object holder = redisTemplate.opsForValue().get(LOCK_KEY); + return holder != null ? String.valueOf(holder) : null; + } + + public boolean isLeader() { + return instanceId.equals(currentHolder()); + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream集群】Redis选主仅单节点建连----------- + + private static String buildInstanceId() { + String host = "unknown-host"; + try { + host = InetAddress.getLocalHost().getHostName(); + } catch (Exception ignored) { + // 使用默认 host 标识 + } + String pid = ManagementFactory.getRuntimeMXBean().getName(); + return host + ":" + pid + "@" + UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java new file mode 100644 index 00000000..5b2450bb --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java @@ -0,0 +1,32 @@ +package org.jeecg.modules.xslmes.dingtalk.stream; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 钉钉 Stream 集群与监控配置。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "jeecg.xslmes.dingtalk.stream") +public class DingTalkStreamProperties { + + /** + * 集群模式:true 时通过 Redis 选主,仅 Leader 节点建立 Stream 长连接。 + * 多实例生产环境务必保持 true;单实例可设为 false 简化部署。 + */ + private boolean clusterMode = true; + + /** Leader 锁续期间隔(毫秒) */ + private long leaderRenewIntervalMs = 10_000L; + + /** Follower 抢主重试间隔(毫秒) */ + private long followerRetryIntervalMs = 15_000L; + + /** 存活状态日志输出间隔(毫秒) */ + private long healthLogIntervalMs = 60_000L; + + /** 无事件空闲告警阈值(秒),Leader 且连接中超过该时间无推送则 warn */ + private long idleWarnSeconds = 1800L; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamSdkRunner.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamSdkRunner.java index 21e2e6a9..4ad5f0cd 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamSdkRunner.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamSdkRunner.java @@ -1,109 +1,242 @@ -package org.jeecg.modules.xslmes.dingtalk.stream; - -import com.dingtalk.open.app.api.GenericEventListener; -import com.dingtalk.open.app.api.OpenDingTalkStreamClientBuilder; -import com.dingtalk.open.app.api.message.GenericOpenDingTalkEvent; -import com.dingtalk.open.app.api.security.AuthClientCredential; -import com.dingtalk.open.app.stream.protocol.event.EventAckStatus; -import lombok.extern.slf4j.Slf4j; - -/** - * 钉钉 Stream SDK 启动器(非 Spring Bean)。 - *

- * 与 {@link DingTalkStreamClient} 分离:LiteFlow 在上下文初始化早期会扫描所有 {@code @Component} - * 并调用 {@code getDeclaredMethods()},若 Bean 类字节码直接引用钉钉 SDK 类型,会提前加载 - * {@code DingTalkCredential} 等类;本类不参与 Spring 扫描,仅在后台线程中按需加载。 - * - * @author GHT - * @date 2026-06-05 for:【钉钉Stream回调】隔离SDK类避免LiteFlow启动期加载失败 - */ -@Slf4j -public final class DingTalkStreamSdkRunner { - - /** 统一日志前缀,便于 grep:钉钉回调 */ - public static final String LOG_TAG = "[钉钉回调]"; - - private DingTalkStreamSdkRunner() { - } - - //update-begin---author:GHT ---date:2026-06-05 for:【钉钉Stream回调】将SDK启动逻辑从Spring Bean中剥离----- - /** - * 建立钉钉 Stream 长连接并开始接收事件。 - * - * @param appKey 钉钉 AppKey - * @param appSecret 钉钉 AppSecret - * @param processor 审批事件处理器 - */ - public static void start(String appKey, String appSecret, DingBpmsEventProcessor processor) throws Exception { - log.info("{} Stream 正在建连 AppKey={}", LOG_TAG, appKey); - - OpenDingTalkStreamClientBuilder - .custom() - .credential(new AuthClientCredential(appKey, appSecret)) - .registerAllEventListener(new GenericEventListener() { - @Override - public EventAckStatus onEvent(GenericOpenDingTalkEvent event) { - String eventType = event != null ? event.getEventType() : null; - String eventId = event != null ? event.getEventId() : null; - Long bornTime = event != null ? event.getEventBornTime() : null; - long startMs = System.currentTimeMillis(); - try { - com.alibaba.fastjson2.JSONObject data = event != null ? toJsonObject(event.getData()) : null; - //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉Stream入站全量日志----------- - log.info("{} Stream入站 eventId={} eventType={} bornTime={} data={}", - LOG_TAG, eventId, eventType, bornTime, - data != null ? data.toJSONString() : "null"); - //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉Stream入站全量日志----------- - - if (!"bpms_instance_change".equals(eventType) && !"bpms_task_change".equals(eventType)) { - log.info("{} 非审批BPMS事件,已忽略 eventType={}", LOG_TAG, eventType); - return EventAckStatus.SUCCESS; - } - - if (data == null) { - log.warn("{} 事件 data 为空,无法处理 eventType={} eventId={}", LOG_TAG, eventType, eventId); - return EventAckStatus.SUCCESS; - } - - String instanceId = data.getString("processInstanceId"); - log.info("{} 开始处理 eventType={} instanceId={}", LOG_TAG, eventType, instanceId); - - if ("bpms_instance_change".equals(eventType)) { - processor.onInstanceChange(data); - } else { - processor.onTaskChange(data); - } - - log.info("{} 处理完成 eventType={} instanceId={} costMs={}", - LOG_TAG, eventType, instanceId, System.currentTimeMillis() - startMs); - return EventAckStatus.SUCCESS; - - } catch (Exception e) { - log.error("{} 事件处理异常 eventId={} eventType={} costMs={}: {}", - LOG_TAG, eventId, eventType, System.currentTimeMillis() - startMs, - e.getMessage(), e); - return EventAckStatus.LATER; - } - } - }) - .build() - .start(); - - log.info("{} Stream 客户端已启动,等待审批事件推送", LOG_TAG); - } - //update-end---author:GHT ---date:2026-06-05 for:【钉钉Stream回调】将SDK启动逻辑从Spring Bean中剥离----- - - private static com.alibaba.fastjson2.JSONObject toJsonObject(Object raw) { - if (raw == null) { - return null; - } - if (raw instanceof com.alibaba.fastjson2.JSONObject) { - return (com.alibaba.fastjson2.JSONObject) raw; - } - try { - return com.alibaba.fastjson2.JSONObject.parseObject(String.valueOf(raw)); - } catch (Exception e) { - return null; - } - } -} +package org.jeecg.modules.xslmes.dingtalk.stream; + +import com.dingtalk.open.app.api.GenericEventListener; +import com.dingtalk.open.app.api.KeepAliveOption; +import com.dingtalk.open.app.api.OpenDingTalkClient; +import com.dingtalk.open.app.api.OpenDingTalkStreamClientBuilder; +import com.dingtalk.open.app.api.message.GenericOpenDingTalkEvent; +import com.dingtalk.open.app.api.security.AuthClientCredential; +import com.dingtalk.open.app.stream.protocol.event.EventAckStatus; +import lombok.extern.slf4j.Slf4j; + +/** + * 钉钉 Stream SDK 启动器(非 Spring Bean)。 + *

+ * 与 {@link DingTalkStreamClient} 分离:LiteFlow 在上下文初始化早期会扫描所有 {@code @Component} + * 并调用 {@code getDeclaredMethods()},若 Bean 类字节码直接引用钉钉 SDK 类型,会提前加载 + * {@code DingTalkCredential} 等类;本类不参与 Spring 扫描,仅在后台线程中按需加载。 + * + * @author GHT + * @date 2026-06-05 for:【钉钉Stream回调】隔离SDK类避免LiteFlow启动期加载失败 + */ +@Slf4j +public final class DingTalkStreamSdkRunner { + + /** 统一日志前缀,便于 grep:钉钉回调 */ + public static final String LOG_TAG = "[钉钉回调]"; + + private static volatile OpenDingTalkClient activeClient; + private static volatile boolean streamRunning; + private static volatile long connectedAtMs; + private static volatile long lastEventAtMs; + private static volatile int totalEventCount; + private static volatile int reconnectCount; + + private DingTalkStreamSdkRunner() { + } + + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream监控】连接状态快照供存活日志使用----------- + public static ConnectionSnapshot snapshot() { + return new ConnectionSnapshot(streamRunning, connectedAtMs, lastEventAtMs, totalEventCount, reconnectCount); + } + + public static final class ConnectionSnapshot { + private final boolean streamRunning; + private final long connectedAtMs; + private final long lastEventAtMs; + private final int totalEventCount; + private final int reconnectCount; + + private ConnectionSnapshot(boolean streamRunning, long connectedAtMs, long lastEventAtMs, + int totalEventCount, int reconnectCount) { + this.streamRunning = streamRunning; + this.connectedAtMs = connectedAtMs; + this.lastEventAtMs = lastEventAtMs; + this.totalEventCount = totalEventCount; + this.reconnectCount = reconnectCount; + } + + public boolean streamRunning() { + return streamRunning; + } + + public long connectedAtMs() { + return connectedAtMs; + } + + public long lastEventAtMs() { + return lastEventAtMs; + } + + public int totalEventCount() { + return totalEventCount; + } + + public int reconnectCount() { + return reconnectCount; + } + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream监控】连接状态快照供存活日志使用----------- + + //update-begin---author:GHT ---date:2026-06-05 for:【钉钉Stream回调】将SDK启动逻辑从Spring Bean中剥离----- + /** + * 建立钉钉 Stream 长连接并开始接收事件。 + * + * @param appKey 钉钉 AppKey + * @param appSecret 钉钉 AppSecret + * @param processor 审批事件处理器 + * @param logHelper 回调日志落库辅助(可为 null,仅不写库) + * @return SDK 客户端实例,供集群 Leader 切换时主动 stop + */ + public static OpenDingTalkClient start(String appKey, String appSecret, DingBpmsEventProcessor processor, + DingStreamCallbackLogHelper logHelper) throws Exception { + stop(activeClient); + + boolean isReconnect = reconnectCount > 0 || connectedAtMs > 0; + if (isReconnect) { + reconnectCount++; + log.info("{} Stream 正在重连 AppKey={} reconnectCount={}", LOG_TAG, appKey, reconnectCount); + } else { + log.info("{} Stream 正在建连 AppKey={}", LOG_TAG, appKey); + } + + OpenDingTalkClient client = OpenDingTalkStreamClientBuilder + .custom() + .credential(new AuthClientCredential(appKey, appSecret)) + // SDK 内部 WebSocket 心跳,默认 60s 空闲探测 + .keepAlive(KeepAliveOption.create().withKeepAliveIdleMill(60_000L)) + .registerAllEventListener(new GenericEventListener() { + @Override + public EventAckStatus onEvent(GenericOpenDingTalkEvent event) { + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream】推送监听日志----------- + lastEventAtMs = System.currentTimeMillis(); + totalEventCount++; + log.info("{} 钉钉Stream推送监听 event={}", LOG_TAG, event); + //update-end---author:GHT ---date:20260609 for:【钉钉Stream】推送监听日志----------- + String eventType = event != null ? event.getEventType() : null; + String eventId = event != null ? event.getEventId() : null; + Long bornTime = event != null ? event.getEventBornTime() : null; + long startMs = System.currentTimeMillis(); + try { + com.alibaba.fastjson2.JSONObject data = event != null ? toJsonObject(event.getData()) : null; + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉Stream入站全量日志----------- + log.info("{} Stream入站 eventId={} eventType={} bornTime={} data={}", + LOG_TAG, eventId, eventType, bornTime, + data != null ? data.toJSONString() : "null"); + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉Stream入站全量日志----------- + + //update-begin---author:GHT ---date:20260609 for:【钉钉回调日志】Stream入站全量落库----------- + String logId = logHelper != null ? logHelper.recordInbound(eventId, eventType, data) : null; + //update-end---author:GHT ---date:20260609 for:【钉钉回调日志】Stream入站全量落库----------- + + if (!"bpms_instance_change".equals(eventType) && !"bpms_task_change".equals(eventType)) { + log.info("{} 非审批BPMS事件,已忽略 eventType={}", LOG_TAG, eventType); + if (logHelper != null) { + logHelper.beginProcessing(logId); + logHelper.markSkipped("非审批BPMS事件,已忽略"); + logHelper.finishProcessing(); + } + return EventAckStatus.SUCCESS; + } + + if (data == null) { + log.warn("{} 事件 data 为空,无法处理 eventType={} eventId={}", LOG_TAG, eventType, eventId); + if (logHelper != null) { + logHelper.beginProcessing(logId); + logHelper.markSkipped("事件 data 为空,无法处理"); + logHelper.finishProcessing(); + } + return EventAckStatus.SUCCESS; + } + + String instanceId = data.getString("processInstanceId"); + log.info("{} 开始处理 eventType={} instanceId={}", LOG_TAG, eventType, instanceId); + + boolean processFailed = false; + String processFailMsg = null; + if (logHelper != null) { + logHelper.beginProcessing(logId); + } + try { + if ("bpms_instance_change".equals(eventType)) { + processor.onInstanceChange(data); + } else { + processor.onTaskChange(data); + } + } catch (Exception processEx) { + processFailed = true; + processFailMsg = processEx.getMessage(); + throw processEx; + } finally { + if (logHelper != null) { + if (processFailed) { + logHelper.markError("处理异常: " + processFailMsg); + } else { + logHelper.finishProcessing(); + } + } + } + + log.info("{} 处理完成 eventType={} instanceId={} costMs={}", + LOG_TAG, eventType, instanceId, System.currentTimeMillis() - startMs); + return EventAckStatus.SUCCESS; + + } catch (Exception e) { + log.error("{} 事件处理异常 eventId={} eventType={} costMs={}: {}", + LOG_TAG, eventId, eventType, System.currentTimeMillis() - startMs, + e.getMessage(), e); + return EventAckStatus.LATER; + } + } + }) + .build(); + + client.start(); + activeClient = client; + streamRunning = true; + connectedAtMs = System.currentTimeMillis(); + + if (isReconnect) { + log.info("{} Stream 重连成功,等待审批事件推送 reconnectCount={}", LOG_TAG, reconnectCount); + } else { + log.info("{} Stream 客户端已启动,等待审批事件推送", LOG_TAG); + } + return client; + } + + /** + * 停止 Stream 长连接(Leader 释放或应用关闭时调用)。 + */ + public static void stop(OpenDingTalkClient client) { + if (client == null) { + return; + } + try { + client.stop(); + log.info("{} Stream 连接已断开", LOG_TAG); + } catch (Exception e) { + log.warn("{} Stream 断开异常: {}", LOG_TAG, e.getMessage(), e); + } finally { + if (client == activeClient) { + activeClient = null; + } + streamRunning = false; + } + } + //update-end---author:GHT ---date:2026-06-05 for:【钉钉Stream回调】将SDK启动逻辑从Spring Bean中剥离----- + + private static com.alibaba.fastjson2.JSONObject toJsonObject(Object raw) { + if (raw == null) { + return null; + } + if (raw instanceof com.alibaba.fastjson2.JSONObject) { + return (com.alibaba.fastjson2.JSONObject) raw; + } + try { + return com.alibaba.fastjson2.JSONObject.parseObject(String.valueOf(raw)); + } catch (Exception e) { + return null; + } + } +} + \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml index 0cde415e..6eafae91 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml @@ -211,6 +211,15 @@ mybatis-plus: minidao: base-package: org.jeecg.modules.jmreport.*,org.jeecg.modules.drag.* jeecg: + xslmes: + dingtalk: + stream: + # 多实例部署务必 true:Redis 选主,仅 Leader 建 Stream 长连接 + cluster-mode: true + leader-renew-interval-ms: 10000 + follower-retry-interval-ms: 15000 + health-log-interval-ms: 60000 + idle-warn-seconds: 1800 # 自定义资源请求前缀(js、css等解决nginx转发问题) custom-resource-prefix-path: # AI集成 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_142__mes_xsl_registry_list_api_path.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_142__mes_xsl_registry_list_api_path.sql new file mode 100644 index 00000000..9355fb48 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_142__mes_xsl_registry_list_api_path.sql @@ -0,0 +1,5 @@ +-- 审批注册中心:增加列表接口路径字段,用于 ResponseBodyAdvice 自动注入审批痕迹字段 +ALTER TABLE mes_xsl_biz_doc_registry + ADD COLUMN list_api_path VARCHAR(500) DEFAULT NULL + COMMENT '列表接口路径(多个逗号分隔,如/xslmes/mesFormulaSpec/list),配置后自动注入审批痕迹字段到响应' + AFTER approve_time_field; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_143__mes_xsl_registry_drop_by_time_fields.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_143__mes_xsl_registry_drop_by_time_fields.sql new file mode 100644 index 00000000..299c7f0e --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_143__mes_xsl_registry_drop_by_time_fields.sql @@ -0,0 +1,28 @@ +-- 审批注册中心:移除操作人/时间字段配置列 +-- 操作人/时间统一由 mes_xsl_approval_trace 痕迹表承载,业务表只保留状态字段 +-- author: GHT date: 2026-06-09 for:【审批注册中心】业务表只写状态,操作人/时间由痕迹表承载 +SET @db = DATABASE(); + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='mes_xsl_biz_doc_registry' AND COLUMN_NAME='proofread_by_field')>0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` DROP COLUMN `proofread_by_field`','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='mes_xsl_biz_doc_registry' AND COLUMN_NAME='proofread_time_field')>0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` DROP COLUMN `proofread_time_field`','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='mes_xsl_biz_doc_registry' AND COLUMN_NAME='audit_by_field')>0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` DROP COLUMN `audit_by_field`','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='mes_xsl_biz_doc_registry' AND COLUMN_NAME='audit_time_field')>0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` DROP COLUMN `audit_time_field`','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='mes_xsl_biz_doc_registry' AND COLUMN_NAME='approve_by_field')>0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` DROP COLUMN `approve_by_field`','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME='mes_xsl_biz_doc_registry' AND COLUMN_NAME='approve_time_field')>0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` DROP COLUMN `approve_time_field`','SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_144__mes_xsl_ding_callback_log.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_144__mes_xsl_ding_callback_log.sql new file mode 100644 index 00000000..bf74aaa5 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_144__mes_xsl_ding_callback_log.sql @@ -0,0 +1,89 @@ +-- ============================================= +-- 钉钉回调日志表 mes_xsl_ding_callback_log +-- ============================================= + +CREATE TABLE `mes_xsl_ding_callback_log` ( + `id` varchar(36) NOT NULL COMMENT '主键', + `event_id` varchar(100) DEFAULT NULL COMMENT '钉钉事件ID', + `event_type` varchar(50) DEFAULT NULL COMMENT '事件类型(bpms_instance_change/bpms_task_change)', + `process_instance_id` varchar(100) DEFAULT NULL COMMENT '审批实例ID', + `raw_data` text DEFAULT NULL COMMENT '原始推送数据JSON', + `received_time` datetime DEFAULT NULL COMMENT '接收时间', + `processed` tinyint(1) DEFAULT 0 COMMENT '是否已处理集成方案(0否1是)', + `process_remark` varchar(500) DEFAULT NULL COMMENT '处理备注', + `biz_table` varchar(100) DEFAULT NULL COMMENT '关联业务表', + `biz_data_id` varchar(100) DEFAULT NULL COMMENT '关联业务记录ID', + `record_id` varchar(100) DEFAULT NULL COMMENT '关联审批台账ID', + `create_by` varchar(50) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建日期', + `update_by` varchar(50) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新日期', + `del_flag` tinyint(1) DEFAULT 0 COMMENT '逻辑删除 0正常 1删除', + `tenant_id` int DEFAULT 0 COMMENT '租户ID', + `sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门', + PRIMARY KEY (`id`), + KEY `idx_ding_cb_log_instance` (`process_instance_id`), + KEY `idx_ding_cb_log_event` (`event_id`), + KEY `idx_ding_cb_log_processed` (`processed`), + KEY `idx_ding_cb_log_received` (`received_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='钉钉回调日志'; + +-- ============================================= +-- 菜单权限(父节点:178046026420801 MESToDing审批配置) +-- ============================================= + +INSERT INTO `sys_permission` + (id, parent_id, name, url, component, component_name, redirect, menu_type, + perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, + hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, + update_by, update_time, internal_or_external) +VALUES + -- 主菜单页面 + ('178098992722901', '178046026420801', '钉钉回调日志', '/xslmes/mesXslDingCallbackLogList', + 'xslmes/dingCallbackLog/MesXslDingCallbackLogList', 'MesXslDingCallbackLogList', + NULL, 0, NULL, '1', 6, 0, NULL, 1, 0, 0, 0, 0, NULL, '1', 0, 0, + 'admin', NOW(), NULL, NULL, 0), + + -- 添加按钮 + ('178098992722902', '178098992722901', '添加', NULL, NULL, NULL, NULL, 2, + 'xslmes:mes_xsl_ding_callback_log:add', '1', 1, 0, NULL, 1, 1, 0, 0, 0, + NULL, '1', 0, 0, 'admin', NOW(), NULL, NULL, 0), + + -- 编辑按钮 + ('178098992722903', '178098992722901', '编辑', NULL, NULL, NULL, NULL, 2, + 'xslmes:mes_xsl_ding_callback_log:edit', '1', 2, 0, NULL, 1, 1, 0, 0, 0, + NULL, '1', 0, 0, 'admin', NOW(), NULL, NULL, 0), + + -- 删除按钮 + ('178098992722904', '178098992722901', '删除', NULL, NULL, NULL, NULL, 2, + 'xslmes:mes_xsl_ding_callback_log:delete', '1', 3, 0, NULL, 1, 1, 0, 0, 0, + NULL, '1', 0, 0, 'admin', NOW(), NULL, NULL, 0), + + -- 批量删除按钮 + ('178098992722905', '178098992722901', '批量删除', NULL, NULL, NULL, NULL, 2, + 'xslmes:mes_xsl_ding_callback_log:deleteBatch', '1', 4, 0, NULL, 1, 1, 0, 0, 0, + NULL, '1', 0, 0, 'admin', NOW(), NULL, NULL, 0), + + -- 导出按钮 + ('178098992722906', '178098992722901', '导出', NULL, NULL, NULL, NULL, 2, + 'xslmes:mes_xsl_ding_callback_log:exportXls', '1', 5, 0, NULL, 1, 1, 0, 0, 0, + NULL, '1', 0, 0, 'admin', NOW(), NULL, NULL, 0), + + -- 导入按钮 + ('178098992722907', '178098992722901', '导入', NULL, NULL, NULL, NULL, 2, + 'xslmes:mes_xsl_ding_callback_log:importExcel', '1', 6, 0, NULL, 1, 1, 0, 0, 0, + NULL, '1', 0, 0, 'admin', NOW(), NULL, NULL, 0); + +-- ============================================= +-- 为 admin 角色授权(角色ID: f6817f48af4fb3af11b9e8bf182f618b) +-- ============================================= + +INSERT INTO `sys_role_permission` (id, role_id, permission_id, data_rule_ids) +VALUES + (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178098992722901', NULL), + (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178098992722902', NULL), + (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178098992722903', NULL), + (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178098992722904', NULL), + (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178098992722905', NULL), + (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178098992722906', NULL), + (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178098992722907', NULL); diff --git a/jeecgboot-vue3/src/hooks/system/useListPage.ts b/jeecgboot-vue3/src/hooks/system/useListPage.ts index fcdc382f..52e6db10 100644 --- a/jeecgboot-vue3/src/hooks/system/useListPage.ts +++ b/jeecgboot-vue3/src/hooks/system/useListPage.ts @@ -11,6 +11,9 @@ import { useDesign } from '/@/hooks/web/useDesign'; import { filterObj } from '/@/utils/common/compUtils'; import { isFunction } from '@/utils/is'; import { registerImPageListProvider } from '/@/views/system/im/imPageListRegistry'; +//update-begin---author:GHT ---date:20260609 for:【审批注册中心】列表页静态注入审批痕迹列(默认隐藏)----------- +import { traceColumns } from '/@/views/xslmes/approval/integration/traceColumns'; +//update-end---author:GHT ---date:20260609 for:【审批注册中心】列表页静态注入审批痕迹列(默认隐藏)----------- //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】列表选中行同步到审批发起上下文----- import { useApprovalSelection } from '/@/components/ApprovalLaunch/useApprovalSelection'; //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】列表选中行同步到审批发起上下文----- @@ -573,6 +576,14 @@ export function useListTable(tableProps: TableProps): [ } } + //update-begin---author:GHT ---date:20260609 for:【审批注册中心】静态追加审批痕迹列(默认隐藏),无需注册中心判断----------- + const existingCols: any[] = (defaultTableProps.columns as any[]) ?? []; + const hasTrace = existingCols.some((c) => String(c.dataIndex ?? '').startsWith('trace')); + if (!hasTrace) { + defaultTableProps.columns = [...existingCols, ...traceColumns]; + } + //update-end---author:GHT ---date:20260609 for:【审批注册中心】静态追加审批痕迹列(默认隐藏),无需注册中心判断----------- + return [ ...useTable(defaultTableProps), { diff --git a/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue b/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue index e3238df8..cd061786 100644 --- a/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue +++ b/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue @@ -383,6 +383,10 @@ }; // 清空历史 HTTP 回调配置,避免与集成方案双写 form.value.props.callbackActions = { onNodeApprove: [], onApprove: [], onReject: [] }; + // update-begin---author:GHT ---date:2026-06-09 for:【审批流设计】打开节点配置时强制刷新集成方案下拉,避免先开设计器后生成方案导致缓存空值----- + integrationPlansCacheKey.value = ''; + loadIntegrationPlans(); + // update-end---author:GHT ---date:2026-06-09 for:【审批流设计】打开节点配置时强制刷新集成方案下拉,避免先开设计器后生成方案导致缓存空值----- } open.value = true; } diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts index 5a46c370..c7f7243f 100644 --- a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts @@ -2,13 +2,6 @@ import { BasicColumn, FormSchema } from '/@/components/Table'; const STAGE_DICT = 'mes_xsl_approval_stage'; -function hasStage(values: Recordable, stage: string) { - const raw = values?.enabledStages; - if (!raw) return false; - if (Array.isArray(raw)) return raw.includes(stage); - return String(raw).split(',').includes(stage); -} - export const columns: BasicColumn[] = [ { title: '业务编码', dataIndex: 'docCode', width: 140, align: 'left' }, { title: '物理表名', dataIndex: 'tableName', width: 200, align: 'left' }, @@ -84,46 +77,11 @@ export const formSchema: FormSchema[] = [ componentProps: { placeholder: '默认 status' }, }, { - label: '校对人字段', - field: 'proofreadByField', + label: '列表接口路径', + field: 'listApiPath', component: 'Input', - defaultValue: 'proofread_by', - ifShow: ({ values }) => hasStage(values, 'proofread'), - }, - { - label: '校对时间字段', - field: 'proofreadTimeField', - component: 'Input', - defaultValue: 'proofread_time', - ifShow: ({ values }) => hasStage(values, 'proofread'), - }, - { - label: '审核人字段', - field: 'auditByField', - component: 'Input', - defaultValue: 'audit_by', - ifShow: ({ values }) => hasStage(values, 'audit'), - }, - { - label: '审核时间字段', - field: 'auditTimeField', - component: 'Input', - defaultValue: 'audit_time', - ifShow: ({ values }) => hasStage(values, 'audit'), - }, - { - label: '批准人字段', - field: 'approveByField', - component: 'Input', - defaultValue: 'approve_by', - ifShow: ({ values }) => hasStage(values, 'approve'), - }, - { - label: '批准时间字段', - field: 'approveTimeField', - component: 'Input', - defaultValue: 'approve_time', - ifShow: ({ values }) => hasStage(values, 'approve'), + slot: 'listApiPath', + helpMessage: '从二级菜单选取后自动填入路径;配置后列表响应自动追加 traceProofreadBy / traceAuditBy / traceApproveBy 等6个字段', }, { label: '备注', diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue index 39beea7b..030660ad 100644 --- a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue @@ -122,6 +122,7 @@ { title: '流程节点', dataIndex: 'nodeName', width: 200 }, { title: '识别环节', dataIndex: 'stage', width: 130 }, { title: '前置状态', dataIndex: 'expectedFromLabel', width: 90 }, + { title: '通过后状态', dataIndex: 'statusAfterLabel', width: 100 }, { title: '生成方案', dataIndex: 'willGenerate', width: 88 }, { title: '未配置原因', dataIndex: 'unconfiguredReason', ellipsis: true }, ]; @@ -182,6 +183,12 @@ return '环节未完整配置'; } + function resolveStatusAfter(stage?: string, statusChain?: any[]) { + if (!stage) return null; + const hit = (statusChain || []).find((item) => item.value === stage); + return hit ? stage : null; + } + function resolveExpectedFrom(bindings: any[], index: number, statusChain: any[], initialStatus: string) { const current = bindings[index]; if (!current?.stage) { @@ -226,6 +233,8 @@ record.triggerPhase = null; record.expectedFrom = null; record.expectedFromLabel = '-'; + record.statusAfter = null; + record.statusAfterLabel = '-'; return; } const cfgIdx = configuredBindings.indexOf(record); @@ -234,6 +243,9 @@ const expectedFrom = resolveExpectedFrom(bindings, bindings.indexOf(record), statusChain, initialStatus); record.expectedFrom = expectedFrom; record.expectedFromLabel = labelOfStatusChain(statusChain, expectedFrom); + const statusAfter = resolveStatusAfter(record.stage, statusChain); + record.statusAfter = statusAfter; + record.statusAfterLabel = statusAfter ? labelOfStatusChain(statusChain, statusAfter) : '需手配'; }); preview.value.configuredNodeCount = configuredBindings.length; diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue index d833bc3f..446fe3b9 100644 --- a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue @@ -1,6 +1,10 @@ @@ -10,6 +14,7 @@ import { BasicForm, useForm } from '/@/components/Form/index'; import { formSchema } from '../MesXslBizDocRegistry.data'; import { saveOrUpdate } from '../MesXslBizDocRegistry.api'; + import RegistryMenuSelect from './RegistryMenuSelect.vue'; const emit = defineEmits(['register', 'success']); const isUpdate = ref(false); diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue index 0d96dc8a..b373ba42 100644 --- a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue @@ -96,10 +96,11 @@ if (record.actionType === 'REGISTRY_STAGE_SYNC') { const stage = cfg.registryStage?.stage || cfg.stage; const from = cfg.registryStage?.expectedFrom || cfg.expectedFrom; - return `环节→${STAGE_LABELS[stage] || stage || '?'}${from ? `,前置=${from}` : ''}`; + const after = cfg.registryStage?.statusAfter || cfg.statusAfter || stage; + return `环节→${STAGE_LABELS[stage] || stage || '?'}${from ? `,前置=${from}` : ''},通过后=${after}`; } - const target = cfg.registryStage?.targetStage || cfg.targetStage || 'compile'; - return `回退→${target}`; + const target = cfg.registryStage?.targetStage ?? cfg.targetStage; + return target !== undefined && target !== null && target !== '' ? `回退→${target}` : '回退→未配置'; } catch { return record.actionConfig || '-'; } diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/RegistryMenuSelect.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/RegistryMenuSelect.vue new file mode 100644 index 00000000..00a4ec7e --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/RegistryMenuSelect.vue @@ -0,0 +1,120 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue index 952f5fb8..406d2352 100644 --- a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue @@ -58,7 +58,7 @@ type="info" show-icon style="margin-bottom: 14px" - message="按审批注册中心配置自动更新源单状态、操作人/时间,并双写审批痕迹明细,无需绑定 Java 校对/审核/批准接口。" + message="审批环节仅用于匹配审批流与写入痕迹;业务表 status 由「通过后状态」控制。操作人/时间写入痕迹表,无需绑定 Java 接口。" /> @@ -305,6 +329,7 @@ interface RegistryStageConfig { stage?: string; expectedFrom?: string; + statusAfter?: string; targetStage?: string; } @@ -321,10 +346,18 @@ const emit = defineEmits<{ success: [action: any] }>(); const { createMessage } = useMessage(); + /** 审批环节码固定中文名(与业务 status 字典无关) */ + const APPROVAL_STAGE_LABELS: Record = { + proofread: '校对', + audit: '审核', + approve: '批准', + }; + /** 触发表 status 字段未带字典注释时的兜底映射 */ const SOURCE_TABLE_STATUS_DICT: Record = { mes_xsl_mixer_ps_compile: 'xslmes_mixer_ps_status', mes_xsl_formula_spec: 'xslmes_formula_spec_status', + mes_xsl_raw_material_entry: 'xslmes_entry_status', }; /** 目标表 status 字段未带字典注释时的兜底映射 */ @@ -362,7 +395,7 @@ .split(',') .map((s) => s.trim()) .filter(Boolean) - .map((v) => ({ value: v, label: dictLabelMap[v] || v })); + .map((v) => ({ value: v, label: APPROVAL_STAGE_LABELS[v] || dictLabelMap[v] || v })); }); const targetColumns = ref([]); @@ -387,6 +420,7 @@ const defaultRegistryStage = (): RegistryStageConfig => ({ stage: '', expectedFrom: '', + statusAfter: '', targetStage: '', }); @@ -428,7 +462,12 @@ if (parsed.expectedFrom !== undefined && parsed.expectedFrom !== null) { merged.registryStage!.expectedFrom = parsed.expectedFrom; } - if (parsed.targetStage) merged.registryStage!.targetStage = parsed.targetStage; + if (parsed.statusAfter !== undefined && parsed.statusAfter !== null) { + merged.registryStage!.statusAfter = parsed.statusAfter; + } + if (parsed.targetStage !== undefined && parsed.targetStage !== null) { + merged.registryStage!.targetStage = parsed.targetStage; + } return merged; } @@ -461,7 +500,7 @@ }, ); - // 审批环节变化时,前置状态留空则填入默认推断值 + // 审批环节变化时,前置/通过后状态留空则填入默认推断值 watch( () => vc.value.registryStage?.stage, (stage) => { @@ -469,6 +508,9 @@ if (!vc.value.registryStage.expectedFrom) { vc.value.registryStage.expectedFrom = defaultExpectedFromForStage(stage); } + if (!vc.value.registryStage.statusAfter) { + vc.value.registryStage.statusAfter = defaultStatusAfterForStage(stage); + } }, ); @@ -548,6 +590,16 @@ return items.length ? items[0].value : ''; } + /** 推断通过后业务状态:字典含环节码时用环节码,否则不自动填充(需用户手选) */ + function defaultStatusAfterForStage(stage?: string): string { + if (!stage) return ''; + const items = sourceStatusDictItems.value; + if (items.some((i) => i.value === stage)) { + return stage; + } + return ''; + } + function defaultRevertTargetStage(): string { const items = sourceStatusDictItems.value; if (!items.length) return 'compile'; @@ -565,7 +617,12 @@ if (config.registryStage?.expectedFrom !== undefined && config.registryStage?.expectedFrom !== null) { payload.expectedFrom = config.registryStage.expectedFrom; } - if (config.registryStage?.targetStage) payload.targetStage = config.registryStage.targetStage; + if (config.registryStage?.statusAfter !== undefined && config.registryStage?.statusAfter !== null) { + payload.statusAfter = config.registryStage.statusAfter; + } + if (config.registryStage?.targetStage !== undefined && config.registryStage?.targetStage !== null) { + payload.targetStage = config.registryStage.targetStage; + } return JSON.stringify(payload); } @@ -607,6 +664,7 @@ if (type === 'REGISTRY_STAGE_SYNC' && registryStageOptions.value.length && !vc.value.registryStage?.stage) { vc.value.registryStage!.stage = registryStageOptions.value[0].value; vc.value.registryStage!.expectedFrom = defaultExpectedFromForStage(vc.value.registryStage!.stage); + vc.value.registryStage!.statusAfter = defaultStatusAfterForStage(vc.value.registryStage!.stage); } if (type === 'REGISTRY_STAGE_REVERT' && !vc.value.registryStage?.targetStage) { vc.value.registryStage!.targetStage = defaultRevertTargetStage(); @@ -687,6 +745,7 @@ if (registryStageOptions.value.length) { vc.value.registryStage!.stage = registryStageOptions.value[0].value; vc.value.registryStage!.expectedFrom = defaultExpectedFromForStage(vc.value.registryStage!.stage); + vc.value.registryStage!.statusAfter = defaultStatusAfterForStage(vc.value.registryStage!.stage); } vc.value.registryStage!.targetStage = defaultRevertTargetStage(); formRef.value?.clearValidate?.(); @@ -702,6 +761,10 @@ createMessage.warning('请选择审批环节'); return; } + if (!vc.value.registryStage?.statusAfter) { + createMessage.warning('请选择通过后状态'); + return; + } emit('success', { ...form.value, actionType: 'REGISTRY_STAGE_SYNC', @@ -712,6 +775,10 @@ return; } if (vc.value.visualType === 'REGISTRY_STAGE_REVERT') { + if (vc.value.registryStage?.targetStage === undefined || vc.value.registryStage?.targetStage === null || vc.value.registryStage?.targetStage === '') { + createMessage.warning('请选择回退目标(业务状态字典项)'); + return; + } emit('success', { ...form.value, actionType: 'REGISTRY_STAGE_REVERT', @@ -777,10 +844,19 @@ } await loadSourceStatusDict(); + // 旧数据兼容:未配置 statusAfter 时,若字典含环节码则回填,否则保持空由用户手选 + if (isUpdate.value && vc.value.registryStage?.stage && !vc.value.registryStage?.statusAfter) { + const legacy = defaultStatusAfterForStage(vc.value.registryStage.stage); + if (legacy) { + vc.value.registryStage.statusAfter = legacy; + } + } + if (!isUpdate.value) { if (registryStageOptions.value.length && !vc.value.registryStage?.stage) { vc.value.registryStage!.stage = registryStageOptions.value[0].value; vc.value.registryStage!.expectedFrom = defaultExpectedFromForStage(vc.value.registryStage!.stage); + vc.value.registryStage!.statusAfter = defaultStatusAfterForStage(vc.value.registryStage!.stage); } if (!vc.value.registryStage?.targetStage) { vc.value.registryStage!.targetStage = defaultRevertTargetStage(); diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/traceColumns.ts b/jeecgboot-vue3/src/views/xslmes/approval/integration/traceColumns.ts new file mode 100644 index 00000000..16c89f61 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/traceColumns.ts @@ -0,0 +1,24 @@ +import { BasicColumn } from '/@/components/Table'; + +/** + * 按 sentinel key 分组的审批痕迹列。 + * key = 后端注入的字段名(traceProofreadBy / traceAuditBy / traceApproveBy) + * 只要响应里出现该 key,就注入对应两列。 + */ +export const traceColumnsByStage: Record = { + traceProofreadBy: [ + { title: '校对人', dataIndex: 'traceProofreadBy', width: 100, align: 'center', defaultHidden: true }, + { title: '校对时间', dataIndex: 'traceProofreadTime', width: 165, align: 'center', defaultHidden: true }, + ], + traceAuditBy: [ + { title: '审核人', dataIndex: 'traceAuditBy', width: 100, align: 'center', defaultHidden: true }, + { title: '审核时间', dataIndex: 'traceAuditTime', width: 165, align: 'center', defaultHidden: true }, + ], + traceApproveBy: [ + { title: '批准人', dataIndex: 'traceApproveBy', width: 100, align: 'center', defaultHidden: true }, + { title: '批准时间', dataIndex: 'traceApproveTime', width: 165, align: 'center', defaultHidden: true }, + ], +}; + +/** 全量痕迹列(密炼PS等已知需要全部的场景直接引用) */ +export const traceColumns: BasicColumn[] = Object.values(traceColumnsByStage).flat(); diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/useTraceTable.ts b/jeecgboot-vue3/src/views/xslmes/approval/integration/useTraceTable.ts new file mode 100644 index 00000000..801a6a61 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/useTraceTable.ts @@ -0,0 +1,16 @@ +import { useTable } from '/@/components/Table'; +import type { BasicTableProps } from '/@/components/Table'; +import { traceColumns } from './traceColumns'; + +/** + * 替换 useTable(不经过 useListPage 的特殊场景):自动追加审批痕迹列(默认隐藏)。 + * 普通列表页已由 useListPage 统一注入,无需使用本函数。 + */ +export function useTraceTable(tableProps: BasicTableProps) { + const columns = tableProps.columns as any[] | undefined; + const alreadyHasTrace = columns?.some((c) => c.dataIndex === 'traceProofreadBy'); + return useTable({ + ...tableProps, + columns: alreadyHasTrace ? columns : [...(columns ?? []), ...traceColumns], + }); +} diff --git a/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLog.api.ts b/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLog.api.ts new file mode 100644 index 00000000..49798cf8 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLog.api.ts @@ -0,0 +1,64 @@ +import { defHttp } from '/@/utils/http/axios'; +import { useMessage } from '/@/hooks/web/useMessage'; + +const { createConfirm } = useMessage(); + +enum Api { + list = '/xslmes/mesXslDingCallbackLog/list', + save = '/xslmes/mesXslDingCallbackLog/add', + edit = '/xslmes/mesXslDingCallbackLog/edit', + deleteOne = '/xslmes/mesXslDingCallbackLog/delete', + deleteBatch = '/xslmes/mesXslDingCallbackLog/deleteBatch', + exportXlsUrl = '/xslmes/mesXslDingCallbackLog/exportXls', + importExcelUrl = '/xslmes/mesXslDingCallbackLog/importExcel', +} + +/** + * 列表分页查询 + */ +export const list = (params) => defHttp.get({ url: Api.list, params }); + +/** + * 删除单条 + */ +export const deleteOne = (params, handleSuccess) => { + return defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => { + handleSuccess(); + }); +}; + +/** + * 批量删除 + */ +export const batchDelete = (params, handleSuccess) => { + createConfirm({ + iconType: 'warning', + title: '确认删除', + content: '是否删除选中数据', + okText: '确认', + cancelText: '取消', + onOk: () => { + return defHttp.delete({ url: Api.deleteBatch, data: params }, { joinParamsToUrl: true }).then(() => { + handleSuccess(); + }); + }, + }); +}; + +/** + * 保存或新增 + */ +export const saveOrUpdate = (params, isUpdate) => { + const url = isUpdate ? Api.edit : Api.save; + return defHttp.post({ url, params }); +}; + +/** + * 导出 XLS + */ +export const getExportUrl = Api.exportXlsUrl; + +/** + * 导入 XLS + */ +export const getImportUrl = Api.importExcelUrl; diff --git a/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLog.data.ts b/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLog.data.ts new file mode 100644 index 00000000..eb411904 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLog.data.ts @@ -0,0 +1,158 @@ +import { BasicColumn } from '/@/components/Table'; +import { FormSchema } from '/@/components/Form'; +import { rules } from '/@/utils/helper/validator'; + +export const columns: BasicColumn[] = [ + { + title: '钉钉事件ID', + align: 'center', + dataIndex: 'eventId', + width: 180, + }, + { + title: '事件类型', + align: 'center', + dataIndex: 'eventType', + width: 160, + }, + { + title: '审批实例ID', + align: 'center', + dataIndex: 'processInstanceId', + width: 200, + }, + { + title: '接收时间', + align: 'center', + dataIndex: 'receivedTime', + width: 160, + }, + { + title: '是否已处理', + align: 'center', + dataIndex: 'processed_dictText', + width: 100, + }, + { + title: '关联业务表', + align: 'center', + dataIndex: 'bizTable', + width: 140, + }, + { + title: '关联业务记录ID', + align: 'center', + dataIndex: 'bizDataId', + width: 160, + }, + { + title: '关联审批台账ID', + align: 'center', + dataIndex: 'recordId', + width: 160, + }, +]; + +export const searchFormSchema: FormSchema[] = [ + { + label: '事件类型', + field: 'eventType', + component: 'Input', + colProps: { span: 6 }, + }, + { + label: '审批实例ID', + field: 'processInstanceId', + component: 'Input', + colProps: { span: 6 }, + }, + { + label: '是否已处理', + field: 'processed', + component: 'JDictSelectTag', + componentProps: { dictCode: 'yn' }, + colProps: { span: 6 }, + }, + { + label: '接收时间', + field: 'receivedTime', + component: 'RangePicker', + componentProps: { valueType: 'Date', showTime: true, format: 'YYYY-MM-DD HH:mm:ss' }, + colProps: { span: 12 }, + }, + { + label: '关联业务表', + field: 'bizTable', + component: 'Input', + colProps: { span: 6 }, + }, +]; + +export const formSchema: FormSchema[] = [ + { label: '', field: 'id', component: 'Input', show: false }, + { + label: '钉钉事件ID', + field: 'eventId', + component: 'Input', + colProps: { span: 12 }, + }, + { + label: '事件类型', + field: 'eventType', + component: 'Input', + componentProps: { placeholder: '如 bpms_instance_change' }, + colProps: { span: 12 }, + }, + { + label: '审批实例ID', + field: 'processInstanceId', + component: 'Input', + colProps: { span: 12 }, + }, + { + label: '接收时间', + field: 'receivedTime', + component: 'DatePicker', + componentProps: { showTime: true, format: 'YYYY-MM-DD HH:mm:ss', valueFormat: 'YYYY-MM-DD HH:mm:ss', style: { width: '100%' } }, + colProps: { span: 12 }, + }, + { + label: '是否已处理', + field: 'processed', + component: 'JDictSelectTag', + componentProps: { dictCode: 'yn', placeholder: '请选择' }, + colProps: { span: 12 }, + }, + { + label: '关联业务表', + field: 'bizTable', + component: 'Input', + colProps: { span: 12 }, + }, + { + label: '关联业务记录ID', + field: 'bizDataId', + component: 'Input', + colProps: { span: 12 }, + }, + { + label: '关联审批台账ID', + field: 'recordId', + component: 'Input', + colProps: { span: 12 }, + }, + { + label: '原始推送数据', + field: 'rawData', + component: 'InputTextArea', + componentProps: { rows: 6, placeholder: 'JSON 原始推送内容' }, + colProps: { span: 24 }, + }, + { + label: '处理备注', + field: 'processRemark', + component: 'InputTextArea', + componentProps: { rows: 3, placeholder: '处理结果或失败原因' }, + colProps: { span: 24 }, + }, +]; diff --git a/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLogList.vue b/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLogList.vue new file mode 100644 index 00000000..87d78355 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/MesXslDingCallbackLogList.vue @@ -0,0 +1,107 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/components/MesXslDingCallbackLogModal.vue b/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/components/MesXslDingCallbackLogModal.vue new file mode 100644 index 00000000..d40ee125 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingCallbackLog/components/MesXslDingCallbackLogModal.vue @@ -0,0 +1,52 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.data.ts b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.data.ts index 7b3e2b34..ac5e2585 100644 --- a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.data.ts +++ b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.data.ts @@ -19,7 +19,7 @@ const deptSelectProps = { }; const hasWorkflowInfo = ({ values }) => - !!(values.proofreadBy || values.proofreadTime || values.auditBy || values.auditTime || values.approveBy || values.approveTime); + !!(values.traceProofreadBy || values.traceProofreadTime || values.traceAuditBy || values.traceAuditTime || values.traceApproveBy || values.traceApproveTime); function sectionDivider(label: string, field: string, ifShow?: FormSchema['ifShow']): FormSchema { return { @@ -51,12 +51,6 @@ export const columns: BasicColumn[] = [ width: 100, customRender: ({ record }) => record?.createBy_dictText || record?.createBy || '', }, - { title: '校对人', align: 'center', dataIndex: 'proofreadBy', width: 100, defaultHidden: true }, - { title: '校对时间', align: 'center', dataIndex: 'proofreadTime', width: 165, defaultHidden: true }, - { title: '审核人', align: 'center', dataIndex: 'auditBy', width: 100, defaultHidden: true }, - { title: '审核时间', align: 'center', dataIndex: 'auditTime', width: 165, defaultHidden: true }, - { title: '批准人', align: 'center', dataIndex: 'approveBy', width: 100, defaultHidden: true }, - { title: '批准时间', align: 'center', dataIndex: 'approveTime', width: 165, defaultHidden: true }, { title: '所属工厂', align: 'center', dataIndex: 'factoryName', width: 120, defaultHidden: true }, { title: '施工代号', align: 'center', dataIndex: 'constructionCode_dictText', width: 110, defaultHidden: true }, { title: '创建人', align: 'center', dataIndex: 'createBy', width: 100, defaultHidden: true }, @@ -222,51 +216,51 @@ export const formSchema: FormSchema[] = [ sectionDivider('审批记录', 'dividerWorkflow', hasWorkflowInfo), { label: '校对人', - field: 'proofreadBy', + field: 'traceProofreadBy', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.proofreadBy, + ifShow: ({ values }) => !!values.traceProofreadBy, }, { label: '校对时间', - field: 'proofreadTime', + field: 'traceProofreadTime', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.proofreadTime, + ifShow: ({ values }) => !!values.traceProofreadTime, }, { label: '审核人', - field: 'auditBy', + field: 'traceAuditBy', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.auditBy, + ifShow: ({ values }) => !!values.traceAuditBy, }, { label: '审核时间', - field: 'auditTime', + field: 'traceAuditTime', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.auditTime, + ifShow: ({ values }) => !!values.traceAuditTime, }, { label: '批准人', - field: 'approveBy', + field: 'traceApproveBy', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.approveBy, + ifShow: ({ values }) => !!values.traceApproveBy, }, { label: '批准时间', - field: 'approveTime', + field: 'traceApproveTime', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.approveTime, + ifShow: ({ values }) => !!values.traceApproveTime, }, ];