Parcourir la source

feat(runtime): 客户端类型硬编码自报与本地配置远程接管
- 新增 ClientType.CODE 常量(MANUAL),心跳时由 JAR 自报,不再从配置读,防止串味
- ClientRuntimeAgent.start 签名去掉 type 参数,clientId 交由服务端拼装
- 心跳上报本地 config/config.properties 内容(currentConfigJson),便于服务端可视化和编辑
- 服务端下发 desiredConfigJson 时:备份 config.properties.bak → 合并同名 key(保留其他 key) → 静默重启生效
- 升级流程改用 vbs + wscript.exe 后台执行,消除黑窗残留与"找不到批处理文件"问题
- config-version.txt 由重启 vbs 在启动新 Java 前写入,避免"版本号更新但重启失败"的死循环
- 修 update() 升级后未更新 client-version.txt 的 bug(原逻辑会重复触发升级)
- 废弃 config/server-config.json,配置真源统一到 config.properties
- MesClient.java 调用点同步改成 3 参数

需在快捷方式/启动脚本里将 java 改为 javaw 以避免主控制台黑窗。

wangxichen il y a 23 heures
Parent
commit
53a57d2f49

+ 3 - 5
src/com/mes/ui/MesClient.java

@@ -5,11 +5,7 @@ import com.mes.component.MesRadio;
 import com.mes.component.MesWebView;
 import com.mes.netty.NettyClient;
 import com.mes.netty.ProtocolParam;
