diff --git a/.gitignore b/.gitignore
index 0296a22..d18a342 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,28 +1,57 @@
-# ---> Kotlin
-# Compiled class file
+# 编译产物
+target/
*.class
-
-# Log file
-*.log
-
-# BlueJ files
-*.ctxt
-
-# Mobile Tools for Java (J2ME)
-.mtj.tmp/
-
-# Package Files #
*.jar
-*.war
-*.nar
-*.ear
-*.zip
-*.tar.gz
-*.rar
-# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
-hs_err_pid*
-replay_pid*
+# 运行时目录(本地联调用,不入 Git)
+bin/
+conf/
+pid/
+log/
+logs/
+tmp/
+backup/
-# Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects
+# IDE
+.idea/
+*.iml
+*.iws
+*.ipr
+.vscode/
+.project
+.classpath
+.settings/
+
+# 系统文件
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# 日志
+*.log
+logs/
+
+# 外置配置(部署时单独注入,不入 Git)
+pac/conf/*.yml
+pac/log/
+pac/backup/
+pac/pid/
+pac/tmp/
+
+# 临时文件
+*.tmp
+*.bak
+*.swp
+*~
+
+# Maven
+.mvn/
+mvnw
+mvnw.cmd
+
+# Kotlin
.kotlin/
+
+# JDK
+*.jdk
+*.jre
\ No newline at end of file
diff --git a/README.md b/README.md
index 8749714..0afc65c 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,145 @@
-# hospital-front
+# Hospital Front — 医院前置机程序
-医院前置机程序
\ No newline at end of file
+> 基于 Kotlin + Solon 4.0.6 重构的医院前置机对接系统
+>
+> 重构源:[hospitalmiddle](../hospitalmiddle)(SpringBoot 2.7 + MyBatis-Plus + Oracle/SqlServer)
+>
+> 完整方案:[E:\data\ob_data\myob\11 健康长庆\医院前置机重构\](../ob_data)
+
+---
+
+## 1. 项目简介
+
+体检预约系统通过本程序与多家医院 HIS 系统对接,实现:
+- 体检预约的提交、取消
+- 体检项目查询、状态查询
+- 体检结果/结论/建议的获取
+- 单位信息同步
+
+**支持 5 家医院**(拼音编码):
+| 编码 | 医院名 | 数据库 |
+|---|---|---|
+| `xinglongyuan` | 兴隆园医院 | Oracle |
+| `qingyang` | 庆阳医院 | Oracle |
+| `ningxia` | 宁夏宝石花医院 | SqlServer |
+| `yanan` | 延安医院 | SqlServer |
+| `jinghe` | 泾河医院 | Oracle |
+
+---
+
+## 2. 技术栈
+
+- **语言**:Kotlin 2.0.21(`jvmTarget=17`)
+- **框架**:Solon 4.0.6(国产轻量级 Java 框架)
+- **JDK**:17 LTS(推荐)或 21 LTS
+- **ORM**:MyBatis-Plus 3.5.9
+- **数据库**:Oracle / SqlServer(直连医院库,不可修改)
+- **日志**:Logback 1.5.12
+- **HTTP/WS**:OkHttp 4.12.0
+- **构建**:Maven 3.9+
+
+> ✅ **依赖瘦身**:不接入 Knife4j / Druid / Lombok / Jasypt(详见 [01-技术栈选型.md](../ob_data/01-技术栈选型.md))
+
+---
+
+## 3. 项目结构
+
+```
+hospital-front/
+├── pom.xml
+├── README.md
+├── .gitignore
+├── src/
+│ ├── main/
+│ │ ├── kotlin/com/hospital/front/
+│ │ │ ├── HospitalFrontApp.kt # @SolonMain 启动类
+│ │ │ ├── adapter/ # 医院适配器(策略模式)
+│ │ │ ├── api/ # HTTP 接口层(按业务域)
+│ │ │ ├── service/ # 业务服务层
+│ │ │ ├── mapper/ # 数据访问层
+│ │ │ ├── vo/ # 值对象
+│ │ │ ├── dto/ # 数据传输对象
+│ │ │ ├── infra/ # 基础设施
+│ │ │ └── task/ # 定时任务
+│ │ └── resources/
+│ │ ├── app.yml # Solon 主配置
+│ │ ├── logback.xml # 日志配置
+│ │ └── mapper/ # MyBatis XML
+│ └── test/kotlin/ # 单元测试
+└── pac/ # 部署包(运行时外置)
+ ├── bin/ # 启动脚本
+ ├── conf/ # 外置配置
+ ├── backup/ # 历史版本
+ ├── log/ # 日志
+ ├── pid/ # PID 文件
+ └── tmp/ # 临时文件
+```
+
+---
+
+## 4. 构建
+
+```bash
+mvn clean package -DskipTests
+# 产出:target/hospital-front-1.0.0.jar
+```
+
+---
+
+## 5. 启动(开发环境)
+
+```bash
+# 拷贝外置配置(首次启动)
+cp -r pac/conf-example pac/conf
+# 编辑配置
+vim pac/conf/app-jinghe.yml
+
+# 启动(默认加载 app.yml + conf/app-{HOSPITAL}.yml)
+HOSPITAL=jinghe java -jar target/hospital-front-1.0.0.jar
+```
+
+---
+
+## 6. 部署
+
+详细部署、升级、回滚 SOP:[05-部署运维.md](../ob_data/05-部署运维.md)
+
+**Windows Server 简版**:
+```batch
+D:
+cd D:\hospital-front\bin
+call start.bat start
+```
+
+---
+
+## 7. 文档
+
+完整设计文档位于:`E:\data\ob_data\myob\11 健康长庆\医院前置机重构\`
+
+| 文档 | 内容 |
+|---|---|
+| [00-总体方案.md](../ob_data/00-总体方案.md) | 整体方案 |
+| [01-技术栈选型.md](../ob_data/01-技术栈选型.md) | Solon 选型、版本、注解对照 |
+| [02-医院差异化矩阵.md](../ob_data/02-医院差异化矩阵.md) | 5 家医院差异、Adapter 拆分 |
+| [03-架构设计.md](../ob_data/03-架构设计.md) | 系统架构、模块、时序 |
+| [04-数据流与接口契约.md](../ob_data/04-数据流与接口契约.md) | 接口清单、入参出参 |
+| [05-部署运维.md](../ob_data/05-部署运维.md) | 部署、升级、回滚 SOP |
+| [06-变更记录.md](../ob_data/06-变更记录.md) | 所有变更登记 |
+
+---
+
+## 8. 任务进度
+
+| Step | 内容 | 状态 |
+|---|---|---|
+| 1 | 工程骨架 | ✅ |
+| 2 | 5 医院配置中心 | ⏳ |
+| 3 | 业务接口 Kotlin 重写 | ⏳ |
+| 4 | 5 套 Mapper 适配 | ⏳ |
+| 5 | 主程序被控端 API | ⏳ |
+| 6 | Agent 工程 | ⏳ |
+| 7 | 升级流程 | ⏳ |
+| 8 | 日志推送 | ⏳ |
+| 9 | B 端管理后台 | ⏳ |
+| 10 | 联调 + 文档 | ⏳ |
\ No newline at end of file
diff --git a/pac/conf-example/README.md b/pac/conf-example/README.md
new file mode 100644
index 0000000..169324e
--- /dev/null
+++ b/pac/conf-example/README.md
@@ -0,0 +1,49 @@
+# 外置配置示例(部署时拷贝到 conf\)
+
+> **使用流程**:
+> 1. 首次部署:把整个 `conf-example\` 目录拷贝为 `conf\`
+> ```
+> xcopy /E /I conf-example conf
+> ```
+> 2. 编辑 `conf\app-{编码}.yml`,把 `CHANGE_ME` 替换为真实密码(或通过环境变量注入)
+> 3. 根据实际部署环境修改 `hospital.id`、数据库 URL 等
+>
+> **本目录不入 Git**(已在 `.gitignore` 中声明 `pac/conf/*.yml`)。
+
+## 5 家医院配置清单
+
+| 医院 | 文件 | 数据库 | 默认端口 | `hm.type` |
+|---|---|---|---|---|
+| 兴隆园医院 | `app-xinglongyuan.yml` | Oracle | 8082 | `bsm` |
+| 庆阳医院 | `app-qingyang.yml` | Oracle | 8082 | `bsm` |
+| 宁夏宝石花医院 | `app-ningxia.yml` | SqlServer | 8082 | `nx` |
+| 延安医院 | `app-yanan.yml` | SqlServer | 8082 | `ya` |
+| 泾河医院 | `app-jinghe.yml` | Oracle | 8082 | `bsm` |
+
+## 切换医院
+
+```batch
+# 启动泾河(默认)
+bin\start.bat start
+
+# 启动其他医院
+set HOSPITAL=xinglongyuan
+bin\start.bat start
+```
+
+## 环境变量占位
+
+配置文件中使用 `${ENV_VAR:DEFAULT}` 占位语法,例如:
+```yaml
+password: ${DB_PWD_JINGHE:CHANGE_ME}
+```
+
+启动前设置环境变量(推荐,避免明文写密码):
+```batch
+set DB_PWD_JINGHE=ActualPassword
+bin\start.bat start
+```
+
+> **安全提示**:
+> 1. 部署后 `chmod 600` 整个 `conf\` 目录(Linux)或 `Set-Acl`(Windows PowerShell)
+> 2. **不要**把包含真实密码的 yml 提交到 Git
\ No newline at end of file
diff --git a/pac/conf-example/agent.properties b/pac/conf-example/agent.properties
new file mode 100644
index 0000000..6051a84
--- /dev/null
+++ b/pac/conf-example/agent.properties
@@ -0,0 +1,29 @@
+# ====================================
+# Agent 配置(部署时拷贝到 D:\hospital-front\conf\agent.properties)
+# Agent 启动命令:javaw -cp bin\hospital-front.jar com.hospital.agent.AgentMainKt
+# 环境变量可覆盖:AGENT_ 前缀 + 大写 + 下划线(如 AGENT_SERVER_URL)
+# ====================================
+
+# B 端管理服务器 WebSocket 地址
+server.url=ws://127.0.0.1:9091/ws
+
+# 设备编号(每台前置机唯一,建议按医院+序号命名)
+device.id=jinghe-01
+
+# 设备鉴权 Token(与 B 端注册信息一致)
+device.token=
+
+# 主程序健康检查地址
+main.health.url=http://127.0.0.1:8082/api/admin/health
+
+# 主程序管理接口 Token(与主程序 app.yml 的 admin.token 一致)
+main.admin.token=
+
+# 主程序启动命令(宕机拉起用;Windows 用 .bat,Linux 用 .sh)
+main.start.cmd=bin/start.bat
+
+# 心跳间隔(秒)
+heartbeat.sec=30
+
+# 是否守护主程序(宕机自动拉起)
+guard.enable=true
\ No newline at end of file
diff --git a/pac/conf-example/app-jinghe.yml b/pac/conf-example/app-jinghe.yml
new file mode 100644
index 0000000..1aee28e
--- /dev/null
+++ b/pac/conf-example/app-jinghe.yml
@@ -0,0 +1,18 @@
+# 泾河医院 → 本地 Oracle 测试库(mac204,bsm 方言)
+hospital:
+ active: jinghe
+hospital.id: 40289fa481b283a90181b340ec060001
+hm:
+ type: bsm
+ ver: V2
+ allsecondFlag: false
+ occFlag: false
+solon.dataSources:
+ db1!:
+ class: "com.zaxxer.hikari.HikariDataSource"
+ jdbcUrl: jdbc:oracle:thin:@192.168.50.204:1521/FREEPDB1
+ username: SYSTEM
+ password: ${DB_PWD:jyf@2024}
+ minimumIdle: 2
+ maximumPoolSize: 10
+ connectionTimeout: 30000
diff --git a/pac/conf-example/app-ningxia.yml b/pac/conf-example/app-ningxia.yml
new file mode 100644
index 0000000..731e331
--- /dev/null
+++ b/pac/conf-example/app-ningxia.yml
@@ -0,0 +1,18 @@
+# 宁夏宝石花医院 → 本地 SqlServer 测试库(mac204,nx 方言)
+hospital:
+ active: ningxia
+hospital.id: 402885e0788113ad01788124b3430007
+hm:
+ type: nx
+ ver: V2
+ allsecondFlag: true
+ occFlag: false
+solon.dataSources:
+ db1!:
+ class: "com.zaxxer.hikari.HikariDataSource"
+ jdbcUrl: jdbc:sqlserver://192.168.50.204:1433;DatabaseName=HospitalTest;trustServerCertificate=true;encrypt=false
+ username: sa
+ password: ${DB_PWD:jyf@2024}
+ driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
+ minimumIdle: 2
+ maximumPoolSize: 10
diff --git a/pac/conf-example/app-qingyang.yml b/pac/conf-example/app-qingyang.yml
new file mode 100644
index 0000000..17b61bb
--- /dev/null
+++ b/pac/conf-example/app-qingyang.yml
@@ -0,0 +1,32 @@
+# ====================================
+# 庆阳医院(先与 jinghe 一致,正式环境再调整) 配置
+# 文件路径:D:\hospital-front\conf\app-qingyang.yml
+# 启动:set HOSPITAL=qingyang && bin\start.bat start
+# ====================================
+
+hospital:
+ active: qingyang
+
+# 医院业务 ID(HIS 系统分配,正式环境核对)
+hospital.id: CHANGE_ME_HOSPITAL_ID
+
+# 业务开关
+hm:
+ type: bsm
+ ver: V2
+ allsecondFlag: false
+ occFlag: false
+
+# 数据源(HikariCP,Solon 默认连接池;db1! 中 ! 表示默认数据源)
+solon.dataSources:
+ db1!:
+ class: "com.zaxxer.hikari.HikariDataSource"
+ jdbcUrl: jdbc:oracle:thin:@CHANGE_ME:1521/ORCL
+ username: CHANGE_ME
+ password: ${DB_PWD:CHANGE_ME}
+ minimumIdle: 2
+ maximumPoolSize: 10
+ connectionTimeout: 30000
+
+# 驱动说明:Oracle 由 jdbcUrl 自动识别;SqlServer 需加 driverClassName
+ # Oracle 驱动按 jdbcUrl 自动识别
diff --git a/pac/conf-example/app-xinglongyuan.yml b/pac/conf-example/app-xinglongyuan.yml
new file mode 100644
index 0000000..5335525
--- /dev/null
+++ b/pac/conf-example/app-xinglongyuan.yml
@@ -0,0 +1,32 @@
+# ====================================
+# 兴隆园医院 配置
+# 文件路径:D:\hospital-front\conf\app-xinglongyuan.yml
+# 启动:set HOSPITAL=xinglongyuan && bin\start.bat start
+# ====================================
+
+hospital:
+ active: xinglongyuan
+
+# 医院业务 ID(HIS 系统分配,正式环境核对)
+hospital.id: CHANGE_ME_HOSPITAL_ID
+
+# 业务开关
+hm:
+ type: bsm
+ ver: V2
+ allsecondFlag: false
+ occFlag: false
+
+# 数据源(HikariCP,Solon 默认连接池;db1! 中 ! 表示默认数据源)
+solon.dataSources:
+ db1!:
+ class: "com.zaxxer.hikari.HikariDataSource"
+ jdbcUrl: jdbc:oracle:thin:@172.17.10.8:1521/ORCL
+ username: CHANGE_ME
+ password: ${DB_PWD:CHANGE_ME}
+ minimumIdle: 2
+ maximumPoolSize: 10
+ connectionTimeout: 30000
+
+# 驱动说明:Oracle 由 jdbcUrl 自动识别;SqlServer 需加 driverClassName
+ # Oracle 驱动按 jdbcUrl 自动识别
diff --git a/pac/conf-example/app-yanan.yml b/pac/conf-example/app-yanan.yml
new file mode 100644
index 0000000..26b350e
--- /dev/null
+++ b/pac/conf-example/app-yanan.yml
@@ -0,0 +1,18 @@
+# 延安医院 → 本地 SqlServer 测试库(mac204,ya 方言)
+hospital:
+ active: yanan
+hospital.id: 402885e0788113ad01788124b3430007
+hm:
+ type: ya
+ ver: V2
+ allsecondFlag: false
+ occFlag: false
+solon.dataSources:
+ db1!:
+ class: "com.zaxxer.hikari.HikariDataSource"
+ jdbcUrl: jdbc:sqlserver://192.168.50.204:1433;DatabaseName=HospitalTest;trustServerCertificate=true;encrypt=false
+ username: sa
+ password: ${DB_PWD:jyf@2024}
+ driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
+ minimumIdle: 2
+ maximumPoolSize: 10
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..3e442e1
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,235 @@
+
+
+ 4.0.0
+
+
+
+ org.noear
+ solon-parent
+ 3.10.7
+
+
+
+ com.hospital
+ hospital-front
+ 1.0.0
+ jar
+ hospital-front
+ 医院前置机程序(Kotlin + Solon 3.10.7)
+
+
+
+ 17
+ 17
+ 17
+ 2.0.21
+ 17
+
+
+ 23.3.0.23.09
+ 12.6.1.jre11
+ 3.46.1.0
+
+
+ 1.39.0
+
+
+
+
+
+ org.noear
+ solon
+
+
+
+
+ org.noear
+ solon-server-smarthttp
+
+
+
+
+ org.noear
+ solon-config-yaml
+
+
+
+
+ org.noear
+ solon-logging-logback-jakarta
+
+
+
+
+ org.noear
+ solon-scheduling
+
+
+
+
+
+ org.noear
+ mybatis-plus-solon-plugin
+
+
+ com.baomidou
+ mybatis-plus
+ 3.5.9
+
+
+
+
+
+ com.zaxxer
+ HikariCP
+ 5.1.0
+
+
+
+ com.oracle.database.jdbc
+ ojdbc11
+ ${ojdbc.version}
+
+
+
+
+ com.microsoft.sqlserver
+ mssql-jdbc
+ ${mssql-jdbc.version}
+
+
+
+
+ org.xerial
+ sqlite-jdbc
+ ${sqlite-jdbc.version}
+
+
+
+
+ com.h2database
+ h2
+ 2.3.232
+ runtime
+
+
+
+
+ com.squareup.okhttp3
+ okhttp
+
+
+
+
+ cn.dev33
+ sa-token-solon-plugin
+ ${sa-token.version}
+ true
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-stdlib
+ ${kotlin.version}
+
+
+
+
+ org.noear
+ snack3
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-test-junit5
+ ${kotlin.version}
+ test
+
+
+
+
+ hospital-front-${project.version}
+ ${project.basedir}/src/main/kotlin
+ ${project.basedir}/src/test/kotlin
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-plugin
+ ${kotlin.version}
+
+
+ spring
+
+ ${kotlin.jvmTarget}
+
+
+
+ compile
+
+ compile
+
+
+
+ test-compile
+
+ test-compile
+
+
+
+
+
+ org.jetbrains.kotlin
+ kotlin-maven-allopen
+ ${kotlin.version}
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.3
+
+
+ package
+
+ shade
+
+
+ false
+
+
+ com.hospital.front.HospitalFrontAppKt
+
+
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
+ hospital-front-${project.version}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/agent/AgentConfig.kt b/src/main/kotlin/com/hospital/agent/AgentConfig.kt
new file mode 100644
index 0000000..0c1bc19
--- /dev/null
+++ b/src/main/kotlin/com/hospital/agent/AgentConfig.kt
@@ -0,0 +1,56 @@
+package com.hospital.agent
+
+import java.io.File
+import java.util.Properties
+
+/**
+ * Agent 配置。
+ *
+ * 从 conf/agent.properties 读取(key=value),支持环境变量覆盖(AGENT_ 前缀大写)。
+ * 不依赖 Solon 容器,Agent 以纯 main 方式运行。
+ *
+ * 配置项:
+ * - server.url B 端 WebSocket 地址(ws://host:9091/ws)
+ * - device.id 设备编号(医院唯一,如 jinghe-01)
+ * - device.token 设备鉴权 Token
+ * - main.health.url 主程序健康检查地址(http://127.0.0.1:8082/api/admin/health)
+ * - main.admin.token 主程序管理接口 Token(与主程序 admin.token 一致)
+ * - main.start.cmd 主程序启动命令(Windows: bin\start.bat;Linux: bin/start.sh)
+ * - heartbeat.sec 心跳间隔(默认 30 秒)
+ * - guard.enable 是否守护主程序(默认 true)
+ */
+class AgentConfig private constructor(
+ val serverUrl: String,
+ val deviceId: String,
+ val deviceToken: String,
+ val mainHealthUrl: String,
+ val mainAdminToken: String,
+ val mainStartCmd: String,
+ val heartbeatSec: Long,
+ val guardEnabled: Boolean,
+) {
+ companion object {
+ /** 从 conf/agent.properties 加载,环境变量优先 */
+ fun load(confPath: String = "conf/agent.properties"): AgentConfig {
+ val props = Properties()
+ val file = File(confPath)
+ if (file.exists()) file.inputStream().use { props.load(it) }
+
+ fun get(key: String, def: String): String {
+ val envKey = "AGENT_" + key.replace(".", "_").uppercase()
+ return System.getenv(envKey) ?: props.getProperty(key) ?: def
+ }
+
+ return AgentConfig(
+ serverUrl = get("server.url", "ws://127.0.0.1:9091/ws"),
+ deviceId = get("device.id", "unknown-device"),
+ deviceToken = get("device.token", ""),
+ mainHealthUrl = get("main.health.url", "http://127.0.0.1:8082/api/admin/health"),
+ mainAdminToken = get("main.admin.token", ""),
+ mainStartCmd = get("main.start.cmd", "bin/start.bat"),
+ heartbeatSec = get("heartbeat.sec", "30").toLong(),
+ guardEnabled = get("guard.enable", "true") == "true",
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/agent/AgentMain.kt b/src/main/kotlin/com/hospital/agent/AgentMain.kt
new file mode 100644
index 0000000..ec61d78
--- /dev/null
+++ b/src/main/kotlin/com/hospital/agent/AgentMain.kt
@@ -0,0 +1,73 @@
+package com.hospital.agent
+
+import com.hospital.agent.cmd.CommandDispatcher
+import com.hospital.agent.cmd.UpgradeCommand
+import com.hospital.agent.ipc.MainAppClient
+import com.hospital.agent.process.ProcessGuard
+import com.hospital.agent.ws.AgentClient
+import org.slf4j.LoggerFactory
+import java.io.File
+
+/**
+ * Agent 启动入口(独立进程,不启动 Solon 容器)。
+ *
+ * 启动方式:
+ * java -cp hospital-front-1.0.0.jar com.hospital.agent.AgentMainKt
+ *
+ * 职责:
+ * 1. WebSocket 主动连接 B 端管理服务器(注册/心跳/命令)
+ * 2. 守护主程序(健康探测 + 宕机拉起)
+ * 3. 写 PID 文件(pid/hospital-agent.pid)
+ *
+ * 由 WinSW Windows 服务(HospitalAgent)守护本进程。
+ */
+object AgentMain {
+
+ private val log = LoggerFactory.getLogger(AgentMain::class.java)
+
+ fun run() {
+ val config = AgentConfig.load()
+ log.info("Agent 启动:deviceId={}, server={}", config.deviceId, config.serverUrl)
+ println("[Agent] deviceId=${config.deviceId}, server=${config.serverUrl}")
+
+ // 写 PID 文件
+ writePidFile()
+
+ // 组装组件(client 与 dispatcher 互相引用,通过 lateinit 解耦)
+ val mainAppClient = MainAppClient(config)
+ val dispatcher = CommandDispatcher(config, mainAppClient)
+ val client = AgentClient(config, dispatcher)
+ dispatcher.client = client
+ val guard = ProcessGuard(config, mainAppClient, client)
+
+ // 优雅停机钩子
+ Runtime.getRuntime().addShutdownHook(Thread {
+ log.info("Agent 停机中...")
+ guard.stop()
+ client.stop()
+ })
+
+ client.start()
+ guard.start()
+
+ // 主线程保活
+ Thread.currentThread().join()
+ }
+
+ private fun writePidFile() {
+ try {
+ val dir = File("pid")
+ if (!dir.exists()) dir.mkdirs()
+ File(dir, "hospital-agent.pid").writeText(ProcessHandle.current().pid().toString())
+ } catch (e: Exception) {
+ log.warn("PID 文件写入失败:{}", e.message)
+ }
+ }
+}
+
+/**
+ * 主入口(顶层函数,编译为 AgentMainKt 的静态 main)。
+ */
+fun main() {
+ AgentMain.run()
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/agent/cmd/CommandDispatcher.kt b/src/main/kotlin/com/hospital/agent/cmd/CommandDispatcher.kt
new file mode 100644
index 0000000..d102c50
--- /dev/null
+++ b/src/main/kotlin/com/hospital/agent/cmd/CommandDispatcher.kt
@@ -0,0 +1,99 @@
+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
+
+/**
+ * 下行命令分发器。
+ *
+ * B 端通过 WebSocket 下发 JSON 命令:{"type":"CMD_TYPE","payload":{...}}
+ *
+ * 已支持命令:
+ * - PING 探活(立即回 PONG)
+ * - GET_HEALTH 查询主程序健康
+ * - GET_VERSION 查询主程序版本
+ * - GET_LOG_FILES 日志文件列表
+ * - LOG_TAIL 日志尾部(payload: file, lines)
+ * - RESTART 重启主程序(优雅停机后由 ProcessGuard 拉起)
+ * - GET_CONFIG 读取医院配置文件(payload: hospital)
+ * - SAVE_CONFIG 写回医院配置文件(payload: hospital, content)
+ * - UPGRADE 升级主程序(Step 7 实现,当前返回未实现)
+ *
+ * 所有命令执行结果通过 AgentClient 上行(CMD_RESULT)。
+ */
+class CommandDispatcher(
+ val config: AgentConfig,
+ val mainAppClient: MainAppClient,
+) {
+ private val log = LoggerFactory.getLogger(CommandDispatcher::class.java)
+
+ /** 由 AgentMain 注入(避免构造环) */
+ lateinit var client: AgentClient
+
+ /** 升级命令(懒加载,依赖 client 注入完成后可用) */
+ private val upgradeCommand: UpgradeCommand by lazy {
+ UpgradeCommand(config, mainAppClient, client)
+ }
+
+ /** 分发并执行命令 */
+ fun dispatch(type: String, payloadJson: String?) {
+ log.info("收到命令:{}", type)
+ val payload = payloadJson?.let { runCatching { ONode.load(it) }.getOrNull() }
+
+ val result: Map = when (type) {
+ "PING" -> mapOf("pong" to true, "ts" to System.currentTimeMillis() / 1000)
+
+ "GET_HEALTH" -> mainAppClient.health()
+
+ "GET_VERSION" -> mainAppClient.version()
+
+ "GET_LOG_FILES" -> mapOf("ok" to true, "data" to mainAppClient.logFiles())
+
+ "LOG_TAIL" -> {
+ val file = payload?.get("file")?.getString() ?: "info.log"
+ val lines = payload?.get("lines")?.getInt() ?: 200
+ mapOf("ok" to true, "data" to mainAppClient.logTail(file, lines))
+ }
+
+ "RESTART" -> {
+ val ok = mainAppClient.restart()
+ mapOf("ok" to ok, "msg" to if (ok) "已触发优雅重启,等待 ProcessGuard 拉起" else "重启请求失败")
+ }
+
+ "GET_CONFIG" -> {
+ val hospital = payload?.get("hospital")?.getString() ?: ""
+ val content = mainAppClient.configFile(hospital)
+ mapOf("ok" to (content != null), "data" to content)
+ }
+
+ "SAVE_CONFIG" -> {
+ val hospital = payload?.get("hospital")?.getString() ?: ""
+ val content = payload?.get("content")?.getString() ?: ""
+ mapOf("ok" to mainAppClient.saveConfigFile(hospital, content))
+ }
+
+ "UPGRADE" -> {
+ // 异步执行(升级耗时长,避免阻塞 WS 消息循环)
+ val version = payload?.get("version")?.getString() ?: "unknown"
+ val downloadUrl = payload?.get("downloadUrl")?.getString()
+ val sha256 = payload?.get("sha256")?.getString()
+ if (downloadUrl.isNullOrBlank()) {
+ mapOf("ok" to false, "msg" to "缺少 downloadUrl")
+ } else {
+ Thread {
+ val result = upgradeCommand.upgrade(version, downloadUrl, sha256)
+ client.send("UPGRADE_RESULT", result)
+ }.apply { isDaemon = true }.start()
+ mapOf("ok" to true, "msg" to "升级已开始(异步执行,结果将通过 UPGRADE_RESULT 上报)")
+ }
+ }
+
+ else -> mapOf("ok" to false, "msg" to "未知命令:$type")
+ }
+
+ client.send("CMD_RESULT", mapOf("cmd" to type, "result" to result))
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/agent/cmd/UpgradeCommand.kt b/src/main/kotlin/com/hospital/agent/cmd/UpgradeCommand.kt
new file mode 100644
index 0000000..6c34ca3
--- /dev/null
+++ b/src/main/kotlin/com/hospital/agent/cmd/UpgradeCommand.kt
@@ -0,0 +1,296 @@
+package com.hospital.agent.cmd
+
+import com.hospital.agent.AgentConfig
+import com.hospital.agent.ipc.MainAppClient
+import com.hospital.agent.ws.AgentClient
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.slf4j.LoggerFactory
+import java.io.File
+import java.security.MessageDigest
+import java.util.concurrent.TimeUnit
+
+/**
+ * 主程序升级命令。
+ *
+ * 流程(见 05-部署运维.md SOP):
+ * 1. 从 B 端下载新 jar 到 tmp(SHA-256 校验,防传输损坏)
+ * 2. 备份当前 bin/hospital-front.jar → backup/hospital-front-{时间戳}.jar(保留最近 5 个)
+ * 3. 优雅停止主程序(调 /api/admin/restart;超时强杀)
+ * 4. 替换 jar
+ * 5. 调启动命令拉起主程序
+ * 6. 轮询健康检查(最长 90 秒)
+ * 7. 成功 → 上报;失败 → 用备份 jar 自动回滚并重启 → 上报
+ *
+ * 期间整个流程串行(upgradeLock 防并发升级)。
+ */
+class UpgradeCommand(
+ private val config: AgentConfig,
+ private val mainAppClient: MainAppClient,
+ private val client: AgentClient,
+) {
+ private val log = LoggerFactory.getLogger(UpgradeCommand::class.java)
+
+ private val http = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(120, TimeUnit.SECONDS) // 大文件下载
+ .build()
+
+ /** 升级互斥锁(同一时刻只允许一个升级在跑) */
+ private val upgradeLock = Any()
+
+ /** 备份保留数量 */
+ private val keepBackups = 5
+
+ /**
+ * 执行升级。
+ *
+ * @param version 新版本号(仅用于日志与上报)
+ * @param downloadUrl B 端提供的 jar 下载地址(A 主动出站拉取)
+ * @param sha256 可选校验值
+ */
+ fun upgrade(version: String, downloadUrl: String, sha256: String?): Map = synchronized(upgradeLock) {
+ log.info("开始升级到版本 {}:{}", version, downloadUrl)
+ client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "download"))
+
+ // ===== 1. 下载 =====
+ val tmpJar = File("tmp/upgrade-$version.jar")
+ tmpJar.parentFile?.mkdirs()
+ try {
+ http.newCall(Request.Builder().url(downloadUrl).get().build()).execute().use { resp ->
+ if (!resp.isSuccessful) return fail(version, "下载失败:HTTP ${resp.code}")
+ resp.body?.byteStream()?.use { input ->
+ tmpJar.outputStream().use { output -> input.copyTo(output) }
+ } ?: return fail(version, "下载失败:响应体为空")
+ }
+ } catch (e: Exception) {
+ return fail(version, "下载异常:${e.message}")
+ }
+ log.info("下载完成:{}({} 字节)", tmpJar.name, tmpJar.length())
+
+ // ===== 2. 校验 =====
+ if (sha256 != null) {
+ val actual = sha256File(tmpJar)
+ if (!actual.equals(sha256, ignoreCase = true)) {
+ tmpJar.delete()
+ return fail(version, "SHA-256 校验失败:expected=$sha256, actual=$actual")
+ }
+ log.info("SHA-256 校验通过")
+ }
+
+ // ===== 3. 备份 =====
+ val jarFile = File("bin/hospital-front.jar")
+ val backupFile = File("backup/hospital-front-${System.currentTimeMillis()}.jar")
+ backupFile.parentFile?.mkdirs()
+ if (jarFile.exists()) {
+ jarFile.copyTo(backupFile, overwrite = true)
+ log.info("已备份当前版本 → {}", backupFile.name)
+ cleanOldBackups()
+ }
+
+ // ===== 4. 停止主程序 =====
+ client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "stop"))
+ stopMainApp()
+ log.info("主程序已停止")
+
+ // ===== 5. 替换 =====
+ // 注意:Agent 自身以 -cp bin/hospital-agent.jar(主 jar 的副本)运行,
+ // 因此可以安全替换 bin/hospital-front.jar;替换后同步刷新 Agent 副本
+ // (Agent 副本在下次重启时生效,不影响本次升级)。
+ try {
+ copyWithRetry(tmpJar, jarFile)
+ tmpJar.delete()
+ copyWithRetry(jarFile, File("bin/hospital-agent.jar"))
+ } catch (e: Exception) {
+ // 替换失败(极少见):直接回滚
+ rollback(backupFile)
+ return fail(version, "替换 jar 失败,已回滚:${e.message}")
+ }
+ log.info("jar 已替换(含 Agent 副本)")
+
+ // ===== 6. 拉起 + 健康检查 =====
+ client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "restart"))
+ startMainApp()
+
+ client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "healthcheck"))
+ if (waitHealthy(90)) {
+ log.info("升级成功:{} ", version)
+ return mapOf(
+ "ok" to true,
+ "version" to version,
+ "msg" to "升级成功",
+ )
+ }
+
+ // ===== 7. 健康检查失败 → 回滚 =====
+ log.error("新版本健康检查失败,自动回滚到 {}", backupFile.name)
+ client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "rollback"))
+ stopMainApp()
+ rollback(backupFile)
+ startMainApp()
+ val recovered = waitHealthy(90)
+ return if (recovered) {
+ fail(version, "新版本启动失败,已自动回滚到 ${backupFile.name} 并恢复服务")
+ } else {
+ client.send("ALERT", mapOf(
+ "level" to "CRITICAL",
+ "msg" to "升级失败且回滚后仍不健康,请人工介入!版本=$version",
+ ))
+ fail(version, "升级失败且回滚后仍不健康,请人工介入")
+ }
+ }
+
+ // ================= 内部步骤 =================
+
+ /** 停止主程序:优先优雅(admin API),超时后按 PID 强杀;确保进程完全退出(jar 锁释放) */
+ private fun stopMainApp() {
+ val pid = readPidFile()
+ if (mainAppClient.restart(delayMs = 100)) {
+ // 等待端口关闭(最长 30 秒)
+ val deadline = System.currentTimeMillis() + 30_000
+ while (System.currentTimeMillis() < deadline) {
+ if (mainAppClient.health()["ok"] != true) break
+ Thread.sleep(1000)
+ }
+ }
+ // 兜底:按 PID 文件强杀
+ killByPidFile("pid/hospital-front.pid")
+ // 等待进程真正退出(Windows 下进程退出后才释放 jar 文件锁)
+ if (pid != null) waitProcessExit(pid, 15)
+ Thread.sleep(1000)
+ }
+
+ /** 读 PID 文件 */
+ private fun readPidFile(): String? = try {
+ val f = File("pid/hospital-front.pid")
+ if (f.exists()) f.readText().trim().ifEmpty { null } else null
+ } catch (_: Exception) {
+ null
+ }
+
+ /** 轮询等待进程退出(tasklist 查不到即退出) */
+ private fun waitProcessExit(pid: String, timeoutSec: Long) {
+ val os = System.getProperty("os.name").lowercase()
+ val deadline = System.currentTimeMillis() + timeoutSec * 1000
+ while (System.currentTimeMillis() < deadline) {
+ val alive = try {
+ if (os.contains("win")) {
+ ProcessBuilder("tasklist", "/FI", "PID eq $pid")
+ .start().inputStream.use { it.readBytes().toString(Charsets.UTF_8).contains(pid) }
+ } else {
+ ProcessBuilder("kill", "-0", pid).start().waitFor() == 0
+ }
+ } catch (_: Exception) {
+ false
+ }
+ if (!alive) return
+ Thread.sleep(500)
+ }
+ }
+
+ /** 拉起主程序 */
+ private fun startMainApp() {
+ try {
+ val cmd = config.mainStartCmd.replace("/", "\\") // cmd/c 要求反斜杠路径
+ val pb = if (cmd.endsWith(".bat", ignoreCase = true)) {
+ ProcessBuilder("cmd", "/c", cmd, "start")
+ } else {
+ ProcessBuilder("sh", cmd, "start")
+ }
+ pb.directory(File(".").absoluteFile)
+ pb.redirectErrorStream(true)
+ val proc = pb.start()
+ log.info("主程序拉起命令已执行,pid={}", proc.pid())
+ } catch (e: Exception) {
+ log.error("主程序拉起失败:{}", e.message)
+ }
+ }
+
+ /** 轮询等待主程序健康 */
+ private fun waitHealthy(timeoutSec: Long): Boolean {
+ val deadline = System.currentTimeMillis() + timeoutSec * 1000
+ while (System.currentTimeMillis() < deadline) {
+ if (mainAppClient.health()["ok"] == true) return true
+ Thread.sleep(3000)
+ }
+ return false
+ }
+
+ /** 回滚:备份 jar 覆盖回 bin(含 Agent 副本) */
+ private fun rollback(backupFile: File) {
+ try {
+ if (backupFile.exists()) {
+ val jarFile = File("bin/hospital-front.jar")
+ copyWithRetry(backupFile, jarFile)
+ copyWithRetry(jarFile, File("bin/hospital-agent.jar"))
+ log.info("已回滚到 {}", backupFile.name)
+ }
+ } catch (e: Exception) {
+ log.error("回滚失败:{}", e.message)
+ }
+ }
+
+ /** 带重试的文件覆盖(Windows 下进程退出有延迟,锁释放需等待) */
+ private fun copyWithRetry(src: File, dst: File, retries: Int = 10) {
+ var lastError: Exception? = null
+ repeat(retries) {
+ try {
+ if (dst.exists()) dst.delete()
+ if (!dst.exists()) {
+ src.copyTo(dst)
+ return
+ }
+ } catch (e: Exception) {
+ lastError = e
+ }
+ Thread.sleep(1000)
+ }
+ throw lastError ?: IllegalStateException("文件替换失败(重试 $retries 次):${dst.name}")
+ }
+
+ /** 清理旧备份(保留最近 N 个) */
+ private fun cleanOldBackups() {
+ val dir = File("backup")
+ val backups = dir.listFiles { f -> f.name.startsWith("hospital-front-") }
+ ?.sortedByDescending { it.name } ?: return
+ backups.drop(keepBackups).forEach { it.delete() }
+ }
+
+ /** 按 PID 文件强杀进程 */
+ private fun killByPidFile(pidFile: String) {
+ try {
+ val f = File(pidFile)
+ if (!f.exists()) return
+ val pid = f.readText().trim()
+ if (pid.isEmpty()) return
+ val os = System.getProperty("os.name").lowercase()
+ if (os.contains("win")) {
+ ProcessBuilder("taskkill", "/PID", pid, "/T", "/F").start().waitFor()
+ } else {
+ ProcessBuilder("kill", "-9", pid).start().waitFor()
+ }
+ } catch (e: Exception) {
+ log.warn("PID 强杀失败:{}", e.message)
+ }
+ }
+
+ /** 文件 SHA-256 */
+ private fun sha256File(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().use { input ->
+ val buf = ByteArray(8192)
+ while (true) {
+ val n = input.read(buf)
+ if (n < 0) break
+ digest.update(buf, 0, n)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ /** 统一失败上报 */
+ private fun fail(version: String, msg: String): Map {
+ log.error("升级失败:{}", msg)
+ return mapOf("ok" to false, "version" to version, "msg" to msg)
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/agent/ipc/MainAppClient.kt b/src/main/kotlin/com/hospital/agent/ipc/MainAppClient.kt
new file mode 100644
index 0000000..524de84
--- /dev/null
+++ b/src/main/kotlin/com/hospital/agent/ipc/MainAppClient.kt
@@ -0,0 +1,101 @@
+package com.hospital.agent.ipc
+
+import com.hospital.agent.AgentConfig
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.noear.snack.ONode
+import org.slf4j.LoggerFactory
+import java.util.concurrent.TimeUnit
+
+/**
+ * 主程序 IPC 客户端(环回 HTTP)。
+ *
+ * Agent 通过本机 HTTP 调用主程序被控端 API(/api/admin)。
+ */
+class MainAppClient(private val config: AgentConfig) {
+
+ private val log = LoggerFactory.getLogger(MainAppClient::class.java)
+
+ private val http = OkHttpClient.Builder()
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ /** 主程序健康检查:返回解析后的 JSON 字符串;不可达返回 {"ok":false,"error":"..."} */
+ fun health(): Map {
+ val body = getJson(config.mainHealthUrl) ?: return mapOf("ok" to false, "error" to "主程序不可达")
+ return try {
+ val node = ONode.load(body)
+ mapOf(
+ "ok" to true,
+ "code" to node.get("code").getInt(),
+ "data" to node.get("data")?.toJson(),
+ )
+ } catch (e: Exception) {
+ mapOf("ok" to false, "error" to "健康响应解析失败:${e.message}")
+ }
+ }
+
+ /** 主程序版本 */
+ fun version(): Map {
+ val body = getJson(url("/api/admin/version")) ?: return mapOf("ok" to false)
+ return try {
+ val node = ONode.load(body)
+ mapOf("ok" to true, "data" to node.get("data")?.toJson())
+ } catch (e: Exception) {
+ mapOf("ok" to false, "error" to e.message)
+ }
+ }
+
+ /** 日志尾部 */
+ fun logTail(file: String, lines: Int): String? =
+ getJson(url("/api/admin/log/tail?file=$file&lines=$lines"))
+
+ /** 日志文件列表 */
+ fun logFiles(): String? = getJson(url("/api/admin/log/files"))
+
+ /** 触发主程序优雅重启 */
+ fun restart(delayMs: Long = 1000): Boolean =
+ post(url("/api/admin/restart?delayMs=$delayMs"), "") != null
+
+ /** 读取医院配置文件 */
+ fun configFile(hospital: String): String? =
+ getJson(url("/api/admin/config/file?hospital=$hospital"))
+
+ /** 写回医院配置文件 */
+ fun saveConfigFile(hospital: String, content: String): Boolean {
+ val body = post(url("/api/admin/config/file"), "hospital=$hospital&content=$content")
+ return body != null
+ }
+
+ // ================= 基础 HTTP =================
+
+ private fun url(path: String): String {
+ val base = config.mainHealthUrl.substringBefore("/api/")
+ return base + path
+ }
+
+ private fun getJson(url: String): String? = try {
+ http.newCall(
+ Request.Builder().url(url)
+ .header("X-Admin-Token", config.mainAdminToken)
+ .get().build()
+ ).execute().use { resp -> if (resp.isSuccessful) resp.body?.string() else null }
+ } catch (e: Exception) {
+ log.debug("GET {} 失败:{}", url, e.message)
+ null
+ }
+
+ private fun post(url: String, formBody: String): String? = try {
+ http.newCall(
+ Request.Builder().url(url)
+ .header("X-Admin-Token", config.mainAdminToken)
+ .post(formBody.toRequestBody(null))
+ .build()
+ ).execute().use { resp -> if (resp.isSuccessful) resp.body?.string() else null }
+ } catch (e: Exception) {
+ log.debug("POST {} 失败:{}", url, e.message)
+ null
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/agent/process/ProcessGuard.kt b/src/main/kotlin/com/hospital/agent/process/ProcessGuard.kt
new file mode 100644
index 0000000..047f757
--- /dev/null
+++ b/src/main/kotlin/com/hospital/agent/process/ProcessGuard.kt
@@ -0,0 +1,93 @@
+package com.hospital.agent.process
+
+import com.hospital.agent.AgentConfig
+import com.hospital.agent.ipc.MainAppClient
+import com.hospital.agent.ws.AgentClient
+import org.slf4j.LoggerFactory
+import java.io.File
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * 主程序进程守护(看门狗)。
+ *
+ * 周期探测主程序健康:
+ * - 连续 3 次失败 → 认为主程序宕机 → 强杀残留 → 调启动命令拉起
+ * - 拉起后等待健康恢复;连续 3 次拉起失败 → 上报告警(ALERT)
+ */
+class ProcessGuard(
+ private val config: AgentConfig,
+ private val mainAppClient: MainAppClient,
+ private val client: AgentClient,
+) {
+ private val log = LoggerFactory.getLogger(ProcessGuard::class.java)
+
+ private val failCount = AtomicInteger(0)
+ private val restartCount = AtomicInteger(0)
+
+ private val scheduler = Executors.newSingleThreadScheduledExecutor()
+
+ /** 启动守护(默认 15 秒探测一次) */
+ fun start() {
+ if (!config.guardEnabled) {
+ log.info("进程守护未启用(guard.enable=false)")
+ return
+ }
+ scheduler.scheduleWithFixedDelay({ check() }, 30, 15, TimeUnit.SECONDS)
+ log.info("进程守护已启动:每 15 秒探测 {}", config.mainHealthUrl)
+ }
+
+ fun stop() {
+ scheduler.shutdownNow()
+ }
+
+ private fun check() {
+ val health = mainAppClient.health()
+ val ok = health["ok"] == true
+ if (ok) {
+ failCount.set(0)
+ return
+ }
+
+ val fails = failCount.incrementAndGet()
+ log.warn("主程序健康探测失败({}/3):{}", fails, health["error"])
+ if (fails < 3) return
+
+ // 连续 3 次失败 → 拉起
+ failCount.set(0)
+ val attempts = restartCount.incrementAndGet()
+ if (attempts > 3) {
+ client.send("ALERT", mapOf(
+ "level" to "CRITICAL",
+ "msg" to "主程序连续 $attempts 次拉起失败,请人工介入",
+ ))
+ // 重置计数,之后继续尝试(降频可后续优化)
+ restartCount.set(3)
+ return
+ }
+
+ log.error("主程序宕机,执行第 {} 次拉起:{}", attempts, config.mainStartCmd)
+ client.send("ALERT", mapOf(
+ "level" to "WARN",
+ "msg" to "主程序宕机,Agent 正在执行第 $attempts 次拉起",
+ ))
+ run {
+ try {
+ val cmd = config.mainStartCmd.replace("/", "\\") // cmd/c 要求反斜杠路径
+ val isBat = cmd.endsWith(".bat", ignoreCase = true)
+ val pb = if (isBat) {
+ ProcessBuilder("cmd", "/c", cmd, "start")
+ } else {
+ ProcessBuilder("sh", cmd, "start")
+ }
+ pb.directory(File(".").absoluteFile)
+ pb.redirectErrorStream(true)
+ val proc = pb.start()
+ log.info("启动命令已执行,pid={}", proc.pid())
+ } catch (e: Exception) {
+ log.error("启动命令执行失败:{}", e.message)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/agent/ws/AgentClient.kt b/src/main/kotlin/com/hospital/agent/ws/AgentClient.kt
new file mode 100644
index 0000000..1ce47ed
--- /dev/null
+++ b/src/main/kotlin/com/hospital/agent/ws/AgentClient.kt
@@ -0,0 +1,145 @@
+package com.hospital.agent.ws
+
+import com.hospital.agent.AgentConfig
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import org.noear.snack.ONode
+import org.slf4j.LoggerFactory
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Agent WebSocket 客户端。
+ *
+ * - 主动出站连接 B 端(穿透白名单,无需 A 端开放入站端口)
+ * - 断线指数退避重连(1s → 2s → 4s → ... 最大 60s)
+ * - 周期心跳:拉取主程序 /api/admin/health 并转发给 B 端
+ * - 收到下行命令交给 CommandDispatcher 处理
+ *
+ * 上行消息格式:{"type":"HEARTBEAT","deviceId":"...","ts":...,"payload":{...}}
+ */
+class AgentClient(
+ private val config: AgentConfig,
+ private val dispatcher: com.hospital.agent.cmd.CommandDispatcher,
+) {
+ private val log = LoggerFactory.getLogger(AgentClient::class.java)
+
+ private val client = OkHttpClient.Builder()
+ .pingInterval(25, TimeUnit.SECONDS) // 协议层心跳
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 不设读超时
+ .build()
+
+ private var webSocket: WebSocket? = null
+ private val connected = AtomicBoolean(false)
+ private val reconnectAttempts = AtomicInteger(0)
+ private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
+
+ /** 启动:连接 + 心跳调度 */
+ fun start() {
+ connect()
+ scheduler.scheduleWithFixedDelay(
+ { heartbeat() },
+ config.heartbeatSec, config.heartbeatSec, TimeUnit.SECONDS,
+ )
+ }
+
+ /** 停止并释放资源 */
+ fun stop() {
+ scheduler.shutdownNow()
+ webSocket?.close(1000, "agent stopping")
+ }
+
+ // ================= 连接管理 =================
+
+ private fun connect() {
+ if (connected.get()) return
+ log.info("连接管理服务器:{}", config.serverUrl)
+
+ val request = Request.Builder()
+ .url(config.serverUrl)
+ .header("X-Device-Id", config.deviceId)
+ .header("X-Device-Token", config.deviceToken)
+ .build()
+
+ webSocket = client.newWebSocket(request, object : WebSocketListener() {
+ override fun onOpen(ws: WebSocket, response: Response) {
+ connected.set(true)
+ reconnectAttempts.set(0)
+ log.info("已连接管理服务器")
+ send("REGISTER", mapOf(
+ "deviceId" to config.deviceId,
+ "version" to "1.0.0",
+ ))
+ }
+
+ override fun onMessage(ws: WebSocket, text: String) {
+ try {
+ val node = ONode.load(text)
+ val type = node.get("type").getString()
+ val payload = node.get("payload")?.toJson()
+ dispatcher.dispatch(type, payload)
+ } catch (e: Exception) {
+ log.warn("命令处理异常:{}", e.message)
+ }
+ }
+
+ override fun onClosed(ws: WebSocket, code: Int, reason: String) {
+ connected.set(false)
+ log.info("连接关闭:code={}, reason={}", code, reason)
+ scheduleReconnect()
+ }
+
+ override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
+ connected.set(false)
+ log.warn("连接失败:{},将重连", t.message)
+ scheduleReconnect()
+ }
+ })
+ }
+
+ /** 指数退避重连:1s 起,翻倍至 60s 封顶 */
+ private fun scheduleReconnect() {
+ val attempt = reconnectAttempts.incrementAndGet()
+ val delay = (1000L shl (attempt - 1).coerceAtMost(6)).coerceAtMost(60_000L)
+ log.info("{}ms 后进行第 {} 次重连", delay, attempt)
+ scheduler.schedule({ connect() }, delay, TimeUnit.MILLISECONDS)
+ }
+
+ // ================= 上行消息 =================
+
+ /** 心跳:拉主程序健康数据转发 */
+ private fun heartbeat() {
+ val mainHealth = dispatcher.mainAppClient.health()
+ send("HEARTBEAT", mapOf(
+ "deviceId" to config.deviceId,
+ "mainApp" to mainHealth,
+ ))
+ }
+
+ /** 发送上行消息(未连接时丢弃并记日志) */
+ fun send(type: String, payload: Any?) {
+ val ws = webSocket ?: run {
+ log.debug("未连接,丢弃上行消息:{}", type)
+ return
+ }
+ val json = ONode.load(
+ linkedMapOf(
+ "type" to type,
+ "deviceId" to config.deviceId,
+ "ts" to System.currentTimeMillis() / 1000,
+ "payload" to payload,
+ )
+ ).toJson()
+ ws.send(json)
+ }
+
+ /** 当前是否已连接(供健康状态展示) */
+ fun isConnected(): Boolean = connected.get()
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/front/HospitalFrontApp.kt b/src/main/kotlin/com/hospital/front/HospitalFrontApp.kt
new file mode 100644
index 0000000..31774c7
--- /dev/null
+++ b/src/main/kotlin/com/hospital/front/HospitalFrontApp.kt
@@ -0,0 +1,40 @@
+package com.hospital.front
+
+import org.noear.solon.Solon
+import org.noear.solon.annotation.SolonMain
+
+/**
+ * 医院前置机主程序启动类。
+ *
+ * 使用 Solon 3.x 框架,对外暴露体检预约业务接口(/order 路径)
+ * 与被控端接口(/api/admin 路径)。
+ *
+ * 启动命令示例:
+ * java -Xms256m -Xmx512m -Dsolon.env=jinghe -Dsolon.config.load=app.yml,conf/app-jinghe.yml -jar hospital-front-1.0.0.jar
+ */
+@SolonMain
+class HospitalFrontApp
+
+/**
+ * 主入口(顶层函数,编译为 HospitalFrontAppKt 的静态 main)。
+ */
+fun main(args: Array) {
+ Solon.start(HospitalFrontApp::class.java, args)
+ val pid = ProcessHandle.current().pid()
+ println("[HospitalFront] 启动完成,PID=$pid")
+ // 写 PID 文件(供 start.bat stop / Agent 升级流程强杀使用)
+ try {
+ val dir = java.io.File("pid")
+ if (!dir.exists()) dir.mkdirs()
+ java.io.File(dir, "hospital-front.pid").writeText(pid.toString())
+ } catch (_: Exception) {
+ }
+ // 注册停机钩子:清理 PID + 记录优雅退出日志
+ Runtime.getRuntime().addShutdownHook(Thread {
+ try {
+ java.io.File("pid/hospital-front.pid").delete()
+ } catch (_: Exception) {
+ }
+ println("[HospitalFront] 优雅停机中...")
+ })
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/front/adapter/BsmAdapterBase.kt b/src/main/kotlin/com/hospital/front/adapter/BsmAdapterBase.kt
new file mode 100644
index 0000000..55481a1
--- /dev/null
+++ b/src/main/kotlin/com/hospital/front/adapter/BsmAdapterBase.kt
@@ -0,0 +1,89 @@
+package com.hospital.front.adapter
+
+import com.hospital.front.dto.ProcResult
+import com.hospital.front.dto.Result
+import com.hospital.front.mapper.bsm.BsmOrderMapper
+import com.hospital.front.vo.UnitVo
+import org.noear.solon.annotation.Inject
+
+/**
+ * BSM 方言适配器基类(Oracle)。
+ *
+ * 适用医院:xinglongyuan(兴隆园)/ jinghe(泾河)/ qingyang(庆阳)。
+ * 由具体医院子类提供 code / displayName,其余行为完全一致。
+ */
+abstract class BsmAdapterBase : HospitalAdapter {
+
+ /** 数据库 Mapper(Solon 按类型注入) */
+ @Inject
+ protected lateinit var mapper: BsmOrderMapper
+
+ override fun hmType(): String = "bsm"
+
+ override fun healthCheck(): Result = try {
+ Result.success(mapper.ping() == 1)
+ } catch (e: Exception) {
+ Result.error(Result.CODE_DB_UNREACHABLE, "数据库连接失败:${e.message}")
+ }
+
+ override fun saveOrder(peInfoStr: String, peItemStr: String): ProcResult {
+ val map = mutableMapOf("peInfoStr" to peInfoStr, "peItemStr" to peItemStr)
+ mapper.saveOrder(map)
+ return ProcResult(
+ resultCode = map["resultCode"]?.toString(),
+ errorMsg = map["errorMsg"]?.toString(),
+ peId = map["peId"]?.toString(),
+ )
+ }
+
+ override fun cancelOrder(peId: String, name: String, idcard: String): ProcResult {
+ val map = mutableMapOf("peId" to peId, "name" to name, "idcard" to idcard)
+ mapper.cancelOrder(map)
+ return ProcResult(
+ resultCode = map["resultCode"]?.toString(),
+ errorMsg = map["errorMsg"]?.toString(),
+ )
+ }
+
+ override fun addUnit(unit: UnitVo, useV2: Boolean): ProcResult {
+ val map = mutableMapOf(
+ "unitCode" to unit.unitCode,
+ "unitName" to unit.unitName,
+ "parentUnitCode" to unit.parentUnitCode,
+ "address" to unit.address,
+ "connecter1" to unit.connecter1,
+ "phone1" to unit.phone1,
+ "connecter2" to unit.connecter2,
+ "phone2" to unit.phone2,
+ )
+ if (useV2) mapper.addUnitV2(map) else mapper.addUnit(map)
+ return ProcResult(
+ resultCode = map["resultCode"]?.toString(),
+ errorMsg = map["errorMsg"]?.toString(),
+ )
+ }
+
+ override fun getPhyexamStatus(peIds: String) = mapper.getPhyexamStatus(peIds)
+ override fun getItemList() = mapper.getItemList()
+ override fun getAllItemList() = mapper.getAllItemList()
+ override fun getPeItemList() = mapper.getPeItemList()
+ override fun getItemInfo(peItemCode: String) = mapper.getItemInfo(peItemCode)
+ override fun getEmpItem(peId: String) = mapper.getEmpItem(peId)
+ override fun getResult(peId: String) = mapper.getResult(peId)
+ override fun getConclusion(peId: String) = mapper.getConclusion(peId)
+ override fun getConclusionAll(peId: String) = mapper.getConclusionAll(peId)
+ override fun getSuggest(peId: String) = mapper.getSuggest(peId)
+ override fun getSuggestAll(peId: String) = mapper.getSuggestAll(peId)
+ override fun getPeStatusByIdnoAndYear(idNo: String, year: String) = mapper.getPeStatusByIdnoAndYear(idNo, year)
+ override fun getResultByIdcardAndYear(idNo: String, year: String) = mapper.getResultByIdcardAndYear(idNo, year)
+ override fun getResultByIdcardAndYearAll(idNo: String, year: String) = mapper.getResultByIdcardAndYearAll(idNo, year)
+ override fun getResultByPeId(peId: String) = mapper.getResultByPeId(peId)
+ override fun getDisResultByAuditDate(auditDate: String) = mapper.getDisResultByAuditDate(auditDate)
+ override fun getItemByPeId(peId: String) = mapper.getItemByPeId(peId)
+ override fun getItemByPeIdAll(peId: String) = mapper.getItemByPeIdAll(peId)
+ override fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String) =
+ mapper.getResultByIdNoOrPeId(idNo, peId, auditYear)
+ override fun getPeIdAndDateByIdcard(idNo: String) = mapper.getPeIdAndDateByIdcard(idNo)
+ override fun getPeIdAndDateByIdcardList(idNo: String) = mapper.getPeIdAndDateByIdcardList(idNo)
+ override fun getExamListByDate(auditDate: String?) = mapper.getExamListByDate(auditDate)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/com/hospital/front/adapter/DevAdapter.kt b/src/main/kotlin/com/hospital/front/adapter/DevAdapter.kt
new file mode 100644
index 0000000..f835b60
--- /dev/null
+++ b/src/main/kotlin/com/hospital/front/adapter/DevAdapter.kt
@@ -0,0 +1,56 @@
+package com.hospital.front.adapter
+
+import com.hospital.front.dto.ProcResult
+import com.hospital.front.dto.Result
+import com.hospital.front.vo.UnitVo
+import org.noear.solon.annotation.Component
+
+/**
+ * 开发环境适配器(hospital.active=dev 时激活)。
+ *
+ * 不连接任何数据库:查询一律返回空列表,存储过程一律返回失败。
+ * 用于本机无医院数据库时的启动与联调。
+ */
+@Component
+class DevAdapter : HospitalAdapter {
+
+ override fun code(): String = "dev"
+
+ override fun displayName(): String = "开发环境(Mock)"
+
+ override fun hmType(): String = "dev"
+
+ override fun healthCheck(): Result = Result.success(true)
+
+ override fun saveOrder(peInfoStr: String, peItemStr: String): ProcResult =
+ ProcResult(resultCode = "0", errorMsg = "开发环境不支持预约提交")
+
+ override fun cancelOrder(peId: String, name: String, idcard: String): ProcResult =
+ ProcResult(resultCode = "0", errorMsg = "开发环境不支持取消预约")
+
+ override fun addUnit(unit: UnitVo, useV2: Boolean): ProcResult =
+ ProcResult(resultCode = "0", errorMsg = "开发环境不支持单位同步")
+
+ override fun getPhyexamStatus(peIds: String): List