From 545fa38789de322b40fed197765b0bb535bba759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=9E=E9=BE=99=E5=88=9A?= Date: Fri, 21 Aug 2026 13:59:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20GET=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=BF=94=E5=9B=9E=20toString=20=E8=80=8C=E9=9D=9E=20J?= =?UTF-8?q?SON=20=E7=9A=84=E5=85=B3=E9=94=AE=E7=BC=BA=E9=99=B7=20+=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=BA=93=E8=81=94=E8=B0=83=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20+=20=E6=97=A5=E5=BF=97=E5=AE=9E=E6=97=B6=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关键修复: - 引入 solon-serialization-snack3 渲染插件:无 Accept 头的 GET 请求 之前返回对象 toString(非法响应),现统一输出标准 JSON - saveOrder/addUnit:Solon @Body Map 泛型反序列化为空,改收原始 String + snack3 手动解析 - Oracle CLOB 直接进 Map 显示为对象句柄:bsm XML 包 DBMS_LOB.SUBSTR - SqlServer 中文丢失:去掉 cast(NVARCHAR as varchar(8000)) 有损转换; XML 中文字面量加 N 前缀(状态字典/完成标记) - /api/admin/log/tail 解码异常:历史日志混 GBK 内容,改容错解码 新增: - LogStreamManager:日志实时推送(LOG_TAIL_CTRL 控制 + LOG_BATCH 增量上报,多文件并行、游标推进、滚动重置检测) - mock_b_server.py:最小 WS 模拟 B 端(联调工具) 实测(真实 Oracle/SqlServer 测试库): - 三方言(bsm/nx/ya)20 个业务接口全部通过 - saveOrder 存储过程:PE00100008 / NX00000003 / YA00000002 - 中文入库/查询/汇总全链路正常 - 日志流端到端:B 下发命令 → Agent 增量推送 → B 收到 LOG_BATCH Co-Authored-By: Claude --- pom.xml | 6 + .../kotlin/com/hospital/agent/AgentMain.kt | 1 + .../hospital/agent/cmd/CommandDispatcher.kt | 14 +++ .../hospital/agent/cmd/LogStreamManager.kt | 119 ++++++++++++++++++ .../com/hospital/front/api/admin/AdminApi.kt | 8 +- .../com/hospital/front/api/order/OrderApi.kt | 8 +- .../front/mapper/bsm/BsmOrderMapper.xml | 8 +- .../front/mapper/nx/NxOrderMapper.xml | 10 +- .../front/mapper/ya/YaOrderMapper.xml | 38 +++--- 9 files changed, 180 insertions(+), 32 deletions(-) create mode 100644 src/main/kotlin/com/hospital/agent/cmd/LogStreamManager.kt 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