-import com.mes.util.DateLocalUtils;
-import com.mes.util.ErrorMsg;
-import com.mes.util.HttpUtils;
-import com.mes.util.JdbcUtils;
-import com.mes.util.ClientUpdateUtil;
+import com.mes.util.*;
 import javafx.embed.swing.JFXPanel;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -115,6 +111,8 @@ public class MesClient extends JFrame {
                     try{
                         //读文件配置
                         readProperty();
+                        // 客户端类型由 ClientType.CODE 硬编码(此处 MANUAL),不再由此处传入
+                        ClientRuntimeAgent.start(mes_server_ip, mes_gw, mes_line_sn);
 
                         // 显示界面
                         mesClientFrame = new MesClient();

+ 1 - 1
src/com/mes/ui/OprnoUtil.java

@@ -19,7 +19,7 @@ public class OprnoUtil {
             "激光打码", "框架总成焊接1", "框架总成焊接2", "人工补焊1", "框架气密",
             "框架激光切割正面", "框架激光切割反面", "套筒焊接", "人工补焊2", "总成检具检验",
             "黑件入库", "紧固件装配正面/反面", "框架涂胶", "冷板装配", "半成品气密",
-            "安装密封垫底护板(没底护板)", "整包气密", "液冷板气密", "等电位电阻测试", "CCD检测",
+            "安装密封垫底护板", "整包气密", "液冷板气密", "等电位电阻测试", "CCD检测",
             "包装入库"
     };
 

+ 235 - 0
src/com/mes/util/ClientRuntimeAgent.java

@@ -0,0 +1,235 @@
+package com.mes.util;
+
+import com.alibaba.fastjson2.JSONObject;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.Properties;
+import java.util.concurrent.*;
+
+/**
+ * 客户端运行状态代理。
+ * - 类型由 ClientType.CODE 硬编码自报
+ * - clientId 由服务端根据 (类型, IP) 决定
+ * - 心跳上报本地 config.properties 当前内容(currentConfigJson)
+ * - 服务端下发 desiredConfigJson 直接合并写回 config.properties(备份 .bak)→ 重启生效
+ * - 服务端下发 desiredJarVersion 与本地不一致 → 下载对应 jar → 备份 .bak → 替换 → 重启
+ */
+public final class ClientRuntimeAgent {
+    private static final ScheduledExecutorService EXEC = Executors.newSingleThreadScheduledExecutor(r -> {
+        Thread t = new Thread(r, "client-runtime-agent");
+        t.setDaemon(true);
+        return t;
+    });
+    private static volatile boolean updating;
+
+    private ClientRuntimeAgent() {}
+
+    public static void start(String serverIp, String station, String line) {
+        start(serverIp, station, line, "com.mes.ui.MesClient");
+    }
+
+    public static void start(final String serverIp, final String station, final String line, final String mainClass) {
+        if (serverIp == null || serverIp.trim().isEmpty()) return;
+        send(serverIp, station, line, mainClass);
+        EXEC.scheduleAtFixedRate(() -> send(serverIp, station, line, mainClass), 30, 30, TimeUnit.SECONDS);
+    }
+
+    private static void send(String serverIp, String station, String line, String mainClass) {
+        String type = ClientType.CODE;
+        try {
+            String body = "clientType=" + enc(type)
+                    + "&stationCode=" + enc(station)
+                    + "&lineSn=" + enc(line)
+                    + "&jarVersion=" + version()
+                    + "&configVersion=" + configVersion()
+                    + "&status=RUNNING"
+                    + "&statusMessage=" + enc("客户端运行中")
+                    + "&currentConfigJson=" + enc(readLocalConfigAsJson());
+            HttpURLConnection c = (HttpURLConnection) new URL("http://" + serverIp + ":8980/js/a/mes/clientRuntime/heartbeat").openConnection();
+            c.setRequestMethod("POST");
+            c.setConnectTimeout(5000);
+            c.setReadTimeout(10000);
+            c.setDoOutput(true);
+            c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8");
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+            c.setFixedLengthStreamingMode(bytes.length);
+            try (OutputStream out = c.getOutputStream()) { out.write(bytes); }
+            int code = c.getResponseCode();
+            System.out.println("[ClientRuntime] heartbeat type=" + type + " code=" + code);
+            if (code == 200) {
+                String resp = read(c.getInputStream());
+                System.out.println("[ClientRuntime] response=" + resp);
+                JSONObject data = JSONObject.parseObject(resp).getJSONObject("data");
+                if (data != null) {
+                    Long cv = data.getLong("desiredConfigVersion");
+                    String cfg = data.getString("desiredConfigJson");
+                    if (cv != null && cfg != null && !cfg.trim().isEmpty() && cv > configVersion()) {
+                        applyConfig(cv, cfg, mainClass);
+                        return; // applyConfig 会重启,不用继续处理 jar
+                    }
+                    Long jv = data.getLong("desiredJarVersion");
+                    if (jv != null && jv > version() && !updating) update(serverIp, type, jv, mainClass);
+                }
+            } else {
+                System.err.println("[ClientRuntime] heartbeat rejected code=" + code);
+            }
+            c.disconnect();
+        } catch (Exception e) {
+            System.err.println("[ClientRuntime] heartbeat/update failed: " + e.getMessage());
+        }
+    }
+
+    // 读本地 config/config.properties 转为 JSON 字符串
+    private static String readLocalConfigAsJson() {
+        Path p = Paths.get("config", "config.properties");
+        if (!Files.exists(p)) return "";
+        try {
+            Properties props = new Properties();
+            try (InputStream in = Files.newInputStream(p)) { props.load(in); }
+            StringBuilder sb = new StringBuilder("{");
+            boolean first = true;
+            for (String key : props.stringPropertyNames()) {
+                if (!first) sb.append(",");
+                first = false;
+                sb.append('"').append(esc(key)).append("\":\"").append(esc(props.getProperty(key))).append('"');
+            }
+            sb.append('}');
+            return sb.toString();
+        } catch (Exception e) {
+            return "";
+        }
+    }
+
+    /**
+     * 应用服务端下发的配置:合并到 config.properties(保留未下发的 key),备份 .bak,然后重启生效。
+     */
+    private static void applyConfig(long ver, String cfg, String mainClass) throws Exception {
+        JSONObject json;
+        try { json = JSONObject.parseObject(cfg); } catch (Exception e) { System.err.println("[ClientRuntime] invalid config json"); return; }
+        if (json == null || json.isEmpty()) return;
+
+        Path dir = Paths.get("config");
+        Files.createDirectories(dir);
+        Path pfile = dir.resolve("config.properties");
+        Properties props = new Properties();
+        if (Files.exists(pfile)) {
+            try (InputStream in = Files.newInputStream(pfile)) { props.load(in); }
+            Files.copy(pfile, dir.resolve("config.properties.bak"), StandardCopyOption.REPLACE_EXISTING);
+        }
+        // 合并:JSON 里的 key 覆盖到 properties
+        for (String key : json.keySet()) {
+            String val = json.getString(key);
+            if (val == null) continue;
+            props.setProperty(key, val);
+        }
+        try (OutputStream out = Files.newOutputStream(pfile)) {
+            props.store(out, "Updated by ClientRuntimeAgent " + new java.util.Date());
+        }
+        // 注意:config-version.txt 不在这里写,交给 restartSelf 的 vbs 在启动新 Java 前写
+        // 这样即使 restart 失败,version 也不会被更新,下次心跳仍会触发同一个 applyConfig
+        System.out.println("[ClientRuntime] config applied v=" + ver + ", restarting");
+        restartSelf(mainClass, ver);
+    }
+
+    /** 触发升级:下载新 jar → 备份 → 替换 → 重启 */
+    private static void update(String serverIp, String type, long target, String mainClass) throws Exception {
+        updating = true;
+        JSONObject root = JSONObject.parseObject(read(new URL("http://" + serverIp + ":8980/js/a/mes/clientVersion/ver?clientType=" + enc(type) + "&version=" + target).openStream()));
+        JSONObject data = root.getJSONObject("data");
+        if (data == null) throw new IOException("target version unavailable");
+        File jar = currentJar();
+        File download = new File(jar.getParentFile(), jar.getName() + ".download");
+        download(data.getString("path"), download);
+        if (download.length() == 0) throw new IOException("empty download");
+        Files.write(Paths.get("config", "client-version.txt"), String.valueOf(target).getBytes(StandardCharsets.UTF_8));
+
+        File vbs = new File(jar.getParentFile(), "client-update-" + System.currentTimeMillis() + ".vbs");
+        String jarPath = jar.getAbsolutePath();
+        String bakPath = jarPath + ".bak";
+        String downloadPath = download.getAbsolutePath();
+        String libGlob = new File(jar.getParentFile(), "lib").getAbsolutePath() + File.separator + "*";
+        String workDir = jar.getParentFile().getAbsolutePath();
+        try (Writer w = new OutputStreamWriter(new FileOutputStream(vbs), StandardCharsets.UTF_8)) {
+            w.write(
+                "Set sh = CreateObject(\"WScript.Shell\")\r\n" +
+                "Set fso = CreateObject(\"Scripting.FileSystemObject\")\r\n" +
+                "WScript.Sleep 2000\r\n" +
+                "If fso.FileExists(\"" + jarPath + "\") Then fso.CopyFile \"" + jarPath + "\", \"" + bakPath + "\", True\r\n" +
+                "fso.MoveFile \"" + downloadPath + "\", \"" + jarPath + "\"\r\n" +
+                "sh.CurrentDirectory = \"" + workDir + "\"\r\n" +
+                "sh.Run \"javaw -cp \"\"" + jarPath + ";" + libGlob + "\"\" " + mainClass + "\", 0, False\r\n" +
+                "fso.DeleteFile WScript.ScriptFullName\r\n"
+            );
+        }
+        new ProcessBuilder("wscript.exe", vbs.getAbsolutePath()).start();
+        System.exit(0);
+    }
+
+    /** 仅重启当前 jar,不替换文件(用于配置生效);vbs 在启动新 Java 前把 verToWrite 写入 config-version.txt */
+    private static void restartSelf(String mainClass, long verToWrite) throws Exception {
+        File jar = currentJar();
+        File vbs = new File(jar.getParentFile(), "client-restart-" + System.currentTimeMillis() + ".vbs");
+        String jarPath = jar.getAbsolutePath();
+        String libGlob = new File(jar.getParentFile(), "lib").getAbsolutePath() + File.separator + "*";
+        String workDir = jar.getParentFile().getAbsolutePath();
+        String verFile = new File(new File(workDir, "config"), "config-version.txt").getAbsolutePath();
+        try (Writer w = new OutputStreamWriter(new FileOutputStream(vbs), StandardCharsets.UTF_8)) {
+            w.write(
+                "Set sh = CreateObject(\"WScript.Shell\")\r\n" +
+                "Set fso = CreateObject(\"Scripting.FileSystemObject\")\r\n" +
+                "WScript.Sleep 2000\r\n" +
+                "Set f = fso.CreateTextFile(\"" + verFile + "\", True)\r\n" +
+                "f.Write \"" + verToWrite + "\"\r\n" +
+                "f.Close\r\n" +
+                "sh.CurrentDirectory = \"" + workDir + "\"\r\n" +
+                "sh.Run \"javaw -cp \"\"" + jarPath + ";" + libGlob + "\"\" " + mainClass + "\", 0, False\r\n" +
+                "fso.DeleteFile WScript.ScriptFullName\r\n"
+            );
+        }
+        new ProcessBuilder("wscript.exe", vbs.getAbsolutePath()).start();
+        System.exit(0);
+    }
+
+    private static void download(String u, File f) throws IOException {
+        HttpURLConnection c = (HttpURLConnection) new URL(u).openConnection();
+        c.setConnectTimeout(10000);
+        c.setReadTimeout(120000);
+        if (c.getResponseCode() != 200) throw new IOException("download failed");
+        try (InputStream in = c.getInputStream(); OutputStream out = new FileOutputStream(f)) {
+            byte[] b = new byte[8192];
+            int n;
+            while ((n = in.read(b)) != -1) out.write(b, 0, n);
+        } finally { c.disconnect(); }
+    }
+
+    private static File currentJar() throws Exception {
+        File f = new File(ClientRuntimeAgent.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+        if (!f.isFile()) throw new IOException("not running from jar");
+        return f;
+    }
+
+    private static long version() { return readLong("client-version.txt", 1); }
+    private static long configVersion() { return readLong("config-version.txt", 0); }
+    private static long readLong(String n, long d) {
+        try { return Long.parseLong(new String(Files.readAllBytes(Paths.get("config", n)), StandardCharsets.UTF_8).trim()); }
+        catch (Exception e) { return d; }
+    }
+    private static String enc(String s) {
+        try { return URLEncoder.encode(s == null ? "" : s, "UTF-8"); } catch (Exception e) { return ""; }
+    }
+    private static String esc(String s) {
+        if (s == null) return "";
+        return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
+    }
+    private static String read(InputStream in) throws IOException {
+        try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+            StringBuilder b = new StringBuilder();
+            String l;
+            while ((l = r.readLine()) != null) b.append(l);
+            return b.toString();
+        }
+    }
+}

+ 13 - 0
src/com/mes/util/ClientType.java

@@ -0,0 +1,13 @@
+package com.mes.util;
+
+/**
+ * 客户端类型常量。
+ * 每个客户端项目值不同,由 JAR 自报,不从本地配置读,防止串味。
+ *   mesclient-okng     → MANUAL
+ *   mesclient-qm       → QIMI
+ *   mesclient-op170    → RIVETING
+ */
+public final class ClientType {
+    public static final String CODE = "MANUAL";
+    private ClientType() {}
+}