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 @@