package com.battery.device.bts; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; /** * 新威 BTS 对外接口的**远程命名管道**传输(ADR-0068)。 * *

现场实测的三条关键事实决定了这个类的形状: * *

    *
  1. 接口是命名管道,不是 TCP。协议文档的 TCP 小节在现场没有对应物; * 可达的是服务端命名管道 {@code \\.\pipe\NewareBtsAPI}。跨机访问它要走 SMB, * 所以这里用 {@code \\<主机>\pipe\NewareBtsAPI} 这个 Win32 路径, * 并在打开管道前先建一条 SMB 会话。
  2. *
  3. 一条连接只服务一条请求。实测在同一条连接上发第二条命令会永久挂住 * (不报错、不返回)。所以每次请求都新建连接、收到应答即关闭,不做长连接复用。
  4. *
  5. 报文结束符是纯 {@code \n\n}。文档里的 {@code #\r\n} 与 {@code #\r\n} 变体实测均无效 * (后者零响应)。这里按实测取 {@code \n\n}。
  6. *
* *

权限:调用方需管理员身份,且 SMB 会话用的是那台机器的本地管理员账号—— * 口令从本地凭据文件取({@link com.battery.device.api.credentials.LocalCredentials}),不进数据库、不进仓库。 * 会话由本类用 {@code net.exe use} 建立:Java 无法给命名管道传凭据, * 只有先有一条已认证的会话,路径才打得开。会话按"主机 + 账号 + 口令"缓存,进程内每条只建一次。 * *

只读边界:出站请求先过 {@link NewareBtsReadOnlyCommands} 的白名单, * 非只读命令在写进管道之前就被拒绝。 * *

读超时:{@code RandomAccessFile} 没有读超时(管道挂住就是永久阻塞), * 因此读取放在单线程执行器里、由调用方带超时等待;超时从另一个线程关掉在途句柄把读打断, * 使"挂住"变成一次可分类的失败,而不是把调用线程钉死。 */ public final class NewareBtsPipeTransport implements BtsTransport { /** 服务端命名管道的名字;跨机时路径为 {@code \\<主机>\pipe\<名字>}。 */ private static final String PIPE_NAME = "NewareBtsAPI"; /** 实测的报文结束符(协议文档的 {@code #\r\n} 在本接口上无效)。 */ private static final byte[] FRAME_END = "\n\n".getBytes(StandardCharsets.UTF_8); private static final int DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; private static final int MAX_SESSION_TIMEOUT_MS = 30_000; private static final int MAX_RESPONSE_TIMEOUT_MS = 120_000; private static final int READ_BUFFER_BYTES = 64 * 1024; /** 已建立的 SMB 会话;键为「主机 + 账号 + 口令」的摘要,进程内复用。 */ private static final Set ESTABLISHED_SESSIONS = ConcurrentHashMap.newKeySet(); private final String host; private final String user; private final String password; private final String pipePath; private final String sessionKey; private final int sessionTimeoutMs; private final int responseTimeoutMs; private final int maxResponseBytes; private final ReentrantLock requestLock = new ReentrantLock(true); /** 读取放在这里跑,使调用方能带超时等待;线程是守护线程,不拖住进程退出。 */ private final ExecutorService reader = Executors.newSingleThreadExecutor(runnable -> { Thread thread = new Thread(runnable, "neware-bts-pipe-reader"); thread.setDaemon(true); return thread; }); /** 当前在途的管道句柄:读超时时由调用线程关掉它,把阻塞中的读打断。 */ private volatile RandomAccessFile inFlight; public NewareBtsPipeTransport(String host, String user, String password, Duration sessionTimeout, Duration responseTimeout) { this(host, user, password, sessionTimeout, responseTimeout, DEFAULT_MAX_RESPONSE_BYTES); } NewareBtsPipeTransport(String host, String user, String password, Duration sessionTimeout, Duration responseTimeout, int maxResponseBytes) { this.host = requireText(host, "host"); this.user = requireText(user, "user"); this.password = Objects.requireNonNull(password, "password"); this.sessionTimeoutMs = timeout(sessionTimeout, MAX_SESSION_TIMEOUT_MS, "session"); this.responseTimeoutMs = timeout(responseTimeout, MAX_RESPONSE_TIMEOUT_MS, "response"); this.maxResponseBytes = maxResponseBytes; if (maxResponseBytes < 1) throw new IllegalArgumentException("maxResponseBytes must be positive"); this.pipePath = "\\\\" + this.host + "\\pipe\\" + PIPE_NAME; this.sessionKey = this.host + "|" + this.user + "|" + Integer.toHexString(this.password.hashCode()); } /** * 发出一条只读请求并返回原始应答。 * *

前置条件:请求只含只读白名单内的命令。并发调用由锁串行化(本接口一条连接只服务一条请求)。 * SMB 会话首次使用时建立;管道打开或读取失败时重建会话并重试一次,仍失败则归类抛出。 * * @param request 完整请求报文(不含结束符) * @return 应答原文(已去掉 {@code \n\n}) * @throws BtsTransportException 请求越权,或会话、管道、读取失败 */ @Override public String exchange(String request) { NewareBtsReadOnlyCommands.requireAllowed(request); requestLock.lock(); try { ensureSession(false); try { return perform(request); } catch (BtsTransportException failure) { // 会话可能已被对端或系统回收(长时间空闲、重启上位机):重建一次再试,只试一次。 if (!isSessionRecoverable(failure.reason())) throw failure; ensureSession(true); return perform(request); } } finally { requestLock.unlock(); } } private String perform(String request) { Future pending = reader.submit(() -> openAndExchange(request)); try { return pending.get(responseTimeoutMs, TimeUnit.MILLISECONDS); } catch (TimeoutException ex) { closeInFlight(); pending.cancel(true); throw new BtsTransportException(BtsTransportException.Reason.TIMEOUT, "BTS 管道应答超时(" + responseTimeoutMs + " ms):" + pipePath, ex); } catch (InterruptedException ex) { closeInFlight(); pending.cancel(true); Thread.currentThread().interrupt(); throw new BtsTransportException(BtsTransportException.Reason.IO_FAILURE, "BTS 管道读取被中断:" + pipePath, ex); } catch (ExecutionException ex) { Throwable cause = ex.getCause(); if (cause instanceof BtsTransportException failure) throw failure; throw new BtsTransportException(BtsTransportException.Reason.IO_FAILURE, "BTS 管道交换失败:" + pipePath, cause); } } /** 每次请求新建一条连接:实测同一连接上的第二条命令不会返回。 */ private String openAndExchange(String request) throws IOException { RandomAccessFile pipe; try { pipe = new RandomAccessFile(pipePath, "rw"); } catch (FileNotFoundException ex) { throw new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED, "无法打开新威 BTS 管道(接口未启用?未激活?无管理员凭据?):" + pipePath, ex); } inFlight = pipe; try { pipe.write((request + new String(FRAME_END, StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8)); return decode(readFrame(pipe, maxResponseBytes)); } catch (java.io.EOFException ex) { throw new BtsTransportException(BtsTransportException.Reason.DISCONNECTED, "新威 BTS 管道在应答结束前关闭:" + pipePath, ex); } catch (FrameTooLargeException ex) { throw new BtsTransportException(BtsTransportException.Reason.RESPONSE_TOO_LARGE, "新威 BTS 应答超过 " + maxResponseBytes + " 字节:" + pipePath, ex); } catch (InvalidUtf8Exception ex) { throw new BtsTransportException(BtsTransportException.Reason.INVALID_UTF8, "新威 BTS 应答不是合法 UTF-8:" + pipePath, ex); } finally { inFlight = null; try { pipe.close(); } catch (IOException ignored) { } } } /** * 按块读取并扫描 {@code \n\n},返回去掉结束符的应答字节。逐字节读在 SMB 上是一次读一次往返, * 而 {@code download} 一页最多上千行,所以必须成块读。 */ static byte[] readFrame(RandomAccessFile pipe, int maxPayloadBytes) throws IOException { byte[] chunk = new byte[READ_BUFFER_BYTES]; ByteArrayOutputStream received = new ByteArrayOutputStream(); int matched = 0; while (true) { int read = pipe.read(chunk); if (read < 0) throw new java.io.EOFException("新威 BTS 管道在应答结束前关闭"); for (int i = 0; i < read; i++) { byte value = chunk[i]; if (value == FRAME_END[matched]) { matched++; if (matched == FRAME_END.length) return received.toByteArray(); continue; } if (matched > 0) { // 已匹配到的结束符前缀是正文的一部分:原样补回,再按当前字节重判。 for (int prefix = 0; prefix < matched; prefix++) append(received, FRAME_END[prefix], maxPayloadBytes); matched = 0; } if (value == FRAME_END[0]) { matched = 1; continue; } append(received, value, maxPayloadBytes); } } } private static void append(ByteArrayOutputStream buffer, byte value, int maxPayloadBytes) throws FrameTooLargeException { if (buffer.size() >= maxPayloadBytes) throw new FrameTooLargeException(); buffer.write(value); } private static String decode(byte[] bytes) throws InvalidUtf8Exception { try { return StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT) .decode(ByteBuffer.wrap(bytes)).toString(); } catch (CharacterCodingException ex) { throw new InvalidUtf8Exception(ex); } } /** * 建立 SMB 会话。Java 无法向命名管道传凭据,所以只能先让系统用 {@code net.exe use} 认证一次, * 之后管道路径才打得开;这是"把人工那步 net use 收进后端"的落点。 * * @param force true 表示先撤掉旧会话再建(用于重试路径) */ private void ensureSession(boolean force) { if (force) { runNet("use", "\\\\" + host + "\\ipc$", "/delete", "/y"); ESTABLISHED_SESSIONS.remove(sessionKey); } if (!force && ESTABLISHED_SESSIONS.contains(sessionKey)) return; String output = runNet("use", "\\\\" + host + "\\ipc$", "/user:" + host + "\\" + user, password); if (output == null) throw new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED, "无法建立到 " + host + " 的 SMB 会话", null); ESTABLISHED_SESSIONS.add(sessionKey); } /** * 执行一条 net.exe 命令。 * *

返回值:成功返回输出;失败时若输出表明"该主机的会话已存在"(错误 1219/多重连接) * 也按成功处理——会话已经在了,管道照常可开。其余失败返回空值。 * 建立与撤销都失败不抛出,交给后续管道打开去报错,避免凭据问题被当成会话问题。 */ private String runNet(String... arguments) { String[] command = new String[arguments.length + 1]; command[0] = netExecutable(); System.arraycopy(arguments, 0, command, 1, arguments.length); try { Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); if (!process.waitFor(sessionTimeoutMs, TimeUnit.MILLISECONDS)) { process.destroyForcibly(); return null; } if (process.exitValue() == 0) return output; String normalized = output.toLowerCase(Locale.ROOT); if (normalized.contains("1219") || normalized.contains("多重连接") || normalized.contains("multiple connections")) return output; return null; } catch (IOException ex) { return null; } catch (InterruptedException ex) { Thread.currentThread().interrupt(); return null; } } private static String netExecutable() { String systemRoot = System.getenv("SystemRoot"); return (systemRoot == null || systemRoot.isBlank() ? "C:\\Windows" : systemRoot) + "\\System32\\net.exe"; } /** 会话或连接类失败值得重建会话再试一次;协议与内容类失败重试没有意义。 */ private static boolean isSessionRecoverable(BtsTransportException.Reason reason) { return reason == BtsTransportException.Reason.CONNECTION_FAILED || reason == BtsTransportException.Reason.DISCONNECTED || reason == BtsTransportException.Reason.IO_FAILURE; } /** 关掉在途句柄:这是唯一能打断阻塞中 ReadFile 的手段。 */ private void closeInFlight() { RandomAccessFile pipe = inFlight; if (pipe == null) return; try { pipe.close(); } catch (IOException ignored) { } } @Override public void close() { requestLock.lock(); try { closeInFlight(); } finally { requestLock.unlock(); } reader.shutdownNow(); } private static String requireText(String value, String field) { String normalized = Objects.requireNonNull(value, field).trim(); if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank"); return normalized; } private static int timeout(Duration value, int maximumMs, String field) { int millis = Math.toIntExact(Objects.requireNonNull(value, field).toMillis()); if (millis < 1) throw new IllegalArgumentException(field + " timeout must be positive"); if (millis > maximumMs) throw new IllegalArgumentException(field + " timeout must not exceed " + maximumMs + " ms"); return millis; } private static final class FrameTooLargeException extends IOException { private static final long serialVersionUID = 1L; } private static final class InvalidUtf8Exception extends IOException { private static final long serialVersionUID = 1L; private InvalidUtf8Exception(Throwable cause) { super(cause); } } }