新增钉钉 Stream SDK 依赖,支持无 HTTP 上下文的后台线程显式传入 token 进行审批回调。同时,完善了 MES 审批台账功能,新增审批记录同步、批量发起审批时的门禁与台账写入逻辑,增强了系统的审批流管理能力。

This commit is contained in:
geht
2026-06-05 10:44:30 +08:00
parent 4785c55e52
commit fc4e3211ad
73 changed files with 5225 additions and 240 deletions

View File

@@ -677,4 +677,34 @@ jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockReason/MesXslRubberSmallLoc
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/MesXslRubberSmallLockLog.data.ts
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/MesXslRubberSmallLockLog.api.ts
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/MesXslRubberSmallLockLogList.vue
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/components/MesXslRubberSmallLockLogModal.vue
-- author:GHT---date:20260604--for: 【QH-MES审批台账】跨 MES/钉钉 统一审批门禁台账 -----
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_125__mes_xsl_approval_record.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/constant/ApprovalRecordConstants.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalRecordMapper.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalRecordService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalRecordServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalGateService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalGateServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/vo/ApprovalGateVo.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalGateController.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java
jeecgboot-vue3/src/views/approval/gate/approvalGate.api.ts
jeecgboot-vue3/src/components/DingTplLaunch/DingBindLaunchModal.vue
jeecgboot-vue3/src/components/DingTplLaunch/index.vue
-- author:GHT---date:20260604--for: 【QH-MES审批台账】MESToDing审批配置下增加审批台账菜单与列表页 -----
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_126__mes_xsl_approval_record_menu.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalRecordController.java
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecord.api.ts
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecord.data.ts
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecordList.vue
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/components/MesXslApprovalRecordDetailModal.vue
-- author:GHT---date:20260604--for: 【钉钉Stream回调】Stream模式接收钉钉审批结果并回写MES审批台账 -----
jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java

View File

@@ -19,6 +19,12 @@
<artifactId>jeecg-boot-base-core</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- 钉钉 Stream SDK: 官方 Stream 模式事件订阅,无需公网回调地址 GHT 20260604 -->
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.12</version>
</dependency>
<!-- 复用打印模板模块打印机枚举、业务绑定、PDF 提交队列 -->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>

View File

