diff --git a/pom.xml b/pom.xml index 3e442e1..6b1c899 100644 --- a/pom.xml +++ b/pom.xml @@ -147,6 +147,12 @@ snack3 + + + org.noear + solon-serialization-snack3 + + org.jetbrains.kotlin diff --git a/src/main/kotlin/com/hospital/agent/AgentMain.kt b/src/main/kotlin/com/hospital/agent/AgentMain.kt index ec61d78..52695e3 100644 --- a/src/main/kotlin/com/hospital/agent/AgentMain.kt +++ b/src/main/kotlin/com/hospital/agent/AgentMain.kt @@ -43,6 +43,7 @@ object AgentMain { // 优雅停机钩子 Runtime.getRuntime().addShutdownHook(Thread { log.info("Agent 停机中...") + dispatcher.shutdownLogStream() guard.stop() client.stop() }) diff --git a/src/main/kotlin/com/hospital/agent/cmd/CommandDispatcher.kt b/src/main/kotlin/com/hospital/agent/cmd/CommandDispatcher.kt index d102c50..4f9ddc6 100644 --- a/src/main/kotlin/com/hospital/agent/cmd/CommandDispatcher.kt +++ b/src/main/kotlin/com/hospital/agent/cmd/CommandDispatcher.kt @@ -38,6 +38,14 @@ class CommandDispatcher( UpgradeCommand(config, mainAppClient, client) } + /** 日志实时流管理(懒加载) */ + private val logStream: LogStreamManager by lazy { + LogStreamManager(config, mainAppClient, client) + } + + /** 日志实时流(供 AgentMain 停机时统一释放) */ + fun shutdownLogStream() = logStream.stopAll() + /** 分发并执行命令 */ fun dispatch(type: String, payloadJson: String?) { log.info("收到命令:{}", type) @@ -58,6 +66,12 @@ class CommandDispatcher( mapOf("ok" to true, "data" to mainAppClient.logTail(file, lines)) } + "LOG_TAIL_CTRL" -> { + val action = payload?.get("action")?.getString() ?: "start" + val file = payload?.get("file")?.getString() ?: "info.log" + logStream.control(action, file) + } + "RESTART" -> { val ok = mainAppClient.restart() mapOf("ok" to ok, "msg" to if (ok) "已触发优雅重启,等待 ProcessGuard 拉起" else "重启请求失败") diff --git a/src/main/kotlin/com/hospital/agent/cmd/LogStreamManager.kt b/src/main/kotlin/com/hospital/agent/cmd/LogStreamManager.kt new file mode 100644 index 0000000..9ccf03b --- /dev/null +++ b/src/main/kotlin/com/hospital/agent/cmd/LogStreamManager.kt @@ -0,0 +1,119 @@ +package com.hospital.agent.cmd + +import com.hospital.agent.AgentConfig +import com.hospital.agent.ipc.MainAppClient +import com.hospital.agent.ws.AgentClient +import org.noear.snack.ONode +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +/** + * 日志实时推送管理器。 + * + * B 端通过 LOG_TAIL_CTRL 命令开启/停止某设备某日志文件的实时流: + * - 开启后每 2 秒从主程序 /api/admin/log/tail 拉取增量行 + * - 新行打包为 LOG_BATCH 上报 B 端(B 端转发给浏览器展示) + * - 每个文件独立游标(已发送行数),支持多文件并行 + * + * 增量算法:tail(max(offset+FETCH, 400)) 行,取 offset 之后的部分; + * 日志滚动(文件变小/重置)时自动重置游标。 + */ +class LogStreamManager( + private val config: AgentConfig, + private val mainAppClient: MainAppClient, + private val client: AgentClient, +) { + private val log = LoggerFactory.getLogger(LogStreamManager::class.java) + + /** 单流状态:文件名 → 已发送行数 */ + private val cursors = ConcurrentHashMap() + + /** 运行中的流:文件名 → 是否激活 */ + private val active = ConcurrentHashMap() + + private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + + companion object { + /** 轮询间隔(秒) */ + private const val POLL_SEC = 2L + + /** 单次拉取上限(行) */ + private const val FETCH_MAX = 400 + } + + /** + * 控制命令入口。 + * + * @param action start / stop + * @param file 日志文件名(info.log / error.log) + */ + fun control(action: String, file: String): Map { + return when (action) { + "start" -> { + if (active.putIfAbsent(file, true) != null) { + return mapOf("ok" to true, "msg" to "流已存在:$file") + } + cursors.remove(file) + scheduler.scheduleWithFixedDelay({ poll(file) }, 0, POLL_SEC, TimeUnit.SECONDS) + log.info("日志流开启:{}", file) + mapOf("ok" to true, "msg" to "日志流已开启:$file") + } + "stop" -> { + active.remove(file) + cursors.remove(file) + log.info("日志流停止:{}", file) + mapOf("ok" to true, "msg" to "日志流已停止:$file") + } + else -> mapOf("ok" to false, "msg" to "未知 action:$action(支持 start/stop)") + } + } + + /** 停止全部流(Agent 停机时调用) */ + fun stopAll() { + active.clear() + cursors.clear() + scheduler.shutdownNow() + } + + /** + * 单轮增量拉取:取尾部行,比对游标,新行打包上报。 + */ + private fun poll(file: String) { + if (active[file] != true) return + try { + val offset = cursors[file] ?: 0 + // 多拉一段,保证能覆盖到 offset 之前的已有内容 + val fetch = (offset + 100).coerceAtLeast(FETCH_MAX) + val raw = mainAppClient.logTail(file, fetch) + log.debug("日志流轮询:file={}, offset={}, fetch={}, rawLen={}", file, offset, fetch, raw?.length ?: -1) + if (raw == null) return + val node = ONode.load(raw) + if (node.get("code").getInt() != 0) return + val content = node.get("data").getString() ?: return + if (content.isEmpty()) return + val lines = content.split('\n') + + // 日志滚动检测:行数比游标还少很多 → 文件已重置 + if (lines.size < offset) { + cursors[file] = 0 + return + } + if (lines.size <= offset) return // 无新行 + + val fresh = lines.drop(offset).filter { it.isNotBlank() } + cursors[file] = lines.size + if (fresh.isEmpty()) return + + client.send("LOG_BATCH", mapOf( + "file" to file, + "lines" to fresh, + "ts" to System.currentTimeMillis() / 1000, + )) + } catch (e: Exception) { + log.warn("日志流轮询失败({}):{}", file, e.message) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/api/admin/AdminApi.kt b/src/main/kotlin/com/hospital/front/api/admin/AdminApi.kt index 4b9ac31..563c061 100644 --- a/src/main/kotlin/com/hospital/front/api/admin/AdminApi.kt +++ b/src/main/kotlin/com/hospital/front/api/admin/AdminApi.kt @@ -160,13 +160,17 @@ class AdminApi { ) } - /** 日志尾部 N 行(默认 200) */ + /** 日志尾部 N 行(默认 200);容错解码(历史日志可能混有 GBK 内容) */ @Mapping("/log/tail") fun logTail(ctx: Context, @Param("file") file: String, @Param("lines") lines: Int?): Result { checkToken(ctx) val target = safeLogFile(file) val n = (lines ?: 200).coerceIn(1, 2000) - val all = Files.readAllLines(target.toPath(), StandardCharsets.UTF_8) + val decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPLACE) + val text = decoder.decode(java.nio.ByteBuffer.wrap(Files.readAllBytes(target.toPath()))).toString() + val all = text.split('\n') return Result.success(all.takeLast(n).joinToString("\n")) } diff --git a/src/main/kotlin/com/hospital/front/api/order/OrderApi.kt b/src/main/kotlin/com/hospital/front/api/order/OrderApi.kt index 97f71fc..b702cef 100644 --- a/src/main/kotlin/com/hospital/front/api/order/OrderApi.kt +++ b/src/main/kotlin/com/hospital/front/api/order/OrderApi.kt @@ -28,8 +28,12 @@ class OrderApi { /** 4. 提交预约保存 */ @Mapping(value = "/saveOrder", method = [MethodType.POST]) - fun saveOrder(@Body body: Map): Result = - Result.success(service.saveOrder(body)) + fun saveOrder(@Body body: String): Result { + // Solon 对 Map 泛型 body 反序列化支持不佳,用 snack3 手动解析 + @Suppress("UNCHECKED_CAST") + val map: Map = org.noear.snack.ONode.load(body).toObject(Map::class.java) as Map + return Result.success(service.saveOrder(map)) + } /** 5. 取消体检预约 */ @Mapping(value = "/cancelOrder/{peId}", method = [MethodType.POST]) diff --git a/src/main/resources/com/hospital/front/mapper/bsm/BsmOrderMapper.xml b/src/main/resources/com/hospital/front/mapper/bsm/BsmOrderMapper.xml index 4af715c..ca7a970 100644 --- a/src/main/resources/com/hospital/front/mapper/bsm/BsmOrderMapper.xml +++ b/src/main/resources/com/hospital/front/mapper/bsm/BsmOrderMapper.xml @@ -144,7 +144,7 @@ PE_VISIT_ID, PE_DEPT_CODE, PE_DEPT_NAME, - REPLACE(REPLACE(REPLACE(CONCLUSION_TEXT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') CONCLUSION_TEXT + REPLACE(REPLACE(REPLACE(DBMS_LOB.SUBSTR(CONCLUSION_TEXT, 4000, 1), chr(13) || chr(10), ''), chr(13), ''), chr(10), '') CONCLUSION_TEXT from V_PHYEXAM_DEPT_RESULT where PE_ID = #{peId} and CONCLUSION_TEXT is not null @@ -154,7 +154,7 @@ PE_VISIT_ID, PE_DEPT_CODE, PE_DEPT_NAME, - REPLACE(REPLACE(REPLACE(CONCLUSION_TEXT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') CONCLUSION_TEXT + REPLACE(REPLACE(REPLACE(DBMS_LOB.SUBSTR(CONCLUSION_TEXT, 4000, 1), chr(13) || chr(10), ''), chr(13), ''), chr(10), '') CONCLUSION_TEXT from (select a.pe_id, a.pe_visit_id, a.pe_dept_code, @@ -174,13 +174,13 @@ select TJH PE_ID,'' PE_VISIT_ID, - REPLACE(REPLACE(REPLACE(REPLACE(cast(TJJL as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT + REPLACE(REPLACE(REPLACE(REPLACE(TJJL,CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT from V_TJJY where TJH = #{peId} and TJJL is not null select TJH PE_ID,'' PE_VISIT_ID, - REPLACE(REPLACE(REPLACE(REPLACE(cast(TJJL as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') REC_CONTENT, - REPLACE(REPLACE(REPLACE(REPLACE(cast(TJJY as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') GUIDE_CONTENT + REPLACE(REPLACE(REPLACE(REPLACE(TJJL,CHAR(10),''), ' ', ''),char(9),''),char(13),'') REC_CONTENT, + REPLACE(REPLACE(REPLACE(REPLACE(TJJY,CHAR(10),''), ' ', ''),char(9),''),char(13),'') GUIDE_CONTENT from V_TJJY where TJH = #{peId} and TJJL is not null