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:
@@ -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), '<br/>'), ' ', ''),
|
||||
REPLACE(REPLACE(GUIDE_CONTENT, CHAR(10), '<br/>'), ' ', ''),
|
||||
CHAR(9),''
|
||||
), CHAR(13), ''
|
||||
), '<br/><br/>','<br/>'
|
||||
@@ -190,7 +190,7 @@
|
||||
REPLACE(
|
||||
REPLACE(
|
||||
REPLACE(
|
||||
REPLACE(REPLACE(CAST (GUIDE_CONTENT AS VARCHAR (8000)), CHAR(10), '<br/>'), ' ', ''),
|
||||
REPLACE(REPLACE(GUIDE_CONTENT, CHAR(10), '<br/>'), ' ', ''),
|
||||
CHAR(9),''
|
||||
), CHAR(13), ''
|
||||
), '<br/><br/>','<br/>'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user