fix: 修复 GET 接口返回 toString 而非 JSON 的关键缺陷 + 测试库联调修复 + 日志实时推送

关键修复:
- 引入 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 13:59:54 +08:00
co-authored by Claude
parent 535162e63a
commit 545fa38789
9 changed files with 180 additions and 32 deletions
+6
View File
@@ -147,6 +147,12 @@
<artifactId>snack3</artifactId>
</dependency>
<!-- Solon JSON 渲染插件(HTTP 响应自动序列化为 JSON) -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-serialization-snack3</artifactId>
</dependency>
<!-- ========== 测试 ========== -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
@@ -43,6 +43,7 @@ object AgentMain {
// 优雅停机钩子
Runtime.getRuntime().addShutdownHook(Thread {
log.info("Agent 停机中...")
dispatcher.shutdownLogStream()
guard.stop()
client.stop()
})
@@ -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 "重启请求失败")
@@ -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<String, Int>()
/** 运行中的流:文件名 → 是否激活 */
private val active = ConcurrentHashMap<String, Boolean>()
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<String, Any?> {
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)
}
}
}
@@ -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<String> {
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"))
}
@@ -28,8 +28,12 @@ class OrderApi {
/** 4. 提交预约保存 */
@Mapping(value = "/saveOrder", method = [MethodType.POST])
fun saveOrder(@Body body: Map<String, Any?>): Result<String> =
Result.success(service.saveOrder(body))
fun saveOrder(@Body body: String): Result<String> {
// Solon 对 Map 泛型 body 反序列化支持不佳,用 snack3 手动解析
@Suppress("UNCHECKED_CAST")
val map: Map<String, Any?> = org.noear.snack.ONode.load(body).toObject(Map::class.java) as Map<String, Any?>
return Result.success(service.saveOrder(map))
}
/** 5. 取消体检预约 */
@Mapping(value = "/cancelOrder/{peId}", method = [MethodType.POST])
@@ -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>
<select id="getSuggest" resultType="java.util.Map">
select PE_ID, PE_VISIT_ID, REC_CONTENT, REPLACE(REPLACE(REPLACE(GUIDE_CONTENT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') GUIDE_CONTENT
select PE_ID, PE_VISIT_ID, REC_CONTENT, REPLACE(REPLACE(REPLACE(DBMS_LOB.SUBSTR(GUIDE_CONTENT, 4000, 1), chr(13) || chr(10), ''), chr(13), ''), chr(10), '') GUIDE_CONTENT
from V_PHYEXAM_DIAGNOSIS
where PE_ID = #{peId}
and GUIDE_CONTENT is not null
</select>
<select id="getSuggestAll" resultType="java.util.HashMap">
select PE_ID, PE_VISIT_ID, REC_CONTENT, REPLACE(REPLACE(REPLACE(GUIDE_CONTENT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') GUIDE_CONTENT
select PE_ID, PE_VISIT_ID, REC_CONTENT, REPLACE(REPLACE(REPLACE(DBMS_LOB.SUBSTR(GUIDE_CONTENT, 4000, 1), chr(13) || chr(10), ''), chr(13), ''), chr(10), '') GUIDE_CONTENT
from (select a.pe_id,
a.pe_visit_id,
a.rec_content,
@@ -123,7 +123,7 @@
DEPARTMENT,
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
(case when Finished_sign = '1' then '完成' when Finished_sign != '1' then '未完成' end) FINISHED_SIGN,
(case when Finished_sign = '1' then N'完成' when Finished_sign != '1' then N'未完成' end) FINISHED_SIGN,
CONVERT ( VARCHAR ( 100 ), PE_DATE, 20 ) PE_DATE
from V_PHYEXAM_PE_PERSONAL_ITEM
where PE_ID = #{peId}
@@ -149,7 +149,7 @@
'' PE_VISIT_ID,
PE_DEPT_CODE,
PE_DEPT_NAME,
REPLACE(REPLACE(REPLACE(REPLACE(cast(CONCLUSION_TEXT as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT
REPLACE(REPLACE(REPLACE(REPLACE(CONCLUSION_TEXT,CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT
from V_PHYEXAM_DEPT_RESULT
where PE_ID = #{peId}
and CONCLUSION_TEXT is not null
@@ -159,7 +159,7 @@
'' PE_VISIT_ID,
PE_DEPT_CODE,
PE_DEPT_NAME,
REPLACE(REPLACE(REPLACE(REPLACE(cast(CONCLUSION_TEXT as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT
REPLACE(REPLACE(REPLACE(REPLACE(CONCLUSION_TEXT,CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT
from V_PHYEXAM_DEPT_RESULT
where PE_ID = #{peId}
and CONCLUSION_TEXT is not null
@@ -172,7 +172,7 @@
REPLACE(
REPLACE(
REPLACE(
REPLACE(REPLACE(CAST (GUIDE_CONTENT AS VARCHAR (8000)), CHAR(10), '&lt;br/&gt;'), ' ', ''),
REPLACE(REPLACE(GUIDE_CONTENT, CHAR(10), '&lt;br/&gt;'), ' ', ''),
CHAR(9),''
), CHAR(13), ''
), '&lt;br/&gt;&lt;br/&gt;','&lt;br/&gt;'
@@ -190,7 +190,7 @@
REPLACE(
REPLACE(
REPLACE(
REPLACE(REPLACE(CAST (GUIDE_CONTENT AS VARCHAR (8000)), CHAR(10), '&lt;br/&gt;'), ' ', ''),
REPLACE(REPLACE(GUIDE_CONTENT, CHAR(10), '&lt;br/&gt;'), ' ', ''),
CHAR(9),''
), CHAR(13), ''
), '&lt;br/&gt;&lt;br/&gt;','&lt;br/&gt;'
@@ -68,17 +68,17 @@
(
CASE
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '4' THEN
'终审'
N'终审'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '3' THEN
'初审'
N'初审'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '2' THEN
'检中'
N'检中'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '1' THEN
'报道'
N'报道'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '0' THEN
'预约'
N'预约'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) IS NULL THEN
'预约'
N'预约'
END
) PE_STATUS
FROM
@@ -120,7 +120,7 @@
DWBM DEPARTMENT,
TJZHXBM ITEM_ASSEM_CODE,
TJZHXMC ITEM_ASSEM_NAME,
(case when WCZT = '1' then '完成' when WCZT != '1' then '未完成' end) FINISHED_SIGN,
(case when WCZT = '1' then N'完成' when WCZT != '1' then N'未完成' end) FINISHED_SIGN,
CONVERT ( VARCHAR ( 100 ), JCWCSJ, 20 ) PE_DATE
from V_YGYYTJZHX
where TJH = #{peId}
@@ -162,14 +162,14 @@
<select id="getConclusion" resultType="java.util.Map">
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>
<select id="getConclusionAll" resultType="java.util.Map">
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
@@ -183,16 +183,16 @@
<select id="getSuggest" resultType="java.util.Map">
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
</select>
<select id="getSuggestAll" resultType="java.util.Map">
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
@@ -214,17 +214,17 @@
(
CASE
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '4' THEN
'终审'
N'终审'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '3' THEN
'初审'
N'初审'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '2' THEN
'检中'
N'检中'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '1' THEN
'报道'
N'报道'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '0' THEN
'预约'
N'预约'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) IS NULL THEN
'预约'
N'预约'
END
) PE_STATUS
FROM