@@ -75,6 +75,19 @@ public class ApprovalActionHttpExecutor {
if (node == null || inst == null) {
return;
}
String token = currentToken();
run(node, phase, inst.getBizDataId(), token);
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】钉钉后台线程无HTTP上下文支持显式传入token-----
/**
* 以显式 token 执行节点回调配置的接口(供无 HTTP 上下文的后台线程使用,如钉钉 Stream 回调)。
* 当 token 为空时降级跳过(与原有行为一致)。
*/
public void run(JSONObject node, String phase, String bizDataId, String token) {
if (node == null || oConvertUtils.isEmpty(bizDataId)) {
return;
}
JSONObject propsObj = node.getJSONObject("props");
if (propsObj == null) {
return;
@@ -87,7 +100,6 @@ public class ApprovalActionHttpExecutor {
if (actions == null || actions.isEmpty()) {
return;
}
String token = currentToken();
for (int i = 0; i < actions.size(); i++) {
JSONObject action = actions.getJSONObject(i);
if (action == null) {
@@ -98,14 +110,14 @@ public class ApprovalActionHttpExecutor {
continue;
}
if (oConvertUtils.isEmpty(token)) {
// 无登录态(自动流转/无人值守) -> 降级跳过
log.warn("[审批回调] 无当前处理人登录态,跳过接口调用 phase={}, url={}, bizId={}", phase, url, inst.getBizDataId());
log.warn("[审批回调] 无登录态,跳过接口调用 phase={}, url={}, bizId={}", phase, url, bizDataId);
continue;
}
String method = oConvertUtils.getString(action.getString("method"), "POST").toUpperCase();
invoke(method, url, action.getJSONObject("body"), inst.getBizDataId(), token);
invoke(method, url, action.getJSONObject("body"), bizDataId, token);
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】钉钉后台线程无HTTP上下文支持显式传入token-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回统一回退按表注解自动调用业务接口-----------
/**

View File

@@ -0,0 +1,34 @@
package org.jeecg.modules.xslmes.approval.constant;
/**
* 审批台账常量
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public final class ApprovalRecordConstants {
private ApprovalRecordConstants() {
}
/** MES 审批通道 */
public static final String CHANNEL_MES = "MES";
/** 钉钉审批通道 */
public static final String CHANNEL_DINGTALK = "DINGTALK";
/** 流转中 */
public static final String STATUS_RUNNING = "0";
/** 审批通过 */
public static final String STATUS_APPROVED = "1";
/** 审批拒绝 */
public static final String STATUS_REJECTED = "2";
/** 已撤销 */
public static final String STATUS_CANCELLED = "3";
/** 发起失败 */
public static final String STATUS_LAUNCH_FAILED = "4";
}

View File

@@ -0,0 +1,74 @@
package org.jeecg.modules.xslmes.approval.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 审批门禁 API
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Tag(name = "MES审批门禁")
@RestController
@RequestMapping("/xslmes/approvalGate")
@Slf4j
public class MesXslApprovalGateController {
@Autowired
private IMesXslApprovalGateService approvalGateService;
@Operation(summary = "检查是否允许发起审批")
@GetMapping("/canLaunch")
public Result<ApprovalGateVo> canLaunch(
@RequestParam("bizTable") String bizTable,
@RequestParam("bizDataId") String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return Result.error("bizTable 与 bizDataId 不能为空");
}
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
ApprovalGateVo vo = approvalGateService.checkCanLaunch(bizTable.trim(), bizDataId.trim(), tenantId);
return Result.OK(vo);
}
@Operation(summary = "批量检查是否允许发起审批")
@PostMapping("/canLaunchBatch")
public Result<List<ApprovalGateVo>> canLaunchBatch(@RequestBody CanLaunchBatchRequest req) {
if (req == null || oConvertUtils.isEmpty(req.getBizTable()) || req.getBizDataIds() == null || req.getBizDataIds().isEmpty()) {
return Result.error("bizTable 与 bizDataIds 不能为空");
}
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
List<ApprovalGateVo> list = approvalGateService.checkCanLaunchBatch(req.getBizTable().trim(), req.getBizDataIds(), tenantId);
return Result.OK(list);
}
@Operation(summary = "查询业务单据审批台账历史")
@GetMapping("/history")
public Result<List<MesXslApprovalRecord>> history(
@RequestParam("bizTable") String bizTable,
@RequestParam("bizDataId") String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return Result.error("bizTable 与 bizDataId 不能为空");
}
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
return Result.OK(approvalGateService.listHistory(bizTable.trim(), bizDataId.trim(), tenantId));
}
@Data
public static class CanLaunchBatchRequest {
private String bizTable;
private List<String> bizDataIds;
}
}

View File

@@ -1,6 +1,5 @@
package org.jeecg.modules.xslmes.approval.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -12,8 +11,10 @@ import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -52,6 +53,11 @@ public class MesXslApprovalLaunchController {
private IMesXslApprovalHandleService approvalHandleService;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起改用流转引擎进入首节点-----
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
@Autowired
private IMesXslApprovalGateService approvalGateService;
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
@Autowired
private JdbcTemplate jdbcTemplate;
@@ -166,22 +172,22 @@ public class MesXslApprovalLaunchController {
return Result.error("该审批流未发布,无法发起");
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】防止同一单据重复发起审批-----
long active = approvalInstanceService.count(new LambdaQueryWrapper<MesXslApprovalInstance>()
.eq(MesXslApprovalInstance::getBizTable, flow.getBizTable())
.eq(MesXslApprovalInstance::getBizDataId, bizDataId)
.eq(MesXslApprovalInstance::getStatus, "0"));
if (active > 0) {
return Result.error("该单据已有审批中的流程,请勿重复发起");
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】防止同一单据重复发起审批-----
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)-----
Integer tenantId = MesXslTenantUtils.resolveTenantId(flow.getTenantId());
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
try {
approvalGateService.assertCanLaunch(flow.getBizTable(), bizDataId, tenantId);
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
approvalInstanceService.save(inst);
approvalGateService.createRunningRecord(
approvalGateService.buildMesDraft(flow.getBizTable(), flow.getBizTableName(), bizDataId, bizTitle,
flow.getId(), flow.getFlowName(), inst.getId(), loginUser, tenantId));
approvalHandleService.enterFirstNode(inst, loginUser);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)-----
syncApprovalRecordAfterLaunch(inst.getId());
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
return Result.OK("发起成功!");
}
@@ -218,23 +224,25 @@ public class MesXslApprovalLaunchController {
if (oConvertUtils.isEmpty(bizDataId)) {
continue;
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】批量发起防重:跳过已有审批中实例的单据-----
long active = approvalInstanceService.count(new LambdaQueryWrapper<MesXslApprovalInstance>()
.eq(MesXslApprovalInstance::getBizTable, flow.getBizTable())
.eq(MesXslApprovalInstance::getBizDataId, bizDataId)
.eq(MesXslApprovalInstance::getStatus, "0"));
if (active > 0) {
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】批量发起统一门禁与台账写入-----
Integer tenantId = MesXslTenantUtils.resolveTenantId(flow.getTenantId());
ApprovalGateVo gate = approvalGateService.checkCanLaunch(flow.getBizTable(), bizDataId, tenantId);
if (gate.getAllowed() == null || !gate.getAllowed()) {
skipCount++;
continue;
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】批量发起防重跳过已有审批中实例的单据-----
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
approvalInstanceService.save(inst);
approvalGateService.createRunningRecord(
approvalGateService.buildMesDraft(flow.getBizTable(), flow.getBizTableName(), bizDataId, bizTitle,
flow.getId(), flow.getFlowName(), inst.getId(), loginUser, tenantId));
approvalHandleService.enterFirstNode(inst, loginUser);
syncApprovalRecordAfterLaunch(inst.getId());
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】批量发起统一门禁与台账写入-----
count++;
}
if (count == 0) {
return Result.error(skipCount > 0 ? "所选单据均已有审批中的流程,无需重复发起" : "没有有效的单据数据");
return Result.error(skipCount > 0 ? "所选单据均不允许发起审批(可能已有流转中或已通过流程)" : "没有有效的单据数据");
}
String msg = "已发起 " + count + " 条审批!";
if (skipCount > 0) {
@@ -265,4 +273,14 @@ public class MesXslApprovalLaunchController {
// 处理人解析、节点进度初始化、卡片发送统一由 IMesXslApprovalHandleService.enterFirstNode 完成
return inst;
}
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】发起后同步台账终态(如无节点自动通过)-----
private void syncApprovalRecordAfterLaunch(String instanceId) {
MesXslApprovalInstance latest = approvalInstanceService.getById(instanceId);
if (latest == null || "0".equals(latest.getStatus())) {
return;
}
approvalGateService.finishByMesInstance(instanceId, latest.getStatus(), latest.getCurrentHandlersText());
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】发起后同步台账终态(如无节点自动通过)-----
}

View File

@@ -0,0 +1,69 @@
package org.jeecg.modules.xslmes.approval.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
/**
* MES 审批台账(只读查询)
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】台账列表菜单
*/
@Tag(name = "MES审批台账")
@RestController
@RequestMapping("/xslmes/mesXslApprovalRecord")
@Slf4j
public class MesXslApprovalRecordController extends JeecgController<MesXslApprovalRecord, IMesXslApprovalRecordService> {
@Autowired
private IMesXslApprovalRecordService mesXslApprovalRecordService;
@Operation(summary = "MES审批台账-分页列表查询")
@RequiresPermissions("xslmes:mes_xsl_approval_record:list")
@GetMapping(value = "/list")
public Result<IPage<MesXslApprovalRecord>> queryPageList(
MesXslApprovalRecord model,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<MesXslApprovalRecord> queryWrapper = QueryGenerator.initQueryWrapper(model, req.getParameterMap());
queryWrapper.orderByDesc("apply_time").orderByDesc("create_time");
Page<MesXslApprovalRecord> page = new Page<>(pageNo, pageSize);
IPage<MesXslApprovalRecord> pageList = mesXslApprovalRecordService.page(page, queryWrapper);
return Result.OK(pageList);
}
@Operation(summary = "MES审批台账-通过id查询")
@RequiresPermissions("xslmes:mes_xsl_approval_record:list")
@GetMapping(value = "/queryById")
public Result<MesXslApprovalRecord> queryById(@RequestParam(name = "id") String id) {
MesXslApprovalRecord entity = mesXslApprovalRecordService.getById(id);
if (entity == null) {
return Result.error("未找到对应数据");
}
return Result.OK(entity);
}
@RequiresPermissions("xslmes:mes_xsl_approval_record:exportXls")
@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, MesXslApprovalRecord model) {
return super.exportXls(request, model, MesXslApprovalRecord.class, "MES审批台账");
}
}

View File

@@ -0,0 +1,111 @@
package org.jeecg.modules.xslmes.approval.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.aspect.annotation.Dict;
import org.jeecg.common.system.base.entity.JeecgEntity;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* MES 审批台账(跨 MES/钉钉 统一门禁)
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("mes_xsl_approval_record")
@Schema(description = "MES审批台账")
public class MesXslApprovalRecord extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "业务单据表名")
private String bizTable;
@Schema(description = "业务单据中文名")
private String bizTableName;
@Schema(description = "业务编码(菜单permission.id)")
private String bizCode;
@Schema(description = "业务单据记录ID")
private String bizDataId;
@Schema(description = "业务单据展示标题")
private String bizTitle;
@Schema(description = "审批通道 MES/DINGTALK")
@Dict(dicCode = "mes_xsl_approval_channel")
private String channel;
@Schema(description = "外部实例ID(MES实例ID或钉钉instanceId)")
private String externalInstanceId;
@Schema(description = "MES审批流ID")
private String flowId;
@Schema(description = "MES审批流名称")
private String flowName;
@Schema(description = "钉钉审批模板ID")
private String templateId;
@Schema(description = "钉钉审批模板名称")
private String templateName;
@Schema(description = "同一业务单据第几次发起")
private Integer launchNo;
@Schema(description = "状态 0流转中 1通过 2拒绝 3撤销 4发起失败")
@Dict(dicCode = "mes_xsl_approval_record_status")
private String status;
@Schema(description = "发起人username")
private String applyUser;
@Schema(description = "发起人姓名")
private String applyUserName;
@Schema(description = "发起时间")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date applyTime;
@Schema(description = "办结时间")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date finishTime;
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】与 MesXslApprovalInstance 对齐,复用 isBizAtOriginStatus 逻辑-----
@Schema(description = "业务单据状态字段名(发起时快照,驳回回写依据)")
private String statusField;
@Schema(description = "发起审批时业务状态原值(驳回回写依据)")
private String originStatus;
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】与 MesXslApprovalInstance 对齐,复用 isBizAtOriginStatus 逻辑-----
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁字段记录已处理的节点回调数-----
@Schema(description = "钉钉回调已处理节点数(幂等去重,勿用于业务逻辑)")
private Integer processedOpCount;
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁字段记录已处理的节点回调数-----
@Schema(description = "备注")
private String remark;
@Schema(description = "逻辑删除 0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
/**
* MES 审批台账 Mapper
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public interface MesXslApprovalRecordMapper extends BaseMapper<MesXslApprovalRecord> {
}

View File

@@ -0,0 +1,80 @@
package org.jeecg.modules.xslmes.approval.service;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import java.util.List;
import java.util.Map;
/**
* 审批门禁服务:统一判断能否发起审批,并维护审批台账
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public interface IMesXslApprovalGateService {
/**
* 检查是否允许对指定业务单据发起审批(跨 MES/钉钉通道)
*/
ApprovalGateVo checkCanLaunch(String bizTable, String bizDataId, Integer tenantId);
/**
* 批量检查是否允许发起审批
*/
List<ApprovalGateVo> checkCanLaunchBatch(String bizTable, List<String> bizDataIds, Integer tenantId);
/**
* 校验允许发起,不允许则抛出 IllegalStateException供 Controller 转 Result.error
*/
void assertCanLaunch(String bizTable, String bizDataId, Integer tenantId);
/**
* 发起审批时写入台账(状态=流转中)
*/
MesXslApprovalRecord createRunningRecord(MesXslApprovalRecord draft);
/**
* MES 审批实例办结时同步台账
*/
void finishByMesInstance(String mesInstanceId, String status, String remark);
/**
* 按外部实例 ID 办结台账(预留钉钉回调等场景)。
* 返回 true 表示本次调用实际更新了台账(状态从流转中变为终态);
* 返回 false 表示台账已是终态,本次为重复调用。
*/
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回是否实际更新供调用方判断是否跳过后续业务回调-----
boolean finishByExternalInstance(String channel, String externalInstanceId, String status, String remark);
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回是否实际更新供调用方判断是否跳过后续业务回调-----
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁标记节点已处理-----
/**
* 用乐观锁尝试将台账的 processed_op_count 推进到 nodeIndex+1。
* 返回 true 表示成功(本次是第一个处理该节点的线程);
* 返回 false 表示已被其他线程/事件处理过,调用方应跳过。
*/
boolean tryMarkNodeProcessed(String recordId, int nodeIndex);
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁标记节点已处理-----
/**
* 查询业务单据审批历史(按发起时间倒序)
*/
List<MesXslApprovalRecord> listHistory(String bizTable, String bizDataId, Integer tenantId);
/**
* 构建 MES 通道台账草稿
*/
MesXslApprovalRecord buildMesDraft(String bizTable, String bizTableName, String bizDataId, String bizTitle,
String flowId, String flowName, String mesInstanceId, LoginUser loginUser,
Integer tenantId);
/**
* 构建钉钉通道台账草稿
*/
MesXslApprovalRecord buildDingDraft(String bizTable, String bizTableName, String bizCode, String bizDataId,
String bizTitle, String flowId, String flowName, String templateId,
String templateName, String dingInstanceId, LoginUser loginUser,
Integer tenantId);
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.approval.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
/**
* MES 审批台账 Service
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public interface IMesXslApprovalRecordService extends IService<MesXslApprovalRecord> {
}

View File

@@ -0,0 +1,302 @@
package org.jeecg.modules.xslmes.approval.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.system.vo.LoginUser;
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.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 审批门禁服务实现
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Slf4j
@Service
public class MesXslApprovalGateServiceImpl implements IMesXslApprovalGateService {
@Autowired
private IMesXslApprovalRecordService recordService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public ApprovalGateVo checkCanLaunch(String bizTable, String bizDataId, Integer tenantId) {
ApprovalGateVo vo = new ApprovalGateVo()
.setBizTable(bizTable)
.setBizDataId(bizDataId);
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return vo.setAllowed(false).setReason("业务单据信息不完整,无法判断审批状态");
}
MesXslApprovalRecord latest = findLatest(bizTable, bizDataId, tenantId);
if (latest == null) {
return vo.setAllowed(true).setReason("尚未发起过审批,允许发起");
}
fillLatestInfo(vo, latest);
if (ApprovalRecordConstants.STATUS_RUNNING.equals(latest.getStatus())) {
String channelText = channelText(latest.getChannel());
return vo.setAllowed(false).setReason("该单据已有" + channelText + "流程流转中,请勿重复发起");
}
if (ApprovalRecordConstants.STATUS_APPROVED.equals(latest.getStatus())) {
return vo.setAllowed(false).setReason("该单据已审批通过,无需重复发起");
}
if (ApprovalRecordConstants.STATUS_REJECTED.equals(latest.getStatus())) {
return vo.setAllowed(true).setReason("上次审批已被拒绝,允许重新发起");
}
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(latest.getStatus())) {
return vo.setAllowed(true).setReason("上次审批已撤销,允许重新发起");
}
if (ApprovalRecordConstants.STATUS_LAUNCH_FAILED.equals(latest.getStatus())) {
return vo.setAllowed(true).setReason("上次发起失败,允许重新发起");
}
return vo.setAllowed(true).setReason("允许发起审批");
}
@Override
public List<ApprovalGateVo> checkCanLaunchBatch(String bizTable, List<String> bizDataIds, Integer tenantId) {
List<ApprovalGateVo> list = new ArrayList<>();
if (bizDataIds == null || bizDataIds.isEmpty()) {
return list;
}
for (String bizDataId : bizDataIds) {
if (oConvertUtils.isEmpty(bizDataId)) {
continue;
}
list.add(checkCanLaunch(bizTable, bizDataId, tenantId));
}
return list;
}
@Override
public void assertCanLaunch(String bizTable, String bizDataId, Integer tenantId) {
ApprovalGateVo gate = checkCanLaunch(bizTable, bizDataId, tenantId);
if (gate.getAllowed() == null || !gate.getAllowed()) {
throw new IllegalStateException(oConvertUtils.getString(gate.getReason(), "当前不允许发起审批"));
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public MesXslApprovalRecord createRunningRecord(MesXslApprovalRecord draft) {
if (draft == null) {
throw new IllegalArgumentException("台账数据不能为空");
}
assertCanLaunch(draft.getBizTable(), draft.getBizDataId(), draft.getTenantId());
Date now = new Date();
if (draft.getApplyTime() == null) {
draft.setApplyTime(now);
}
draft.setStatus(ApprovalRecordConstants.STATUS_RUNNING);
draft.setLaunchNo(nextLaunchNo(draft.getBizTable(), draft.getBizDataId(), draft.getTenantId()));
recordService.save(draft);
return draft;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void finishByMesInstance(String mesInstanceId, String status, String remark) {
finishByExternalInstance(ApprovalRecordConstants.CHANNEL_MES, mesInstanceId, status, remark);
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回实际影响行数0表示台账已是终态-----
@Override
@Transactional(rollbackFor = Exception.class)
public boolean finishByExternalInstance(String channel, String externalInstanceId, String status, String remark) {
if (oConvertUtils.isEmpty(externalInstanceId) || oConvertUtils.isEmpty(status)) {
return false;
}
if (ApprovalRecordConstants.STATUS_RUNNING.equals(status)) {
return false;
}
LambdaUpdateWrapper<MesXslApprovalRecord> uw = new LambdaUpdateWrapper<MesXslApprovalRecord>()
.eq(MesXslApprovalRecord::getExternalInstanceId, externalInstanceId)
.eq(MesXslApprovalRecord::getStatus, ApprovalRecordConstants.STATUS_RUNNING);
if (oConvertUtils.isNotEmpty(channel)) {
uw.eq(MesXslApprovalRecord::getChannel, channel);
}
uw.set(MesXslApprovalRecord::getStatus, status)
.set(MesXslApprovalRecord::getFinishTime, new Date());
if (oConvertUtils.isNotEmpty(remark)) {
uw.set(MesXslApprovalRecord::getRemark, remark);
}
// getBaseMapper().update() 返回实际影响行数0 = 台账已是终态(重复事件)
int affected = recordService.getBaseMapper().update(null, uw);
return affected > 0;
}
@Override
public boolean tryMarkNodeProcessed(String recordId, int nodeIndex) {
if (oConvertUtils.isEmpty(recordId)) return false;
// 乐观锁processed_op_count < nodeIndex+1 时才推进,保证同一节点只被处理一次
// 两个并发线程同时到达时MySQL 行锁保证只有一个 UPDATE 成功
int affected = jdbcTemplate.update(
"UPDATE mes_xsl_approval_record SET processed_op_count = ? " +
"WHERE id = ? AND processed_op_count < ? AND del_flag = 0",
nodeIndex + 1, recordId, nodeIndex + 1);
return affected > 0;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回实际影响行数0表示台账已是终态-----
@Override
public List<MesXslApprovalRecord> listHistory(String bizTable, String bizDataId, Integer tenantId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return List.of();
}
LambdaQueryWrapper<MesXslApprovalRecord> qw = baseBizQuery(bizTable, bizDataId, tenantId);
qw.orderByDesc(MesXslApprovalRecord::getApplyTime).orderByDesc(MesXslApprovalRecord::getCreateTime);
return recordService.list(qw);
}
@Override
public MesXslApprovalRecord buildMesDraft(String bizTable, String bizTableName, String bizDataId, String bizTitle,
String flowId, String flowName, String mesInstanceId, LoginUser loginUser,
Integer tenantId) {
MesXslApprovalRecord record = new MesXslApprovalRecord();
record.setBizTable(bizTable);
record.setBizTableName(bizTableName);
record.setBizDataId(bizDataId);
record.setBizTitle(oConvertUtils.isNotEmpty(bizTitle) ? bizTitle : bizDataId);
record.setChannel(ApprovalRecordConstants.CHANNEL_MES);
record.setExternalInstanceId(mesInstanceId);
record.setFlowId(flowId);
record.setFlowName(flowName);
record.setTenantId(tenantId);
fillApplyUser(record, loginUser);
return record;
}
@Override
public MesXslApprovalRecord buildDingDraft(String bizTable, String bizTableName, String bizCode, String bizDataId,
String bizTitle, String flowId, String flowName, String templateId,
String templateName, String dingInstanceId, LoginUser loginUser,
Integer tenantId) {
MesXslApprovalRecord record = new MesXslApprovalRecord();
record.setBizTable(bizTable);
record.setBizTableName(bizTableName);
record.setBizCode(bizCode);
record.setBizDataId(bizDataId);
record.setBizTitle(oConvertUtils.isNotEmpty(bizTitle) ? bizTitle : bizDataId);
record.setChannel(ApprovalRecordConstants.CHANNEL_DINGTALK);
record.setExternalInstanceId(dingInstanceId);
record.setFlowId(flowId);
record.setFlowName(flowName);
record.setTemplateId(templateId);
record.setTemplateName(templateName);
record.setTenantId(tenantId);
fillApplyUser(record, loginUser);
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】快照业务状态原值复用 isBizAtOriginStatus 逻辑-----
snapshotOriginStatus(record, bizTable, bizDataId);
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】快照业务状态原值复用 isBizAtOriginStatus 逻辑-----
return record;
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】发起时快照业务状态驳回时供共用判断-----
/**
* 探测业务表是否存在 status 字段,若存在则读取当前值快照到台账。
* 与 MesXslApprovalHandleServiceImpl.snapshotBizStatus() 逻辑对齐,
* 使驳回时的 isBizAtOriginStatus 判断可跨 MES/钉钉两通道复用。
*/
private void snapshotOriginStatus(MesXslApprovalRecord record, String bizTable, String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) return;
if (!bizTable.matches("^[A-Za-z0-9_]+$")) return;
try {
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM information_schema.columns " +
"WHERE table_schema=(SELECT DATABASE()) AND table_name=? AND column_name='status'",
Integer.class, bizTable);
if (cnt == null || cnt == 0) return;
java.util.List<String> vals = jdbcTemplate.queryForList(
"SELECT status FROM " + bizTable + " WHERE id=? LIMIT 1", String.class, bizDataId);
if (!vals.isEmpty()) {
record.setStatusField("status");
record.setOriginStatus(vals.get(0));
}
} catch (Exception e) {
log.warn("[ApprovalGate] 快照业务状态失败 table={} id={}: {}", bizTable, bizDataId, e.getMessage());
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】发起时快照业务状态驳回时供共用判断-----
private MesXslApprovalRecord findLatest(String bizTable, String bizDataId, Integer tenantId) {
LambdaQueryWrapper<MesXslApprovalRecord> qw = baseBizQuery(bizTable, bizDataId, tenantId);
qw.orderByDesc(MesXslApprovalRecord::getApplyTime).orderByDesc(MesXslApprovalRecord::getCreateTime).last("LIMIT 1");
return recordService.getOne(qw, false);
}
private LambdaQueryWrapper<MesXslApprovalRecord> baseBizQuery(String bizTable, String bizDataId, Integer tenantId) {
LambdaQueryWrapper<MesXslApprovalRecord> qw = new LambdaQueryWrapper<>();
qw.eq(MesXslApprovalRecord::getBizTable, bizTable)
.eq(MesXslApprovalRecord::getBizDataId, bizDataId);
if (tenantId != null) {
qw.eq(MesXslApprovalRecord::getTenantId, tenantId);
}
return qw;
}
private int nextLaunchNo(String bizTable, String bizDataId, Integer tenantId) {
LambdaQueryWrapper<MesXslApprovalRecord> qw = baseBizQuery(bizTable, bizDataId, tenantId);
long count = recordService.count(qw);
return (int) count + 1;
}
private void fillLatestInfo(ApprovalGateVo vo, MesXslApprovalRecord latest) {
vo.setLatestRecordId(latest.getId())
.setLatestStatus(latest.getStatus())
.setLatestChannel(latest.getChannel())
.setLatestChannelText(channelText(latest.getChannel()))
.setLatestStatusText(statusText(latest.getStatus()));
}
private void fillApplyUser(MesXslApprovalRecord record, LoginUser loginUser) {
if (loginUser == null) {
return;
}
record.setApplyUser(loginUser.getUsername());
record.setApplyUserName(loginUser.getRealname());
record.setSysOrgCode(loginUser.getOrgCode());
}
private String channelText(String channel) {
if (ApprovalRecordConstants.CHANNEL_DINGTALK.equals(channel)) {
return "钉钉审批";
}
if (ApprovalRecordConstants.CHANNEL_MES.equals(channel)) {
return "MES审批";
}
return oConvertUtils.getString(channel, "审批");
}
private String statusText(String status) {
if (ApprovalRecordConstants.STATUS_RUNNING.equals(status)) {
return "流转中";
}
if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) {
return "审批通过";
}
if (ApprovalRecordConstants.STATUS_REJECTED.equals(status)) {
return "审批拒绝";
}
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) {
return "已撤销";
}
if (ApprovalRecordConstants.STATUS_LAUNCH_FAILED.equals(status)) {
return "发起失败";
}
return oConvertUtils.getString(status, "");
}
}

View File

@@ -18,6 +18,7 @@ import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackDispatcher;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -68,6 +69,11 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
@Autowired
private IMesXslApprovalInstanceService instanceService;
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
@Autowired
private IMesXslApprovalGateService approvalGateService;
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
@Autowired
private JdbcTemplate jdbcTemplate;
@@ -121,6 +127,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentNodeName("无审批节点");
inst.setCurrentHandlersText("无审批节点,自动通过");
instanceService.updateById(inst);
syncApprovalRecord(inst, "无审批节点,自动通过");
// 无审批节点直接最终通过 -> 回调业务
callbackDispatcher.fireApproved(buildContext(inst, inst.getCurrentNodeId(), "无审批节点", applyUser, "无审批节点,自动通过"));
notifyApplicant(inst, applyUser == null ? null : applyUser.getUsername(),
@@ -209,6 +216,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("审批通过");
instanceService.updateById(inst);
syncApprovalRecord(inst, comment);
// 无后续流程,直接最终通过
callbackDispatcher.fireApproved(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, comment));
return Result.OK("审批通过");
@@ -258,6 +266,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlersText("已驳回");
inst.setNodeProgress(progress.toJSONString());
instanceService.updateById(inst);
syncApprovalRecord(inst, reason);
// 驳回 -> 回调业务(可回退业务状态)
callbackDispatcher.fireRejected(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, reason));
@@ -324,6 +333,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("已撤销");
instanceService.updateById(inst);
syncApprovalRecord(inst, comment);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销改为恢复发起前快照,不再走业务「拒绝/驳回」接口-----
// 撤销时单据仍处于发起前最初状态,只需把状态字段恢复到发起时快照(幂等)
@@ -423,6 +433,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("审批人为空,流程终止");
instanceService.updateById(inst);
syncApprovalRecord(inst, "审批人为空,流程终止");
// 流程终止(等同驳回) -> 回调业务(可回退业务状态)
callbackDispatcher.fireRejected(buildContext(inst, nodeId, nodeName, null, "审批人为空,流程终止"));
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】流程终止驳回统一回退(按表注解自动执行)-----
@@ -516,6 +527,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("审批通过");
instanceService.updateById(inst);
syncApprovalRecord(inst, null);
// 流程最终通过 -> 回调业务(终态)
callbackDispatcher.fireApproved(buildContext(inst, currentNodeId, inst.getCurrentNodeName(), actingUser, null));
actionHttpExecutor.run(findNodeById(root, currentNodeId), "onApprove", inst);
@@ -1571,4 +1583,13 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】全链路通知补全-----
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
private void syncApprovalRecord(MesXslApprovalInstance inst, String remark) {
if (inst == null || oConvertUtils.isEmpty(inst.getId()) || "0".equals(inst.getStatus())) {
return;
}
approvalGateService.finishByMesInstance(inst.getId(), inst.getStatus(), remark);
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
}

View File

@@ -0,0 +1,18 @@
package org.jeecg.modules.xslmes.approval.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.mapper.MesXslApprovalRecordMapper;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.springframework.stereotype.Service;
/**
* MES 审批台账 ServiceImpl
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Service
public class MesXslApprovalRecordServiceImpl extends ServiceImpl<MesXslApprovalRecordMapper, MesXslApprovalRecord>
implements IMesXslApprovalRecordService {
}

View File

@@ -0,0 +1,48 @@
package org.jeecg.modules.xslmes.approval.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 审批门禁检查结果
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Data
@Accessors(chain = true)
@Schema(description = "审批门禁检查结果")
public class ApprovalGateVo implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "是否允许发起审批")
private Boolean allowed;
@Schema(description = "不允许时的原因说明")
private String reason;
@Schema(description = "业务单据表名")
private String bizTable;
@Schema(description = "业务单据记录ID")
private String bizDataId;
@Schema(description = "最新台账记录ID")
private String latestRecordId;
@Schema(description = "最新台账状态")
private String latestStatus;
@Schema(description = "最新台账通道 MES/DINGTALK")
private String latestChannel;
@Schema(description = "最新台账通道展示名")
private String latestChannelText;
@Schema(description = "最新台账状态展示名")
private String latestStatusText;
}

View File

@@ -1,26 +1,14 @@
package org.jeecg.modules.xslmes.controller;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.jeecg.dingtalk.api.core.response.Response;
import com.jeecg.dingtalk.api.user.JdtUserAPI;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
@@ -34,7 +22,6 @@ import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.entity.SysDepart;
import org.jeecg.modules.system.service.ISysDepartService;
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
import org.jeecg.modules.xslmes.approval.action.ApprovalBizAction;
import org.jeecg.modules.xslmes.entity.MesXslMixerPsCompile;
import org.jeecg.modules.xslmes.service.IMesXslMixerPsCompileService;
@@ -58,8 +45,6 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
private ISysDepartService sysDepartService;
//update-begin---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】注入钉钉服务-----
@Autowired
private ThirdAppDingtalkServiceImpl dingtalkService;
//update-end---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】注入钉钉服务-----
@Operation(summary = "MES密炼PS编制-分页列表查询")
@@ -301,122 +286,6 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
}
//update-end---author:jiangxh ---date:20260520 for【密炼PS编制】保存前校验与冗余回填-----------
//update-begin---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】测试接口-----
@Operation(summary = "钉钉审批测试用当前登录人手机号获取钉钉userId并尝试PROC-GENERIC发起审批")
@PostMapping(value = "/ddApprovalTest")
public Result<Map<String, Object>> ddApprovalTest() {
Map<String, Object> result = new LinkedHashMap<>();
// ① 取当前登录用户
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
String username = loginUser.getUsername();
String realname = oConvertUtils.isNotEmpty(loginUser.getRealname()) ? loginUser.getRealname() : username;
String phone = loginUser.getPhone();
result.put("step1_user", realname + "(" + username + ")");
result.put("step1_phone", oConvertUtils.isNotEmpty(phone) ? phone : "【未配置手机号】");
if (oConvertUtils.isEmpty(phone)) {
result.put("结论", "当前账号未配置手机号无法查询钉钉userId");
return Result.OK(result);
}
// ② 获取 AccessToken
String accessToken;
try {
accessToken = dingtalkService.getAccessToken();
} catch (Exception e) {
result.put("step2_accessToken", "获取失败: " + e.getMessage());
return Result.OK(result);
}
if (oConvertUtils.isEmpty(accessToken)) {
result.put("step2_accessToken", "获取失败,请在[系统配置-第三方应用]中检查钉钉配置");
return Result.OK(result);
}
result.put("step2_accessToken", accessToken.substring(0, Math.min(10, accessToken.length())) + "...[已截断]");
// ③ 手机号查钉钉 userId
Response<String> userIdResp = JdtUserAPI.getUseridByMobile(phone, accessToken);
result.put("step3_getUseridByMobile_errcode", userIdResp.getErrcode());
result.put("step3_getUseridByMobile_errmsg", userIdResp.getErrmsg());
result.put("step3_dingtalkUserId", userIdResp.isSuccess() ? userIdResp.getResult() : "查询失败");
if (!userIdResp.isSuccess() || oConvertUtils.isEmpty(userIdResp.getResult())) {
result.put("结论", "手机号未在钉钉通讯录中找到对应用户,请确认该手机号已在企业钉钉中注册");
return Result.OK(result);
}
String dtUserId = userIdResp.getResult();
// ④ 用真实模板 processCode 发起审批实例
try {
String processCode = "PROC-71957EB4-6F64-4AD7-AA1C-3CD7E797687B"; // MES测试审批流
String url = "https://api.dingtalk.com/v1.0/workflow/processInstances";
String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
// 表单字段(对应钉钉模板中的控件标题)
JSONArray formValues = new JSONArray();
for (String[] kv : new String[][]{
{"PS编码", "TEST-" + System.currentTimeMillis() % 10000},
{"类型", "MES测试"},
{"发行日期", new SimpleDateFormat("yyyy-MM-dd").format(new Date())},
{"发送部门", "技术部"},
{"标题", "MES钉钉审批测试 " + now},
}) {
JSONObject f = new JSONObject();
f.put("name", kv[0]);
f.put("value", kv[1]);
formValues.add(f);
}
// 审批人用当前登录人自己做测试审批人and会签只有1人
JSONArray approvers = new JSONArray();
JSONObject approverNode = new JSONObject();
approverNode.put("actionType", "AND");
JSONArray approverIds = new JSONArray();
approverIds.add(dtUserId);
approverNode.put("userIds", approverIds);
approvers.add(approverNode);
JSONObject reqBody = new JSONObject();
reqBody.put("processCode", processCode);
reqBody.put("originatorUserId", dtUserId);
reqBody.put("deptId", -1);
reqBody.put("approvers", approvers);
reqBody.put("formComponentValues", formValues);
result.put("step4_请求体", reqBody);
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest httpReq = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("x-acs-dingtalk-access-token", accessToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(reqBody.toJSONString()))
.timeout(Duration.ofSeconds(10))
.build();
String respBody = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofString()).body();
JSONObject ddResp = JSONObject.parseObject(respBody);
result.put("step4_钉钉响应", ddResp);
if (ddResp.containsKey("instanceId") || ddResp.containsKey("processInstanceId")) {
String iid = ddResp.containsKey("instanceId")
? ddResp.getString("instanceId")
: ddResp.getString("processInstanceId");
result.put("结论", "✅ 审批实例创建成功instanceId=" + iid + ",请到钉钉「待我审批」查看");
} else {
result.put("结论", "❌ 创建失败code=" + ddResp.getString("code") + " msg=" + ddResp.getString("message"));
}
} catch (Exception e) {
log.error("钉钉审批实例创建测试异常", e);
result.put("step4_钉钉响应", "HTTP请求异常: " + e.getMessage());
result.put("结论", "❌ 请求钉钉接口失败");
}
return Result.OK(result);
}
//update-end---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】测试接口-----
//update-begin---author:jiangxh ---date:20260520 for【密炼PS编制】仅编制状态允许删除-----------
private String assertCompileStatusForDelete(MesXslMixerPsCompile entity) {
if (entity == null) {

View File

@@ -5,6 +5,7 @@ import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.dingtalk.dto.DingFormComponent;
import org.jeecg.modules.xslmes.dingtalk.dto.DingFormComponentProps;
import org.jeecg.modules.xslmes.dingtalk.dto.DingFormCreateRequest;
@@ -75,6 +76,9 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private IMesXslApprovalGateService approvalGateService;
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】绑定MES审批流审批人来源-----------
@Operation(summary = "钉钉审批模板配置-分页列表查询")
@@ -98,10 +102,40 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:add")
@PostMapping(value = "/add")
public Result<String> add(@RequestBody MesXslDingProcessTpl mesXslDingProcessTpl) {
if (oConvertUtils.isEmpty(mesXslDingProcessTpl.getProcessCode())) {
mesXslDingProcessTpl.setProcessCode("");
}
mesXslDingProcessTplService.save(mesXslDingProcessTpl);
return Result.OK("添加成功!");
}
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】新增审批模板本地草稿processCode 由后续创建钉钉模板回填)-----
@AutoLog(value = "钉钉审批模板配置-新增审批模板草稿")
@Operation(summary = "钉钉审批模板配置-新增审批模板(返回含 id 的记录,供打开表单设计器)")
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:add")
@PostMapping(value = "/addNewTemplate")
public Result<MesXslDingProcessTpl> addNewTemplate(@RequestBody MesXslDingProcessTpl body) {
if (body == null || oConvertUtils.isEmpty(body.getTplName())) {
return Result.error("模板名称不能为空");
}
if (oConvertUtils.isEmpty(body.getStatus())) {
body.setStatus("1");
}
if (body.getSortNo() == null) {
body.setSortNo(0);
}
if (oConvertUtils.isEmpty(body.getFormFields())) {
body.setFormFields("[]");
}
// process_code 列 NOT NULL草稿阶段用空串占位创建钉钉模板后回填真实 processCode
if (oConvertUtils.isEmpty(body.getProcessCode())) {
body.setProcessCode("");
}
mesXslDingProcessTplService.save(body);
return Result.OK("审批模板已创建,请继续设计表单并推送到钉钉", body);
}
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】新增审批模板本地草稿processCode 由后续创建钉钉模板回填)-----
@AutoLog(value = "钉钉审批模板配置-编辑")
@Operation(summary = "钉钉审批模板配置-编辑")
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit")
@@ -206,22 +240,37 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
JSONObject result = ddResp.getJSONObject("result");
JSONArray processList = result == null ? null : result.getJSONArray("process_list");
// 查询本地已存在的 processCode标记哪些已导入
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】同步列表已导入判定含 processCode 与同名本地草稿-----
Set<String> existCodes = new HashSet<>();
mesXslDingProcessTplService.list().forEach(t -> existCodes.add(t.getProcessCode()));
Map<String, String> draftIdByTplName = new HashMap<>();
for (MesXslDingProcessTpl t : mesXslDingProcessTplService.list()) {
if (oConvertUtils.isNotEmpty(t.getProcessCode())) {
existCodes.add(t.getProcessCode());
} else if (oConvertUtils.isNotEmpty(t.getTplName())) {
draftIdByTplName.put(t.getTplName().trim(), t.getId());
}
}
List<Map<String, Object>> list = new ArrayList<>();
if (processList != null) {
for (int i = 0; i < processList.size(); i++) {
JSONObject item = processList.getJSONObject(i);
String code = item.getString("process_code");
String name = oConvertUtils.getString(item.getString("name"), "").trim();
Map<String, Object> row = new LinkedHashMap<>();
row.put("processCode", item.getString("process_code"));
row.put("name", item.getString("name"));
row.put("processCode", code);
row.put("name", name);
row.put("description", item.getString("description"));
row.put("imported", existCodes.contains(item.getString("process_code")));
boolean imported = oConvertUtils.isNotEmpty(code) && existCodes.contains(code);
String localDraftId = (!imported && oConvertUtils.isNotEmpty(name))
? draftIdByTplName.get(name) : null;
row.put("imported", imported || localDraftId != null);
row.put("localDraftId", localDraftId);
row.put("linkDraft", localDraftId != null && !imported);
list.add(row);
}
}
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】同步列表已导入判定含 processCode 与同名本地草稿-----
return Result.OK(list);
} catch (Exception e) {
log.error("从钉钉同步审批模板失败", e);
@@ -235,28 +284,62 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
if (items == null || items.isEmpty()) {
return Result.error("请选择要导入的模板");
}
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】批量导入优先回填同名本地草稿避免重复记录-----
Set<String> existCodes = new HashSet<>();
mesXslDingProcessTplService.list().forEach(t -> existCodes.add(t.getProcessCode()));
for (MesXslDingProcessTpl t : mesXslDingProcessTplService.list()) {
if (oConvertUtils.isNotEmpty(t.getProcessCode())) {
existCodes.add(t.getProcessCode());
}
}
List<MesXslDingProcessTpl> toSave = new ArrayList<>();
int linkedCount = 0;
for (Map<String, Object> item : items) {
String code = String.valueOf(item.getOrDefault("processCode", ""));
String name = String.valueOf(item.getOrDefault("name", ""));
String code = String.valueOf(item.getOrDefault("processCode", "")).trim();
String name = String.valueOf(item.getOrDefault("name", "")).trim();
String localDraftId = item.get("localDraftId") != null ? String.valueOf(item.get("localDraftId")) : null;
if (oConvertUtils.isEmpty(code) || existCodes.contains(code)) {
continue;
}
MesXslDingProcessTpl draft = null;
if (oConvertUtils.isNotEmpty(localDraftId)) {
draft = mesXslDingProcessTplService.getById(localDraftId);
}
if (draft == null && oConvertUtils.isNotEmpty(name)) {
draft = findDraftWithoutProcessCode(name);
}
if (draft != null && oConvertUtils.isEmpty(draft.getProcessCode())) {
MesXslDingProcessTpl update = new MesXslDingProcessTpl();
update.setId(draft.getId());
update.setProcessCode(code);
if (oConvertUtils.isNotEmpty(name)) {
update.setTplName(name);
}
mesXslDingProcessTplService.updateById(update);
existCodes.add(code);
linkedCount++;
continue;
}
MesXslDingProcessTpl tpl = new MesXslDingProcessTpl();
tpl.setProcessCode(code);
tpl.setTplName(name);
tpl.setStatus("1");
tpl.setSortNo(0);
toSave.add(tpl);
existCodes.add(code);
}
if (toSave.isEmpty()) {
if (toSave.isEmpty() && linkedCount == 0) {
return Result.OK("所选模板均已存在,无需重复导入");
}
mesXslDingProcessTplService.saveBatch(toSave);
return Result.OK("成功导入 " + toSave.size() + " 个模板");
if (!toSave.isEmpty()) {
mesXslDingProcessTplService.saveBatch(toSave);
}
String msg = "成功导入 " + toSave.size() + " 个模板";
if (linkedCount > 0) {
msg += ",回填本地草稿 " + linkedCount + "";
}
return Result.OK(msg);
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】批量导入优先回填同名本地草稿避免重复记录-----
}
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】从钉钉同步审批模板列表-----
@@ -435,6 +518,7 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】创建/更新钉钉审批模板-----
@Operation(summary = "钉钉审批模板配置-创建钉钉审批模板POST不带processCode=新建)")
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit")
@PostMapping(value = "/createDingTemplate")
public Result<Map<String, Object>> createDingTemplate(@RequestBody Map<String, Object> body) {
String id = String.valueOf(body.getOrDefault("id", ""));
@@ -464,13 +548,16 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
return Result.error("钉钉返回错误: " + resp.getString("message") + " (code=" + resp.getString("code") + ")");
}
String processCode = resp.getString("processCode");
if (oConvertUtils.isNotEmpty(processCode)) {
MesXslDingProcessTpl update = new MesXslDingProcessTpl();
update.setId(tpl.getId());
update.setProcessCode(processCode);
mesXslDingProcessTplService.updateById(update);
// 钉钉 v1.0 接口 processCode 在 result 节点内,非响应根字段
String processCode = extractProcessCodeFromDingFormResp(resp);
if (oConvertUtils.isEmpty(processCode)) {
log.warn("【钉钉创建模板】响应中未解析到 processCode原始响应: {}", respBody);
return Result.error("钉钉模板已提交,但未返回 processCode请从钉钉同步回填或查看后台日志");
}
MesXslDingProcessTpl update = new MesXslDingProcessTpl();
update.setId(tpl.getId());
update.setProcessCode(processCode);
mesXslDingProcessTplService.updateById(update);
Map<String, Object> result = new LinkedHashMap<>();
result.put("processCode", processCode);
@@ -483,6 +570,7 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
}
@Operation(summary = "钉钉审批模板配置-更新钉钉审批模板POST带processCode=更新已有)")
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit")
@PostMapping(value = "/updateDingTemplate")
public Result<Map<String, Object>> updateDingTemplate(@RequestBody Map<String, Object> body) {
String id = String.valueOf(body.getOrDefault("id", ""));
@@ -742,6 +830,34 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
return "TextField";
}
/** 从「创建/更新审批表单」响应中解析 processCode兼容根节点与 result 节点) */
private String extractProcessCodeFromDingFormResp(JSONObject resp) {
if (resp == null) {
return null;
}
String code = resp.getString("processCode");
if (oConvertUtils.isNotEmpty(code)) {
return code;
}
JSONObject result = resp.getJSONObject("result");
if (result != null && oConvertUtils.isNotEmpty(result.getString("processCode"))) {
return result.getString("processCode");
}
return null;
}
/** 查找尚未绑定 processCode 的本地草稿(按模板名称) */
private MesXslDingProcessTpl findDraftWithoutProcessCode(String tplName) {
if (oConvertUtils.isEmpty(tplName)) {
return null;
}
QueryWrapper<MesXslDingProcessTpl> qw = new QueryWrapper<>();
qw.eq("tpl_name", tplName.trim());
qw.and(w -> w.isNull("process_code").or().eq("process_code", ""));
qw.last("LIMIT 1");
return mesXslDingProcessTplService.getOne(qw, false);
}
/** 统一 HTTP 调用钉钉 v1.0 接口 */
private String callDingApi(String method, String url, String accessToken, String jsonBody) throws Exception {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
@@ -1093,6 +1209,20 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
// ⑤ 查询发起人在钉钉的所属部门 ID
long originatorDeptId = resolveUserDeptId(originatorDtUserId, accessToken);
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
String bizTable = firstNonEmpty(str(body.get("bizTable")), approvalFlow.getBizTable());
String bizDataId = str(body.get("bizDataId"));
String bizTitle = str(body.get("bizTitle"));
String bizCode = str(body.get("bizCode"));
if (oConvertUtils.isNotEmpty(bizTable) && oConvertUtils.isNotEmpty(bizDataId)) {
try {
approvalGateService.assertCanLaunch(bizTable, bizDataId, tenantId);
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
// ⑥ 组装钉钉发起审批请求体
JSONObject reqBody = new JSONObject();
reqBody.put("processCode", tpl.getProcessCode());
@@ -1124,8 +1254,18 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
return Result.error("钉钉返回错误: " + resp.getString("message") + " (code=" + resp.getString("code") + ")");
}
String dingInstanceId = resp.getString("instanceId");
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起成功后写入审批台账-----
if (oConvertUtils.isNotEmpty(bizTable) && oConvertUtils.isNotEmpty(bizDataId)) {
approvalGateService.createRunningRecord(
approvalGateService.buildDingDraft(bizTable, approvalFlow.getBizTableName(), bizCode, bizDataId,
bizTitle, flowId, approvalFlow.getFlowName(), tpl.getId(), tpl.getTplName(),
dingInstanceId, loginUser, tenantId));
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起成功后写入审批台账-----
Map<String, Object> result = new LinkedHashMap<>();
result.put("instanceId", resp.getString("instanceId"));
result.put("instanceId", dingInstanceId);
result.put("tplName", tpl.getTplName());
return Result.OK("审批发起成功!审批人将在钉钉「待我审批」中收到任务", result);
} catch (Exception e) {
@@ -1556,5 +1696,18 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
}
return null;
}
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起参数解析辅助-----
private String str(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private String firstNonEmpty(String first, String second) {
if (oConvertUtils.isNotEmpty(first)) {
return first.trim();
}
return oConvertUtils.isNotEmpty(second) ? second.trim() : "";
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起参数解析辅助-----
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批-----------
}

View File

@@ -0,0 +1,319 @@
package org.jeecg.modules.xslmes.dingtalk.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.modules.print.catalog.IPrintBizEntityFieldCatalogProvider;
import org.jeecg.modules.print.service.IPrintBizPermEntityService;
import org.jeecg.modules.print.util.PrintBizDetailPropertyScanner;
import org.jeecg.modules.print.util.PrintBizEntityFieldIntrospector;
import org.jeecg.modules.print.vo.PrintBizDetailSlotVO;
import org.jeecg.modules.print.vo.PrintBizFieldItemVO;
import org.jeecg.modules.print.vo.PrintBizTypeVO;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingTplBindService;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingProcessTplService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 钉钉审批模板绑定:将菜单业务实体字段与审批模板表单控件可视化绑定
*
* @author GHT
* @date 2026-06-04 for【MESToDing审批配置】审批模板绑定
*/
@Tag(name = "钉钉审批模板绑定")
@RestController
@RequestMapping("/xslmes/dingTplBind")
@Slf4j
public class MesXslDingTplBindController {
@Autowired private IMesXslDingTplBindService bindService;
@Autowired private IMesXslDingProcessTplService tplService;
@Autowired private IPrintBizPermEntityService printBizPermEntityService;
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired(required = false)
private IPrintBizEntityFieldCatalogProvider fieldCatalogProvider;
// ═══════════════════════ 菜单树 ═══════════════════════
/**
* 查询 sys_permission 一二级菜单树menu_type=0/1最多两层供左侧选择业务菜单。
* 不限租户(全量展示,与打印绑定白名单选树逻辑一致)。
*/
@Operation(summary = "查询一二级菜单树(供左侧树选择)")
@GetMapping("/menuTree")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<MenuNodeVO>> menuTree() {
String sql = "SELECT id, parent_id, name, sort_no, menu_type, icon " +
"FROM sys_permission " +
"WHERE del_flag = 0 AND status = '1' AND menu_type IN (0, 1) " +
"ORDER BY sort_no ASC, create_time ASC";
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
Map<String, MenuNodeVO> nodeMap = new LinkedHashMap<>();
for (Map<String, Object> row : rows) {
MenuNodeVO n = new MenuNodeVO();
n.setId(str(row.get("id")));
n.setName(str(row.get("name")));
n.setParentId(str(row.get("parent_id")));
n.setMenuType(row.get("menu_type") != null ? ((Number) row.get("menu_type")).intValue() : 0);
n.setIcon(str(row.get("icon")));
n.setChildren(new ArrayList<>());
nodeMap.put(n.getId(), n);
}
// 构建两层树根节点parentId 为空)及其直接子节点
List<MenuNodeVO> roots = new ArrayList<>();
Set<String> rootIds = new HashSet<>();
for (MenuNodeVO n : nodeMap.values()) {
if (StringUtils.isBlank(n.getParentId()) || !nodeMap.containsKey(n.getParentId())) {
roots.add(n);
rootIds.add(n.getId());
}
}
for (MenuNodeVO n : nodeMap.values()) {
if (rootIds.contains(n.getParentId())) {
nodeMap.get(n.getParentId()).getChildren().add(n);
}
}
// 过滤掉没有子节点的根节点(纯目录空节点无意义展示)
roots.removeIf(r -> r.getChildren().isEmpty() && r.getMenuType() == 0);
return Result.OK(roots);
}
// ═══════════════════════ 绑定总览列表 ═══════════════════════
@Operation(summary = "查询所有绑定记录(按创建时间倒序)")
@GetMapping("/list")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<MesXslDingTplBind>> list() {
List<MesXslDingTplBind> rows = bindService.list(
new QueryWrapper<MesXslDingTplBind>()
.orderByDesc("create_time"));
return Result.OK(rows);
}
// ═══════════════════════ 模板列表 ═══════════════════════
@Operation(summary = "可用钉钉审批模板列表(状态启用)")
@GetMapping("/tplList")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<MesXslDingProcessTpl>> tplList() {
List<MesXslDingProcessTpl> list = tplService.list(
new QueryWrapper<MesXslDingProcessTpl>()
.eq("status", "1")
.eq("del_flag", 0)
.orderByAsc("sort_no")
.orderByDesc("create_time"));
return Result.OK(list);
}
// ═══════════════════════ 业务字段 ═══════════════════════
@Operation(summary = "按业务编码获取主实体可用字段")
@GetMapping("/bizFields")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<PrintBizFieldItemVO>> bizFields(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
String code = bizCode.trim();
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
return Result.OK(fieldCatalogProvider.listMainFields(code));
}
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
return Result.OK(Collections.emptyList());
}
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
if (cls == null) {
return Result.OK(Collections.emptyList());
}
return Result.OK(PrintBizEntityFieldIntrospector.listFields(cls));
}
@Operation(summary = "主实体上的明细槽位(供 TableField 绑定明细集合)")
@GetMapping("/detailSlots")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<PrintBizDetailSlotVO>> detailSlots(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
String code = bizCode.trim();
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
return Result.OK(fieldCatalogProvider.listDetailSlots(code));
}
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
return Result.OK(Collections.emptyList());
}
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
if (cls == null) {
return Result.OK(Collections.emptyList());
}
return Result.OK(PrintBizDetailPropertyScanner.listSlots(cls));
}
@Operation(summary = "指定明细槽位的字段列表(字段路径带属性名前缀)")
@GetMapping("/detailFields")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<PrintBizFieldItemVO>> detailFields(
@RequestParam(name = "bizCode") String bizCode,
@RequestParam(name = "detailProperty") String detailProperty,
@RequestParam(name = "slotKind", defaultValue = "LIST") String slotKind) {
if (StringUtils.isAnyBlank(bizCode, detailProperty)) {
return Result.error("bizCode 与 detailProperty 不能为空");
}
String code = bizCode.trim();
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
return Result.OK(fieldCatalogProvider.listPrefixedDetailFields(code, detailProperty.trim(), slotKind.trim()));
}
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
return Result.OK(Collections.emptyList());
}
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
if (cls == null) {
return Result.OK(Collections.emptyList());
}
return Result.OK(PrintBizDetailPropertyScanner.listPrefixedDetailFields(cls, detailProperty.trim(), slotKind.trim()));
}
// ═══════════════════════ 按路由检测绑定(全局悬浮按钮使用) ═══════════════════════
/**
* 通过前端路由路径查找对应的审批模板绑定。
* 全局悬浮按钮在每次路由跳转时调用,若返回非 null 则显示"发起钉钉审批"按钮。
*/
@Operation(summary = "按前端路由路径检测是否有钉钉绑定null=无)")
@GetMapping("/bindingByRoute")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<MesXslDingTplBind> bindingByRoute(@RequestParam(name = "routePath") String routePath) {
if (StringUtils.isBlank(routePath)) {
return Result.OK(null);
}
String path = routePath.trim();
String sql = "SELECT id FROM sys_permission WHERE url = ? AND del_flag = 0 LIMIT 1";
List<String> ids;
try {
ids = jdbcTemplate.queryForList(sql, String.class, path);
} catch (Exception e) {
return Result.OK(null);
}
if (ids.isEmpty()) {
return Result.OK(null);
}
MesXslDingTplBind bind = bindService.getByBizCode(ids.get(0));
return Result.OK(bind);
}
// ═══════════════════════ 绑定 CRUD ═══════════════════════
@Operation(summary = "按业务编码查询已保存的绑定配置")
@GetMapping("/getByBizCode")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<MesXslDingTplBind> getByBizCode(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
MesXslDingTplBind row = bindService.getByBizCode(bizCode.trim());
return row != null ? Result.OK(row) : Result.OK(null);
}
@AutoLog(value = "钉钉审批模板绑定-保存")
@Operation(summary = "保存绑定配置(新增或更新)")
@PostMapping("/save")
@RequiresPermissions("xslmes:mesXslDingTplBind:save")
public Result<String> save(@RequestBody SaveBindRequest req) {
if (req == null || StringUtils.isBlank(req.getBizCode())) {
return Result.error("业务编码不能为空");
}
if (StringUtils.isBlank(req.getTemplateId())) {
return Result.error("请选择钉钉审批模板");
}
MesXslDingProcessTpl tpl = tplService.getById(req.getTemplateId());
if (tpl == null) {
return Result.error("选择的钉钉审批模板不存在");
}
MesXslDingTplBind existing = bindService.getByBizCode(req.getBizCode().trim());
if (existing != null) {
// 更新已有绑定
existing.setTemplateId(req.getTemplateId());
existing.setTemplateName(tpl.getTplName());
if (StringUtils.isNotBlank(req.getBizName())) {
existing.setBizName(req.getBizName());
}
existing.setFieldMappingJson(normalizeJson(req.getFieldMappingJson()));
bindService.updateById(existing);
return Result.OK("更新成功");
}
// 新增
MesXslDingTplBind bind = new MesXslDingTplBind();
bind.setBizCode(req.getBizCode().trim());
bind.setBizName(req.getBizName());
bind.setTemplateId(req.getTemplateId());
bind.setTemplateName(tpl.getTplName());
bind.setFieldMappingJson(normalizeJson(req.getFieldMappingJson()));
bindService.save(bind);
return Result.OK("保存成功");
}
@AutoLog(value = "钉钉审批模板绑定-删除")
@Operation(summary = "删除绑定配置")
@DeleteMapping("/delete")
@RequiresPermissions("xslmes:mesXslDingTplBind:delete")
public Result<String> delete(@RequestParam(name = "id") String id) {
bindService.removeById(id);
return Result.OK("删除成功");
}
// ═══════════════════════ 辅助方法 ═══════════════════════
private static String str(Object o) {
return o == null ? null : o.toString();
}
/** 确保 mapping JSON 为数组字符串,避免空或非法格式入库 */
private static String normalizeJson(String raw) {
if (StringUtils.isBlank(raw)) {
return "[]";
}
String trimmed = raw.trim();
if (trimmed.startsWith("[")) {
return trimmed;
}
return "[]";
}
// ═══════════════════════ 内部 VO ═══════════════════════
@Data
public static class MenuNodeVO {
private String id;
private String name;
private String parentId;
private Integer menuType;
private String icon;
private List<MenuNodeVO> children;
}
@Data
public static class SaveBindRequest {
private String bizCode;
private String bizName;
private String templateId;
private String fieldMappingJson;
}
}

