完善 MCS 桌面代理与开炼机动作同步,修复原料相关菜单权限,桌面端新增上辅机 MES 菜单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-27 15:56:05 +08:00
parent 7a9c19e4f3
commit 442a4c8ae2
113 changed files with 10169 additions and 91 deletions

View File

@@ -0,0 +1,34 @@
package org.jeecg.modules.device.sync.agent;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* MES → 桌面设备库命令。
*/
@Data
public class DeviceAgentCommand implements Serializable {
private static final long serialVersionUID = 1L;
public static final String CMD_QUERY = "EQ_QUERY";
public static final String CMD_WRITE = "EQ_WRITE";
public static final String CMD_SYNC_PULL = "EQ_SYNC_PULL";
public static final String CMD_SYNC_PUSH = "EQ_SYNC_PUSH";
public static final String CMD_TEST = "EQ_TEST";
private String requestId;
private String cmd;
private String deviceId;
/** 白名单表名(可选,与 sql 二选一或同时校验) */
private String table;
/** 已登记 sqlKey 或受限 SQL桌面端白名单校验 */
private String sql;
private String sqlKey;
private List<Object> params;
private Map<String, Object> payload;
private Integer timeoutMs;
private Long sentAt;
}

View File

@@ -0,0 +1,166 @@
package org.jeecg.modules.device.sync.agent;
import com.alibaba.fastjson.JSON;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* 设备代理命令总线:下发 EQ_* 并等待回执。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DeviceAgentCommandService {
public static final int DEFAULT_TIMEOUT_MS = 30_000;
private final SimpMessagingTemplate messagingTemplate;
private final DeviceAgentSessionRegistry sessionRegistry;
private final ConcurrentHashMap<String, CompletableFuture<DeviceAgentResult>> pending = new ConcurrentHashMap<>();
public DeviceAgentResult sendAndWait(DeviceAgentCommand command) {
if (command == null) {
throw new IllegalArgumentException("command 不能为空");
}
if (StringUtils.isBlank(command.getRequestId())) {
command.setRequestId(UUID.randomUUID().toString().replace("-", ""));
}
if (StringUtils.isBlank(command.getDeviceId())) {
DeviceAgentSession picked = sessionRegistry.pickOnlineAgent(null);
if (picked == null) {
throw new IllegalStateException("无在线桌面设备代理,无法访问设备库");
}
command.setDeviceId(picked.getDeviceId());
}
if (!sessionRegistry.isOnline(command.getDeviceId())) {
throw new IllegalStateException("桌面设备代理不在线: " + command.getDeviceId());
}
int timeout = command.getTimeoutMs() != null && command.getTimeoutMs() > 0
? command.getTimeoutMs() : DEFAULT_TIMEOUT_MS;
command.setSentAt(System.currentTimeMillis());
CompletableFuture<DeviceAgentResult> future = new CompletableFuture<>();
CompletableFuture<DeviceAgentResult> previous = pending.putIfAbsent(command.getRequestId(), future);
if (previous != null) {
throw new IllegalStateException("重复的 requestId: " + command.getRequestId());
}
try {
String destination = "/topic/device/" + command.getDeviceId() + "/cmd";
messagingTemplate.convertAndSend(destination, JSON.toJSONString(command));
log.info("已下发设备库命令 cmd={} requestId={} deviceId={}",
command.getCmd(), command.getRequestId(), command.getDeviceId());
return future.get(timeout, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
pending.remove(command.getRequestId(), future);
throw new IllegalStateException("等待桌面设备库回执超时(" + timeout + "ms), requestId=" + command.getRequestId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
pending.remove(command.getRequestId(), future);
throw new IllegalStateException("等待桌面设备库回执被中断", e);
} catch (Exception e) {
pending.remove(command.getRequestId(), future);
if (e instanceof IllegalStateException) {
throw (IllegalStateException) e;
}
throw new IllegalStateException("下发设备库命令失败: " + e.getMessage(), e);
}
}
public void completeResult(DeviceAgentResult result) {
if (result == null || StringUtils.isBlank(result.getRequestId())) {
return;
}
if (StringUtils.isNotBlank(result.getDeviceId())) {
sessionRegistry.registerOrHeartbeat(
result.getDeviceId(),
null,
result.getDbConnected(),
result.getDbMessage());
}
CompletableFuture<DeviceAgentResult> future = pending.remove(result.getRequestId());
if (future != null) {
future.complete(result);
} else {
log.debug("收到无等待方的设备库回执 requestId={}", result.getRequestId());
}
}
public DeviceAgentResult query(String deviceId, String sql, List<Object> params, Integer timeoutMs) {
DeviceAgentCommand cmd = new DeviceAgentCommand();
cmd.setCmd(DeviceAgentCommand.CMD_QUERY);
cmd.setDeviceId(deviceId);
cmd.setSql(sql);
cmd.setParams(params);
cmd.setTimeoutMs(timeoutMs);
DeviceAgentResult result = sendAndWait(cmd);
ensureSuccess(result);
return result;
}
public DeviceAgentResult write(String deviceId, String sql, List<Object> params, Integer timeoutMs) {
DeviceAgentCommand cmd = new DeviceAgentCommand();
cmd.setCmd(DeviceAgentCommand.CMD_WRITE);
cmd.setDeviceId(deviceId);
cmd.setSql(sql);
cmd.setParams(params);
cmd.setTimeoutMs(timeoutMs);
DeviceAgentResult result = sendAndWait(cmd);
ensureSuccess(result);
return result;
}
public DeviceAgentResult test(String deviceId, Integer timeoutMs) {
DeviceAgentCommand cmd = new DeviceAgentCommand();
cmd.setCmd(DeviceAgentCommand.CMD_TEST);
cmd.setDeviceId(deviceId);
cmd.setTimeoutMs(timeoutMs != null ? timeoutMs : 10_000);
return sendAndWait(cmd);
}
public DeviceAgentResult syncPull(String deviceId, String table, String sql, List<Object> params, Integer timeoutMs) {
DeviceAgentCommand cmd = new DeviceAgentCommand();
cmd.setCmd(DeviceAgentCommand.CMD_SYNC_PULL);
cmd.setDeviceId(deviceId);
cmd.setTable(table);
cmd.setSql(sql);
cmd.setParams(params);
cmd.setTimeoutMs(timeoutMs != null ? timeoutMs : 60_000);
DeviceAgentResult result = sendAndWait(cmd);
ensureSuccess(result);
return result;
}
public DeviceAgentResult syncPush(String deviceId, String table, Map<String, Object> payload, Integer timeoutMs) {
DeviceAgentCommand cmd = new DeviceAgentCommand();
cmd.setCmd(DeviceAgentCommand.CMD_SYNC_PUSH);
cmd.setDeviceId(deviceId);
cmd.setTable(table);
cmd.setPayload(payload);
cmd.setTimeoutMs(timeoutMs != null ? timeoutMs : 60_000);
DeviceAgentResult result = sendAndWait(cmd);
ensureSuccess(result);
return result;
}
private void ensureSuccess(DeviceAgentResult result) {
if (result == null) {
throw new IllegalStateException("桌面设备库无回执");
}
if (!result.isSuccess()) {
throw new IllegalStateException(StringUtils.defaultIfBlank(result.getError(), "桌面设备库执行失败"));
}
}
}

View File

@@ -0,0 +1,30 @@
package org.jeecg.modules.device.sync.agent;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* 桌面 → MES 设备库命令回执。
*/
@Data
public class DeviceAgentResult implements Serializable {
private static final long serialVersionUID = 1L;
private String requestId;
private String deviceId;
private String cmd;
private boolean success;
private String error;
private Integer affected;
private List<Map<String, Object>> rows;
private Boolean dbConnected;
private String dbMessage;
private Long respondedAt;
/** 分块回传时使用 */
private Integer chunkIndex;
private Integer chunkTotal;
private Boolean lastChunk;
}

View File

@@ -0,0 +1,22 @@
package org.jeecg.modules.device.sync.agent;
import lombok.Data;
import java.io.Serializable;
/**
* 桌面设备代理在线会话(内存)。
*/
@Data
public class DeviceAgentSession implements Serializable {
private static final long serialVersionUID = 1L;
private String deviceId;
private String hostName;
private boolean online;
/** 设备库SQL Server是否连通 */
private boolean dbConnected;
private String dbMessage;
private long lastSeenAt;
private long registeredAt;
}

View File

@@ -0,0 +1,121 @@
package org.jeecg.modules.device.sync.agent;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 桌面设备代理会话注册表。
*/
@Slf4j
@Component
public class DeviceAgentSessionRegistry {
private static final long ONLINE_TTL_MS = 60_000L;
private final ConcurrentHashMap<String, DeviceAgentSession> sessions = new ConcurrentHashMap<>();
public void registerOrHeartbeat(String deviceId, String hostName, Boolean dbConnected, String dbMessage) {
if (StringUtils.isBlank(deviceId)) {
return;
}
long now = System.currentTimeMillis();
sessions.compute(deviceId, (id, old) -> {
DeviceAgentSession s = old == null ? new DeviceAgentSession() : old;
if (old == null) {
s.setRegisteredAt(now);
}
s.setDeviceId(deviceId);
if (StringUtils.isNotBlank(hostName)) {
s.setHostName(hostName);
}
s.setOnline(true);
if (dbConnected != null) {
s.setDbConnected(dbConnected);
}
if (dbMessage != null) {
s.setDbMessage(dbMessage);
}
s.setLastSeenAt(now);
return s;
});
}
public void markOffline(String deviceId) {
if (StringUtils.isBlank(deviceId)) {
return;
}
DeviceAgentSession s = sessions.get(deviceId);
if (s != null) {
s.setOnline(false);
s.setLastSeenAt(System.currentTimeMillis());
}
}
public DeviceAgentSession get(String deviceId) {
if (StringUtils.isBlank(deviceId)) {
return null;
}
DeviceAgentSession s = sessions.get(deviceId);
if (s == null) {
return null;
}
refreshOnlineFlag(s);
return s;
}
public boolean isOnline(String deviceId) {
DeviceAgentSession s = get(deviceId);
return s != null && s.isOnline();
}
/**
* 选取一个在线代理:优先指定 deviceId否则取最近心跳的在线设备。
*/
public DeviceAgentSession pickOnlineAgent(String preferredDeviceId) {
if (StringUtils.isNotBlank(preferredDeviceId)) {
DeviceAgentSession preferred = get(preferredDeviceId);
if (preferred != null && preferred.isOnline()) {
return preferred;
}
}
return listOnline().stream()
.max(Comparator.comparingLong(DeviceAgentSession::getLastSeenAt))
.orElse(null);
}
public List<DeviceAgentSession> listOnline() {
List<DeviceAgentSession> list = new ArrayList<>();
for (Map.Entry<String, DeviceAgentSession> e : sessions.entrySet()) {
DeviceAgentSession s = e.getValue();
refreshOnlineFlag(s);
if (s.isOnline()) {
list.add(s);
}
}
list.sort(Comparator.comparingLong(DeviceAgentSession::getLastSeenAt).reversed());
return list;
}
public List<DeviceAgentSession> listAll() {
List<DeviceAgentSession> list = new ArrayList<>(sessions.values());
list.forEach(this::refreshOnlineFlag);
list.sort(Comparator.comparingLong(DeviceAgentSession::getLastSeenAt).reversed());
return list;
}
private void refreshOnlineFlag(DeviceAgentSession s) {
if (s == null) {
return;
}
if (System.currentTimeMillis() - s.getLastSeenAt() > ONLINE_TTL_MS) {
s.setOnline(false);
}
}
}

View File

@@ -1,20 +1,29 @@
package org.jeecg.modules.device.sync.controller;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.device.sync.agent.DeviceAgentCommand;
import org.jeecg.modules.device.sync.agent.DeviceAgentCommandService;
import org.jeecg.modules.device.sync.agent.DeviceAgentResult;
import org.jeecg.modules.device.sync.agent.DeviceAgentSession;
import org.jeecg.modules.device.sync.agent.DeviceAgentSessionRegistry;
import org.jeecg.modules.device.sync.entity.DeviceStatus;
import org.jeecg.modules.device.sync.mapper.DeviceStatusMapper;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -29,6 +38,8 @@ public class DeviceWebSocketController {
private final DeviceStatusMapper deviceStatusMapper;
private final SimpMessagingTemplate messagingTemplate;
private final DeviceAgentSessionRegistry agentSessionRegistry;
private final DeviceAgentCommandService agentCommandService;
@MessageMapping("/device/status")
public void receiveDeviceStatus(DeviceStatus payload) {
@@ -70,6 +81,8 @@ public class DeviceWebSocketController {
if (deviceId.isBlank()) {
return;
}
// 心跳同时刷新设备代理在线状态
agentSessionRegistry.registerOrHeartbeat(deviceId, null, null, null);
Map<String, Object> pong = new HashMap<>();
pong.put("cmd", "PONG_DEVICE");
pong.put("deviceId", deviceId);
@@ -91,6 +104,8 @@ public class DeviceWebSocketController {
commandPayload.put("sentAt", System.currentTimeMillis());
messagingTemplate.convertAndSendToUser(deviceId, "/queue/command", commandPayload);
// 匿名桌面同样订阅 /topic/device/{id}/cmd
messagingTemplate.convertAndSend("/topic/device/" + deviceId + "/cmd", commandJson);
log.info("下发设备指令成功, deviceId={}", deviceId);
Map<String, Object> result = new HashMap<>();
@@ -98,4 +113,87 @@ public class DeviceWebSocketController {
result.put("queued", true);
return Result.OK(result);
}
//update-begin---author:cursor ---date:2026-07-20 for【设备库桥接】代理状态/回执/在线列表-----------
/**
* 桌面代理上报在线与设备库连通状态。
* destination: /app/device/agent/status
*/
@MessageMapping("/device/agent/status")
public void receiveAgentStatus(@org.springframework.messaging.handler.annotation.Payload(required = false) Map<String, Object> payload) {
if (payload == null) {
return;
}
String deviceId = String.valueOf(payload.getOrDefault("deviceId", "")).trim();
if (deviceId.isBlank()) {
return;
}
String hostName = payload.get("hostName") == null ? null : String.valueOf(payload.get("hostName"));
Boolean dbConnected = null;
if (payload.get("dbConnected") instanceof Boolean) {
dbConnected = (Boolean) payload.get("dbConnected");
} else if (payload.get("dbConnected") != null) {
dbConnected = Boolean.parseBoolean(String.valueOf(payload.get("dbConnected")));
}
String dbMessage = payload.get("dbMessage") == null ? null : String.valueOf(payload.get("dbMessage"));
agentSessionRegistry.registerOrHeartbeat(deviceId, hostName, dbConnected, dbMessage);
messagingTemplate.convertAndSend("/topic/device/" + deviceId + "/status", payload);
log.debug("设备代理状态已更新 deviceId={} dbConnected={}", deviceId, dbConnected);
}
/**
* 桌面代理回传设备库命令结果。
* destination: /app/device/agent/result
*/
@MessageMapping("/device/agent/result")
public void receiveAgentResult(@org.springframework.messaging.handler.annotation.Payload(required = false) Map<String, Object> payload) {
if (payload == null) {
return;
}
DeviceAgentResult result = JSON.parseObject(JSON.toJSONString(payload), DeviceAgentResult.class);
agentCommandService.completeResult(result);
}
@GetMapping("/agent/online")
public Result<List<DeviceAgentSession>> listOnlineAgents() {
return Result.OK(agentSessionRegistry.listOnline());
}
@GetMapping("/agent/list")
public Result<List<DeviceAgentSession>> listAgents() {
return Result.OK(agentSessionRegistry.listAll());
}
@PostMapping("/agent/test")
public Result<DeviceAgentResult> testAgentDb(@RequestBody(required = false) Map<String, Object> body) {
String deviceId = body == null ? null : String.valueOf(body.getOrDefault("deviceId", "")).trim();
if (deviceId == null || deviceId.isBlank()) {
DeviceAgentSession picked = agentSessionRegistry.pickOnlineAgent(null);
if (picked == null) {
return Result.error("无在线桌面代理");
}
deviceId = picked.getDeviceId();
}
try {
return Result.OK(agentCommandService.test(deviceId, 10_000));
} catch (Exception e) {
return Result.error(e.getMessage());
}
}
@PostMapping("/agent/command")
public Result<DeviceAgentResult> sendAgentCommand(@RequestBody DeviceAgentCommand command) {
try {
return Result.OK(agentCommandService.sendAndWait(command));
} catch (Exception e) {
return Result.error(e.getMessage());
}
}
@GetMapping("/agent/status")
public Result<DeviceAgentSession> agentStatus(@RequestParam(name = "deviceId", required = false) String deviceId) {
DeviceAgentSession session = agentSessionRegistry.pickOnlineAgent(deviceId);
return Result.OK(session);
}
//update-end---author:cursor ---date:2026-07-20 for【设备库桥接】代理状态/回执/在线列表-----------
}