View File

@@ -0,0 +1,52 @@
package org.jeecg.modules.xslmes.dingtalk.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.system.base.entity.JeecgEntity;
import java.io.Serializable;
/**
* 钉钉审批模板绑定(菜单业务实体 ↔ 钉钉审批模板表单字段)
*
* @author GHT
* @date 2026-06-04 for【MESToDing审批配置】审批模板绑定
*/
@Data
@TableName("mes_xsl_ding_tpl_bind")
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@Schema(description = "钉钉审批模板绑定")
public class MesXslDingTplBind extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "业务编码(sys_permission.id与打印绑定 biz_code 含义一致)")
private String bizCode;
@Schema(description = "业务名称(菜单名)")
private String bizName;
@Schema(description = "钉钉审批模板ID(mes_xsl_ding_process_tpl.id)")
private String templateId;
@Schema(description = "钉钉审批模板名称")
private String templateName;
@Schema(description = "字段绑定JSON[{componentId,componentLabel,componentName,parentId,bizField}]")
private String fieldMappingJson;
@Schema(description = "逻辑删除:0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.dingtalk.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
/**
* 钉钉审批模板绑定 Mapper
*
* @author GHT
* @date 2026-06-04
*/
public interface MesXslDingTplBindMapper extends BaseMapper<MesXslDingTplBind> {
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingTplBindMapper">
</mapper>

View File

@@ -0,0 +1,16 @@
package org.jeecg.modules.xslmes.dingtalk.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
/**
* 钉钉审批模板绑定 Service
*
* @author GHT
* @date 2026-06-04
*/
public interface IMesXslDingTplBindService extends IService<MesXslDingTplBind> {
/** 按业务编码查询绑定记录(未删除的第一条) */
MesXslDingTplBind getByBizCode(String bizCode);
}

View File

@@ -0,0 +1,24 @@
package org.jeecg.modules.xslmes.dingtalk.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
import org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingTplBindMapper;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingTplBindService;
import org.springframework.stereotype.Service;
/**
* 钉钉审批模板绑定 ServiceImpl
*
* @author GHT
* @date 2026-06-04
*/
@Service
public class MesXslDingTplBindServiceImpl extends ServiceImpl<MesXslDingTplBindMapper, MesXslDingTplBind>
implements IMesXslDingTplBindService {
@Override
public MesXslDingTplBind getByBizCode(String bizCode) {
return getOne(new QueryWrapper<MesXslDingTplBind>().eq("biz_code", bizCode).last("LIMIT 1"));
}
}

View File

@@ -0,0 +1,352 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
import com.alibaba.fastjson2.JSONArray;
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.callback.ApprovalCallbackContext;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackDispatcher;
import org.jeecg.modules.xslmes.approval.callback.ApprovalActionHttpExecutor;
import org.jeecg.modules.xslmes.approval.constant.ApprovalRecordConstants;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
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.service.IMesXslApprovalRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 钉钉审批事件处理器。
* <p>
* 收到 Stream 事件后,主动调用 {@link DingTalkWorkflowService#getProcessInstance} 拉取完整实例数据,
* 通过 {@code operationRecords} 精准得知"哪个节点由谁操作",从而:
* <ul>
* <li>用审批人自己的 JWT Token 调用业务接口proofread_by/audit_by 写入真实操作人);</li>
* <li>以 operationRecord 的顺序索引映射到 MES 流程节点,执行节点配置的 callbackActions</li>
* <li>同时触发 {@link org.jeecg.modules.xslmes.approval.callback.IApprovalBizCallback} Bean 回调。</li>
* </ul>
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】基于operationRecords精准映射节点与操作人
*/
@Slf4j
@Component
public class DingBpmsEventProcessor {
@Autowired
private IMesXslApprovalGateService approvalGateService;
@Autowired
private IMesXslApprovalRecordService approvalRecordService;
@Autowired
private IMesXslApprovalFlowService approvalFlowService;
@Autowired
private ApprovalCallbackDispatcher callbackDispatcher;
@Autowired
private ApprovalActionHttpExecutor actionHttpExecutor;
@Autowired
private DingTalkWorkflowService workflowService;
@Autowired
private JdbcTemplate jdbcTemplate;
// ==================== bpms_instance_change ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】拉取实例详情后精准执行节点回调-----
public void onInstanceChange(JSONObject data) {
if (data == null) return;
String processInstanceId = data.getString("processInstanceId");
String type = data.getString("type");
String result = data.getString("result");
log.info("[DingBpms] bpms_instance_change instanceId={} type={} result={}",
processInstanceId, type, result);
if (oConvertUtils.isEmpty(processInstanceId) || "start".equals(type)) {
return;
}
// ① 映射终态
String status;
String remark;
if ("finish".equals(type)) {
if ("agree".equals(result)) {
status = ApprovalRecordConstants.STATUS_APPROVED;
remark = "钉钉审批通过";
} else if ("refuse".equals(result)) {
status = ApprovalRecordConstants.STATUS_REJECTED;
remark = "钉钉审批拒绝";
} else {
log.info("[DingBpms] 审批转交 result={},不处理 instanceId={}", result, processInstanceId);
return;
}
} else if ("terminate".equals(type)) {
status = ApprovalRecordConstants.STATUS_CANCELLED;
remark = "钉钉审批已终止";
} else {
return;
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重finishByExternalInstance条件为status=RUNNING0行更新即终态已处理-----
// ② 更新台账乐观条件WHERE status=RUNNING返回 false 表示已是终态,本次为重复事件,直接跳过)
try {
boolean updated = approvalGateService.finishByExternalInstance(
ApprovalRecordConstants.CHANNEL_DINGTALK, processInstanceId, status, remark);
if (!updated) {
log.info("[DingBpms] instanceId={} 台账已是终态,跳过重复的终态事件", processInstanceId);
return;
}
log.info("[DingBpms] 台账已更新 instanceId={} -> status={}", processInstanceId, status);
} catch (Exception e) {
log.error("[DingBpms] 台账更新失败 instanceId={}: {}", processInstanceId, e.getMessage(), e);
return;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重finishByExternalInstance条件为status=RUNNING0行更新即终态已处理-----
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) {
return;
}
// ③ 拉取完整审批实例
MesXslApprovalRecord record = findRecord(processInstanceId);
if (record == null || oConvertUtils.isEmpty(record.getBizTable())) {
return;
}
JSONObject instance = workflowService.getProcessInstance(processInstanceId);
List<JSONObject> taskOps = workflowService.getTaskOperations(instance);
List<JSONObject> mesNodes = loadApproverNodes(record.getFlowId());
ApprovalCallbackContext ctx = buildContext(record, remark);
if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) {
// 最终通过:执行最后一个节点的 onApprove用最后审批人的 token
if (!mesNodes.isEmpty() && !taskOps.isEmpty()) {
JSONObject lastOp = taskOps.get(taskOps.size() - 1);
String lastDtUserId = lastOp.getString("userId");
String token = workflowService.generateTokenByDtUserId(lastDtUserId);
JSONObject lastNode = mesNodes.get(mesNodes.size() - 1);
actionHttpExecutor.run(lastNode, "onApprove", record.getBizDataId(), token);
}
callbackDispatcher.fireApproved(ctx);
} else {
// 驳回:复用与 MES 内部审批相同的 isBizAtOriginStatus 逻辑
// 台账已在发起时快照了 originStatus若单据状态仍为原值说明未被推进跳过 onReject
JSONObject refuseOp = findRefuseOp(taskOps);
if (refuseOp != null) {
int refuseIndex = taskOps.indexOf(refuseOp);
boolean bizAtOrigin = isBizAtOriginStatus(record);
if (!bizAtOrigin && refuseIndex < mesNodes.size()) {
// 单据已被前面节点推进,需要调 onReject 回退
String dtUserId = refuseOp.getString("userId");
String token = workflowService.generateTokenByDtUserId(dtUserId);
try {
actionHttpExecutor.run(mesNodes.get(refuseIndex), "onReject",
record.getBizDataId(), token);
} catch (Exception e) {
log.error("[DingBpms] onReject HTTP 回调失败: {}", e.getMessage());
}
} else {
log.info("[DingBpms] 单据仍处于发起前原始状态,跳过 onReject 回调 instanceId={}",
processInstanceId);
}
}
callbackDispatcher.fireRejected(ctx);
}
log.info("[DingBpms] 终态回调完成 bizTable={} bizDataId={} status={}",
record.getBizTable(), record.getBizDataId(), status);
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】拉取实例详情后精准执行节点回调-----
// ==================== bpms_task_change ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove-----
/**
* 节点任务变更:每个审批人操作时触发。
* <p>
* 节点通过时:
* <ol>
* <li>拉取审批实例详情,从 operationRecords 得到"已完成任务列表"</li>
* <li>最后一条完成操作的索引 = 本次节点在 MES 流程中的位置;</li>
* <li>用该操作人的 Token 执行对应 MES 节点的 onNodeApprove 回调接口;</li>
* <li>触发 IApprovalBizCallback.onNodeApproved。</li>
* </ol>
*/
public void onTaskChange(JSONObject data) {
if (data == null) return;
String processInstanceId = data.getString("processInstanceId");
String type = data.getString("type");
String result = data.getString("result");
String actionerDtUserId = data.getString("actionerUserId");
log.info("[DingBpms] bpms_task_change instanceId={} type={} result={} actionerUserId={}",
processInstanceId, type, result, actionerDtUserId);
// 只处理节点"完成-通过"
if (!"finish".equals(type) || !"agree".equals(result)) {
// 拒绝终态由 bpms_instance_change 统一处理,此处不重复触发
return;
}
MesXslApprovalRecord record = findRecord(processInstanceId);
if (record == null || oConvertUtils.isEmpty(record.getBizTable())) {
return;
}
// 拉取实例详情
JSONObject instance = workflowService.getProcessInstance(processInstanceId);
if (instance == null) {
log.warn("[DingBpms] 获取审批实例详情失败 instanceId={},跳过节点回调", processInstanceId);
return;
}
List<JSONObject> taskOps = workflowService.getTaskOperations(instance);
List<JSONObject> mesNodes = loadApproverNodes(record.getFlowId());
if (taskOps.isEmpty() || mesNodes.isEmpty()) {
return;
}
// 刚完成的是最后一条操作index = taskOps.size()-1
int nodeIndex = taskOps.size() - 1;
if (nodeIndex >= mesNodes.size()) {
log.debug("[DingBpms] 节点索引 {} 超出 MES 节点数 {},跳过", nodeIndex, mesNodes.size());
return;
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁推进processed_op_count并发安全且重启不丢-----
// tryMarkNodeProcessedUPDATE ... SET processed_op_count=nodeIndex+1 WHERE processed_op_count<nodeIndex+1
// MySQL 行锁保证原子性:并发两个事件只有一个成功,另一个返回 false
boolean claimed = approvalGateService.tryMarkNodeProcessed(record.getId(), nodeIndex);
if (!claimed) {
log.info("[DingBpms] 节点{} 回调已执行,跳过重复事件 instanceId={}", nodeIndex + 1, processInstanceId);
return;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁推进processed_op_count并发安全且重启不丢-----
// 操作人:优先用 operationRecords 里的 userId兜底用事件里的 actionerUserId
JSONObject lastOp = taskOps.get(nodeIndex);
String dtUserId = oConvertUtils.isNotEmpty(lastOp.getString("userId"))
? lastOp.getString("userId") : actionerDtUserId;
String actioner = oConvertUtils.isNotEmpty(lastOp.getString("showName"))
? lastOp.getString("showName") : "审批人";
String token = workflowService.generateTokenByDtUserId(dtUserId);
JSONObject node = mesNodes.get(nodeIndex);
// 执行该节点的 onNodeApprove HTTP 回调
actionHttpExecutor.run(node, "onNodeApprove", record.getBizDataId(), token);
// 触发 IApprovalBizCallback.onNodeApproved
try {
ApprovalCallbackContext ctx = buildContext(record, "钉钉节点审批通过(" + actioner + "")
.setOperatorName(actioner);
callbackDispatcher.fireNodeApproved(ctx);
} catch (Exception e) {
log.error("[DingBpms] 节点 Bean 回调失败 instanceId={}: {}", processInstanceId, e.getMessage(), e);
}
log.info("[DingBpms] 节点{}/{} 回调完成 actioner={} bizTable={} bizDataId={}",
nodeIndex + 1, mesNodes.size(), actioner, record.getBizTable(), record.getBizDataId());
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove-----
// ==================== 内部辅助 ====================
private MesXslApprovalRecord findRecord(String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)) return null;
List<MesXslApprovalRecord> list = approvalRecordService.list(
new LambdaQueryWrapper<MesXslApprovalRecord>()
.eq(MesXslApprovalRecord::getExternalInstanceId, processInstanceId)
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
.orderByDesc(MesXslApprovalRecord::getCreateTime)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
private List<JSONObject> loadApproverNodes(String flowId) {
List<JSONObject> result = new ArrayList<>();
if (oConvertUtils.isEmpty(flowId)) return result;
try {
MesXslApprovalFlow flow = approvalFlowService.getById(flowId);
if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) return result;
collectApproverNodes(JSONObject.parseObject(flow.getFlowConfig()), result);
} catch (Exception e) {
log.warn("[DingBpms] 加载流程节点失败 flowId={}: {}", flowId, e.getMessage());
}
return result;
}
private void collectApproverNodes(JSONObject node, List<JSONObject> out) {
if (node == null) return;
if ("approver".equals(node.getString("type"))) {
out.add(node);
}
JSONArray branches = node.getJSONArray("conditionNodes");
if (branches != null && !branches.isEmpty()) {
Object first = branches.get(0);
if (first instanceof JSONObject) {
collectApproverNodes(((JSONObject) first).getJSONObject("childNode"), out);
}
}
collectApproverNodes(node.getJSONObject("childNode"), out);
}
/** 找第一条 result=REFUSE 的操作记录 */
private JSONObject findRefuseOp(List<JSONObject> taskOps) {
for (JSONObject op : taskOps) {
String r = op.getString("result");
if ("REFUSE".equalsIgnoreCase(r) || "refuse".equalsIgnoreCase(r)) {
return op;
}
}
return null;
}
/**
* 判断业务单据是否仍处于发起审批前的原始状态。
* 与 MesXslApprovalHandleServiceImpl.isBizAtOriginStatus(MesXslApprovalInstance) 逻辑完全一致,
* 此处基于 MesXslApprovalRecord钉钉通道实现使驳回逻辑在两通道间真正复用。
*/
private boolean isBizAtOriginStatus(MesXslApprovalRecord record) {
String statusField = record.getStatusField();
String originStatus = record.getOriginStatus();
if (oConvertUtils.isEmpty(statusField) || originStatus == null) {
// 无快照信息(旧数据兼容):退化为 false走 onReject 调用
return false;
}
if (!record.getBizTable().matches("^[A-Za-z0-9_]+$")
|| !statusField.matches("^[A-Za-z0-9_]+$")) {
return false;
}
try {
List<String> vals = jdbcTemplate.queryForList(
"SELECT " + statusField + " FROM " + record.getBizTable()
+ " WHERE id=? LIMIT 1", String.class, record.getBizDataId());
return !vals.isEmpty() && java.util.Objects.equals(originStatus, vals.get(0));
} catch (Exception e) {
log.warn("[DingBpms] 读取业务状态失败 table={}: {}", record.getBizTable(), e.getMessage());
return false;
}
}
private ApprovalCallbackContext buildContext(MesXslApprovalRecord record, String comment) {
return new ApprovalCallbackContext()
.setInstanceId(record.getId())
.setFlowId(record.getFlowId())
.setFlowName(record.getFlowName())
.setBizTable(record.getBizTable())
.setBizTableName(record.getBizTableName())
.setBizDataId(record.getBizDataId())
.setBizTitle(record.getBizTitle())
.setApplyUser(record.getApplyUser())
.setComment(comment)
.setOperatorUsername("dingtalk")
.setOperatorName("钉钉审批");
}
}

View File

@@ -0,0 +1,151 @@
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;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;
/**
* 钉钉 Stream 模式事件接收客户端(基于官方 SDK com.dingtalk.open:dingtalk-stream
* <p>
* 无需注册公网回调地址:应用主动建立长连接,钉钉通过该通道推送事件(如审批结果),
* 官方 SDK 内部自动维护重连与心跳。
* <p>
* 启动时机:{@link SmartLifecycle}phase=MAX-100确保 Spring 上下文完全就绪后再建连。
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】基于官方SDK的Stream客户端
*/
@Slf4j
@Component
public class DingTalkStreamClient implements SmartLifecycle {
@Autowired
private ThirdAppDingtalkServiceImpl dingtalkService;
@Autowired
private DingBpmsEventProcessor bpmsEventProcessor;
private volatile boolean running = false;
// ==================== SmartLifecycle ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】应用启动后自动建立Stream连接-----
@Override
public int getPhase() {
// 最后启动,确保 DB / Spring Bean 全部就绪
return Integer.MAX_VALUE - 100;
}
@Override
public void start() {
running = true;
// 在后台线程初始化,避免阻塞 Spring 上下文刷新
Thread t = new Thread(this::initSdkClient, "ding-stream");
t.setDaemon(true);
t.start();
}
@Override
public void stop() {
running = false;
// SDK 内部使用 daemon 线程JVM 退出时自动终止
log.info("[DingStream] 钉钉 Stream 客户端已停止");
}
@Override
public boolean isRunning() {
return running;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】应用启动后自动建立Stream连接-----
// ==================== SDK 初始化(官方写法)====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】使用官方SDK建立Stream长连接-----
private void initSdkClient() {
try {
String[] creds = dingtalkService.getDingAppCredentials();
if (creds == null || oConvertUtils.isEmpty(creds[0]) || oConvertUtils.isEmpty(creds[1])) {
log.warn("[DingStream] 钉钉 AppKey/AppSecret 未配置Stream 连接未启动。"
+ "请在【系统配置-第三方应用】中完成钉钉应用配置后重启服务。");
return;
}
log.info("[DingStream] 正在建立钉钉 Stream 连接AppKey={}", creds[0]);
// 官方写法build().start() 链式调用SDK 内部管理长连接与重连
OpenDingTalkStreamClientBuilder
.custom()
.credential(new AuthClientCredential(creds[0], creds[1]))
.registerAllEventListener(new GenericEventListener() {
@Override
public EventAckStatus onEvent(GenericOpenDingTalkEvent event) {
return handleEvent(event);
}
})
.build()
.start();
log.info("[DingStream] 钉钉 Stream 客户端已启动,等待审批事件推送");
} catch (Exception e) {
log.error("[DingStream] SDK 启动失败,请检查钉钉配置: {}", e.getMessage(), e);
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】使用官方SDK建立Stream长连接-----
// ==================== 事件处理 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】处理事件并回写审批台账-----
private EventAckStatus handleEvent(GenericOpenDingTalkEvent event) {
try {
String eventType = event.getEventType();
log.debug("[DingStream] 收到事件 eventId={} eventType={} bornTime={}",
event.getEventId(), eventType, event.getEventBornTime());
if (!"bpms_instance_change".equals(eventType) && !"bpms_task_change".equals(eventType)) {
return EventAckStatus.SUCCESS;
}
// getData() 返回 fastjson2 JSONObject
com.alibaba.fastjson2.JSONObject data = toJsonObject(event.getData());
if (data == null) {
log.warn("[DingStream] 事件 data 为空eventType={}", eventType);
return EventAckStatus.SUCCESS;
}
if ("bpms_instance_change".equals(eventType)) {
bpmsEventProcessor.onInstanceChange(data);
} else {
bpmsEventProcessor.onTaskChange(data);
}
return EventAckStatus.SUCCESS;
} catch (Exception e) {
log.error("[DingStream] 事件处理异常 eventType={}: {}", event.getEventType(), e.getMessage(), e);
// LATER通知钉钉稍后重推避免丢失事件
return EventAckStatus.LATER;
}
}
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;
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】处理事件并回写审批台账-----
}

View File

@@ -0,0 +1,204 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.system.util.JwtUtil;
import org.jeecg.common.util.RedisUtil;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.entity.SysUser;
import org.jeecg.modules.system.service.ISysUserService;
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* 钉钉审批工作流 API 封装。
* 提供「获取审批实例详情」和「审批人身份 Token 生成」两个核心能力,
* 供 {@link DingBpmsEventProcessor} 在收到事件后主动查询完整实例数据。
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】主动拉取审批实例详情精准映射节点与操作人
*/
@Slf4j
@Service
public class DingTalkWorkflowService {
private static final String PROCESS_INSTANCE_URL =
"https://api.dingtalk.com/v1.0/workflow/processInstances";
@Autowired
private ThirdAppDingtalkServiceImpl dingtalkService;
@Autowired
private ISysUserService sysUserService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private RedisUtil redisUtil;
// ==================== 审批实例详情 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】拉取钉钉审批实例详情-----
/**
* 调用 GET /v1.0/workflow/processInstances 获取审批实例完整数据。
*
* @param processInstanceId 钉钉审批实例 ID
* @return result 节点(含 status/result/operationRecords/tasks/formComponentValues 等),失败返回 null
*/
public JSONObject getProcessInstance(String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)) {
return null;
}
// 后台线程无 TenantContext必须用绕过租户校验的专用方法
String accessToken = dingtalkService.getAccessTokenForBackground();
if (oConvertUtils.isEmpty(accessToken)) {
log.warn("[DingWorkflow] AccessToken 获取失败,无法查询审批实例 {}", processInstanceId);
return null;
}
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(PROCESS_INSTANCE_URL + "?processInstanceId=" + processInstanceId))
.header("x-acs-dingtalk-access-token", accessToken)
.GET()
.timeout(Duration.ofSeconds(10))
.build();
String body = client.send(req, HttpResponse.BodyHandlers.ofString()).body();
JSONObject resp = JSONObject.parseObject(body);
if (resp.containsKey("code")) {
log.warn("[DingWorkflow] 查询审批实例失败 instanceId={} code={} msg={}",
processInstanceId, resp.getString("code"), resp.getString("message"));
return null;
}
return resp.getJSONObject("result");
} catch (Exception e) {
log.error("[DingWorkflow] 调用钉钉审批实例详情接口异常 instanceId={}: {}",
processInstanceId, e.getMessage(), e);
return null;
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】拉取钉钉审批实例详情-----
// ==================== operationRecords 解析 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】从operationRecords提取节点操作序列-----
/**
* 从审批实例的 operationRecords 中提取所有"正常任务执行"记录type=EXECUTE_TASK_NORMAL
* 按时间顺序排列,每条记录对应一个审批节点的操作(通过/拒绝)。
* <p>
* 返回的列表索引与 MES 流程节点列表索引一一对应第0条 = 第1个节点第1条 = 第2个节点以此类推。
*/
public List<JSONObject> getTaskOperations(JSONObject instanceResult) {
List<JSONObject> ops = new ArrayList<>();
if (instanceResult == null) {
return ops;
}
JSONArray records = instanceResult.getJSONArray("operationRecords");
if (records == null || records.isEmpty()) {
return ops;
}
for (int i = 0; i < records.size(); i++) {
JSONObject rec = records.getJSONObject(i);
if (rec == null) continue;
String type = rec.getString("type");
// 只取正常节点执行(过滤掉发起、转交、评论等操作)
if ("EXECUTE_TASK_NORMAL".equals(type) || "EXECUTE_TASK_AGENT".equals(type)) {
ops.add(rec);
}
}
return ops;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】从operationRecords提取节点操作序列-----
// ==================== 操作人身份 Token ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】按钉钉userId生成操作人JWT Token-----
/**
* 将钉钉 userId 映射到 MES 系统用户,并生成该用户的 JWT Token。
* <p>
* 查询链sys_third_account(third_user_id=dtUserId) → sys_user_id → sys_user.username/password
* <p>
* 这样回调业务接口时,接口内部通过 {@code SecurityUtils.getSubject().getPrincipal()}
* 拿到的就是真实审批人,而非 admin保证 proofread_by/audit_by/approve_by 字段写入正确。
*
* @param dtUserId 钉钉 userId来自 operationRecord.userId
* @return JWT token找不到绑定或发生异常时返回 null调用方降级用 admin token
*/
public String generateTokenByDtUserId(String dtUserId) {
if (oConvertUtils.isEmpty(dtUserId)) {
return null;
}
try {
// ① 钉钉userId → MES sys_user_id
List<String> userIds = jdbcTemplate.queryForList(
"SELECT sys_user_id FROM sys_third_account " +
"WHERE third_type='dingtalk' AND third_user_id=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
String.class, dtUserId);
if (userIds.isEmpty() || oConvertUtils.isEmpty(userIds.get(0))) {
log.debug("[DingWorkflow] 钉钉用户 {} 未绑定 MES 账号,降级使用 admin token", dtUserId);
return generateAdminToken();
}
// ② sys_user_id → username + password
SysUser user = sysUserService.getById(userIds.get(0));
if (user == null || oConvertUtils.isEmpty(user.getPassword())) {
return generateAdminToken();
}
return signAndCache(user.getUsername(), user.getPassword());
} catch (Exception e) {
log.warn("[DingWorkflow] 生成用户 token 失败 dtUserId={}: {}", dtUserId, e.getMessage());
return generateAdminToken();
}
}
/** 生成 admin 系统 token用于钉钉用户未绑定 MES 账号时的兜底 */
public String generateAdminToken() {
try {
SysUser admin = sysUserService.getUserByName("admin");
if (admin == null || oConvertUtils.isEmpty(admin.getPassword())) {
log.warn("[DingWorkflow] admin 用户不存在,无法生成系统 token");
return null;
}
return signAndCache(admin.getUsername(), admin.getPassword());
} catch (Exception e) {
log.warn("[DingWorkflow] 生成 admin token 失败: {}", e.getMessage());
return null;
}
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】生成token后写入Redis通过Shiro校验-----
/**
* 生成 JWT Token 并写入 Rediskey=PREFIX_USER_TOKEN+tokenvalue=token
* <p>
* JeecgBoot Shiro 在 {@code jwtTokenRefresh()} 中验证 token 时,
* 必须从 Redis {@code "prefix_user_token:" + token} 取到缓存值,
* 否则报 "Token失效请重新登录"。新生成的 token 需要手动写入 Redis 才能通过校验。
* <p>
* TTL 设置为 JwtUtil.EXPIRE_TIME / 1000与正常登录保持一致。
*/
private String signAndCache(String username, String password) {
String token = JwtUtil.sign(username, password);
if (oConvertUtils.isNotEmpty(token)) {
// 写入 Redis让 Shiro jwtTokenRefresh 能查到
long ttlSeconds = JwtUtil.EXPIRE_TIME / 1000;
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, token, ttlSeconds);
}
return token;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】生成token后写入Redis通过Shiro校验-----
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】按钉钉userId生成操作人JWT Token-----
}

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;