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> = emptyList() + override fun getItemList(): List> = emptyList() + override fun getAllItemList(): List> = emptyList() + override fun getPeItemList(): List> = emptyList() + override fun getItemInfo(peItemCode: String): List> = emptyList() + override fun getEmpItem(peId: String): List> = emptyList() + override fun getResult(peId: String): List> = emptyList() + override fun getConclusion(peId: String): List> = emptyList() + override fun getConclusionAll(peId: String): List> = emptyList() + override fun getSuggest(peId: String): List> = emptyList() + override fun getSuggestAll(peId: String): List> = emptyList() + override fun getPeStatusByIdnoAndYear(idNo: String, year: String): Map? = null + override fun getResultByIdcardAndYear(idNo: String, year: String): List> = emptyList() + override fun getResultByIdcardAndYearAll(idNo: String, year: String): List> = emptyList() + override fun getResultByPeId(peId: String): List> = emptyList() + override fun getDisResultByAuditDate(auditDate: String): List> = emptyList() + override fun getItemByPeId(peId: String): List> = emptyList() + override fun getItemByPeIdAll(peId: String): List> = emptyList() + override fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String): List> = emptyList() + override fun getPeIdAndDateByIdcard(idNo: String): Map? = null + override fun getPeIdAndDateByIdcardList(idNo: String): List> = emptyList() + override fun getExamListByDate(auditDate: String?): List> = emptyList() +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/adapter/HospitalAdapter.kt b/src/main/kotlin/com/hospital/front/adapter/HospitalAdapter.kt new file mode 100644 index 0000000..aa3e68f --- /dev/null +++ b/src/main/kotlin/com/hospital/front/adapter/HospitalAdapter.kt @@ -0,0 +1,100 @@ +package com.hospital.front.adapter + +import com.hospital.front.dto.ProcResult +import com.hospital.front.dto.Result +import com.hospital.front.vo.OrderItemVo +import com.hospital.front.vo.OrderV2Vo +import com.hospital.front.vo.UnitVo + +/** + * 医院适配器接口(策略模式)。 + * + * 5 家医院各自实现本接口,通过 HospitalRouter 按医院编码路由。 + * 所有数据库差异(Oracle/SqlServer 方言、存储过程名)均封装在实现内; + * 存储过程的入参出参契约由医院 HIS 系统约定,不可修改。 + * + * 查询类方法统一返回 Map 列表(列名与医院视图/存储过程保持一致)。 + */ +interface HospitalAdapter { + + /** 医院编码(拼音):xinglongyuan / qingyang / ningxia / yanan / jinghe / dev */ + fun code(): String + + /** 医院中文名(日志与展示用) */ + fun displayName(): String + + /** Mapper 方言类型:bsm(Oracle)/ nx / ya(SqlServer) */ + fun hmType(): String + + /** 轻量健康检查(SELECT 1 探测) */ + fun healthCheck(): Result + + // ================= 存储过程(写) ================= + + /** 提交预约(P_ADD_PE_REGIST) */ + fun saveOrder(peInfoStr: String, peItemStr: String): ProcResult + + /** 取消预约(P_DEL_PE_REGIST) */ + fun cancelOrder(peId: String, name: String, idcard: String): ProcResult + + /** 添加/修改单位(P_ADD_UNIT) */ + fun addUnit(unit: UnitVo, useV2: Boolean): ProcResult + + // ================= 查询(视图) ================= + + /** 按体检预约 ID(逗号分隔多个)查询体检状态 */ + fun getPhyexamStatus(peIds: String): List> + + /** 医院所有体检项目(组合项) */ + fun getItemList(): List> + + /** 医院所有体检项目及指标项 */ + fun getAllItemList(): List> + + /** 医院所有体检指标项 */ + fun getPeItemList(): List> + + /** 按指标项 CODE 查询所属体检组合 */ + fun getItemInfo(peItemCode: String): List> + + /** 员工体检项目 */ + fun getEmpItem(peId: String): List> + + /** 员工体检结果 */ + fun getResult(peId: String): List> + + /** 体检结论(列表 / 全量) */ + fun getConclusion(peId: String): List> + fun getConclusionAll(peId: String): List> + + /** 体检建议(列表 / 全量) */ + fun getSuggest(peId: String): List> + fun getSuggestAll(peId: String): List> + + /** 按身份证号 + 年份查询体检状态 */ + fun getPeStatusByIdnoAndYear(idNo: String, year: String): Map? + + /** 按身份证号 + 年份查询体检结果(默认 / 全量) */ + fun getResultByIdcardAndYear(idNo: String, year: String): List> + fun getResultByIdcardAndYearAll(idNo: String, year: String): List> + + /** 按体检编号查询体检结果 */ + fun getResultByPeId(peId: String): List> + + /** 按审核日期查询体检报告(去重主表) */ + fun getDisResultByAuditDate(auditDate: String): List> + + /** 按体检编号查询体检项目子表(默认 / 全量) */ + fun getItemByPeId(peId: String): List> + fun getItemByPeIdAll(peId: String): List> + + /** 按身份证号或体检编号 + 审核年份查询体检结果 */ + fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String): List> + + /** 按身份证号查询当年 peId 与预约日期(单条 / 列表) */ + fun getPeIdAndDateByIdcard(idNo: String): Map? + fun getPeIdAndDateByIdcardList(idNo: String): List> + + /** 按审核日期查询当天体检人员列表(去重;日期空则当天) */ + fun getExamListByDate(auditDate: String?): List> +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/adapter/HospitalRouter.kt b/src/main/kotlin/com/hospital/front/adapter/HospitalRouter.kt new file mode 100644 index 0000000..0df89aa --- /dev/null +++ b/src/main/kotlin/com/hospital/front/adapter/HospitalRouter.kt @@ -0,0 +1,43 @@ +package com.hospital.front.adapter + +import org.noear.solon.Solon +import org.noear.solon.annotation.Component + +/** + * 医院适配器路由。 + * + * 通过 Solon.context().getBeanOfType 在首次使用时动态获取所有 HospitalAdapter 实现, + * 避免依赖注入时序问题。 + */ +@Component +class HospitalRouter { + + private val activeCode: String = Solon.cfg().get("hospital.active", "jinghe")!! + + /** + * 动态拉取所有 HospitalAdapter 实现(每次调用都重新拉取,避免 bean 注入时序问题)。 + */ + private fun listAdapters(): List = + Solon.context().getBeansOfType(HospitalAdapter::class.java) + + private val map: Map by lazy { + listAdapters().associateBy { it.code() } + } + + init { + println("[HospitalRouter] 启动激活医院:$activeCode") + } + + /** 获取指定医院编码的适配器 */ + fun get(code: String): HospitalAdapter = + map[code] ?: throw IllegalStateException("未注册医院适配器:$code(已注册:${map.keys})") + + /** 获取启动激活的医院适配器 */ + fun active(): HospitalAdapter = get(activeCode) + + /** 当前激活医院编码 */ + fun activeCode(): String = activeCode + + /** 所有已注册的医院编码 */ + fun registeredCodes(): Set = map.keys +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/adapter/JingheAdapter.kt b/src/main/kotlin/com/hospital/front/adapter/JingheAdapter.kt new file mode 100644 index 0000000..e124535 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/adapter/JingheAdapter.kt @@ -0,0 +1,14 @@ +package com.hospital.front.adapter + +import org.noear.solon.annotation.Component + +/** + * 泾河医院适配器(Oracle,bsm 方言,默认激活医院)。 + */ +@Component +class JingheAdapter : BsmAdapterBase() { + + override fun code(): String = "jinghe" + + override fun displayName(): String = "泾河医院" +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/adapter/NingxiaAdapter.kt b/src/main/kotlin/com/hospital/front/adapter/NingxiaAdapter.kt new file mode 100644 index 0000000..74bc575 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/adapter/NingxiaAdapter.kt @@ -0,0 +1,17 @@ +package com.hospital.front.adapter + +import org.noear.solon.annotation.Component + +/** + * 宁夏宝石花医院适配器(SqlServer,nx 方言)。 + * + * 业务开关(在 OrderService 中按配置生效): + * - hm.allsecondFlag=true:强制二级单位编码/名称截断 + */ +@Component +class NingxiaAdapter : NxAdapterBase() { + + override fun code(): String = "ningxia" + + override fun displayName(): String = "宁夏宝石花医院" +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/adapter/NxAdapterBase.kt b/src/main/kotlin/com/hospital/front/adapter/NxAdapterBase.kt new file mode 100644 index 0000000..5663709 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/adapter/NxAdapterBase.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.nx.NxOrderMapper +import com.hospital.front.vo.UnitVo +import org.noear.solon.annotation.Inject + +/** + * NX 方言适配器基类(SqlServer)。 + * + * 适用医院:ningxia(宁夏宝石花)。 + */ +abstract class NxAdapterBase : HospitalAdapter { + + /** 数据库 Mapper(Solon 按类型注入) */ + @Inject + protected lateinit var mapper: NxOrderMapper + + override fun hmType(): String = "nx" + + 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 { + // 宁夏使用 addUnitV2(原 OrderServiceImpl 中 nx 分支固定走 V2) + 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, + ) + mapper.addUnitV2(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/QingyangAdapter.kt b/src/main/kotlin/com/hospital/front/adapter/QingyangAdapter.kt new file mode 100644 index 0000000..0adc0f8 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/adapter/QingyangAdapter.kt @@ -0,0 +1,16 @@ +package com.hospital.front.adapter + +import org.noear.solon.annotation.Component + +/** + * 庆阳医院适配器(Oracle,bsm 方言)。 + * + * 数据库连接暂与泾河一致,正式环境就绪后调整 pac/conf/app-qingyang.yml。 + */ +@Component +class QingyangAdapter : BsmAdapterBase() { + + override fun code(): String = "qingyang" + + override fun displayName(): String = "庆阳医院" +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/adapter/XingLongYuanAdapter.kt b/src/main/kotlin/com/hospital/front/adapter/XingLongYuanAdapter.kt new file mode 100644 index 0000000..8cfe5af --- /dev/null +++ b/src/main/kotlin/com/hospital/front/adapter/XingLongYuanAdapter.kt @@ -0,0 +1,14 @@ +package com.hospital.front.adapter + +import org.noear.solon.annotation.Component + +/** + * 兴隆园医院适配器(Oracle,bsm 方言)。 + */ +@Component +class XingLongYuanAdapter : BsmAdapterBase() { + + override fun code(): String = "xinglongyuan" + + override fun displayName(): String = "兴隆园医院" +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/adapter/YaAdapterBase.kt b/src/main/kotlin/com/hospital/front/adapter/YaAdapterBase.kt new file mode 100644 index 0000000..3e3cc50 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/adapter/YaAdapterBase.kt @@ -0,0 +1,88 @@ +package com.hospital.front.adapter + +import com.hospital.front.dto.ProcResult +import com.hospital.front.dto.Result +import com.hospital.front.mapper.ya.YaOrderMapper +import com.hospital.front.vo.UnitVo +import org.noear.solon.annotation.Inject + +/** + * YA 方言适配器基类(SqlServer)。 + * + * 适用医院:yanan(延安)。 + */ +abstract class YaAdapterBase : HospitalAdapter { + + /** 数据库 Mapper(Solon 按类型注入) */ + @Inject + protected lateinit var mapper: YaOrderMapper + + override fun hmType(): String = "ya" + + 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/YananAdapter.kt b/src/main/kotlin/com/hospital/front/adapter/YananAdapter.kt new file mode 100644 index 0000000..0a9e13d --- /dev/null +++ b/src/main/kotlin/com/hospital/front/adapter/YananAdapter.kt @@ -0,0 +1,14 @@ +package com.hospital.front.adapter + +import org.noear.solon.annotation.Component + +/** + * 延安医院适配器(SqlServer,ya 方言)。 + */ +@Component +class YananAdapter : YaAdapterBase() { + + override fun code(): String = "yanan" + + override fun displayName(): String = "延安医院" +} \ 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 new file mode 100644 index 0000000..4b9ac31 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/api/admin/AdminApi.kt @@ -0,0 +1,208 @@ +package com.hospital.front.api.admin + +import com.hospital.front.adapter.HospitalRouter +import com.hospital.front.dto.Result +import com.hospital.front.infra.exception.BusinessException +import org.noear.solon.Solon +import org.noear.solon.annotation.Controller +import org.noear.solon.annotation.Inject +import org.noear.solon.annotation.Mapping +import org.noear.solon.annotation.Param +import org.noear.solon.core.handle.Context +import java.io.File +import java.lang.management.ManagementFactory +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths + +/** + * 主程序被控端 API(/api/admin)。 + * + * 供同机 Agent(环回地址)调用,实现远程运维: + * - 健康检查 / 版本查询 / JVM 指标 + * - 配置查看(脱敏) + * - 日志文件列表 / 尾部查看 / 全量下载 + * - 优雅重启 + * + * 鉴权:请求头 X-Admin-Token 与配置 admin.token 比对(未配置时放行,仅本机使用)。 + */ +@Controller +@Mapping("/api/admin") +class AdminApi { + + @Inject + private lateinit var router: HospitalRouter + + /** 日志目录(与 logback.xml 的 log.path 保持一致) */ + private val logDir: File + get() = File(Solon.cfg().get("log.path", "./log")!!) + + // ================= 鉴权 ================= + + private fun checkToken(ctx: Context) { + val expected = Solon.cfg().get("admin.token", "") + if (expected.isNotBlank() && ctx.header("X-Admin-Token") != expected) { + throw BusinessException.of(Result.CODE_AUTH_FAILED, "管理接口鉴权失败") + } + } + + // ================= 1. 健康与指标 ================= + + /** 健康检查(含数据库探测) */ + @Mapping("/health") + fun health(ctx: Context): Result> { + checkToken(ctx) + val mem = Runtime.getRuntime() + val db = router.active().healthCheck() + return Result.success( + mapOf( + "status" to if (db.code == 0) "UP" else "DEGRADED", + "hospital" to router.activeCode(), + "hospitalName" to router.active().displayName(), + "registeredHospitals" to router.registeredCodes(), + "database" to mapOf( + "ok" to (db.code == 0), + "msg" to db.msg, + ), + "jvm" to mapOf( + "totalMemoryMb" to mem.totalMemory() / 1048576, + "freeMemoryMb" to mem.freeMemory() / 1048576, + "maxMemoryMb" to mem.maxMemory() / 1048576, + "availableProcessors" to mem.availableProcessors(), + ), + "uptimeSec" to ManagementFactory.getRuntimeMXBean().uptime / 1000, + "pid" to ProcessHandle.current().pid(), + ) + ) + } + + /** 版本信息(读 MANIFEST) */ + @Mapping("/version") + fun version(ctx: Context): Result> { + checkToken(ctx) + val pkg = this::class.java.`package` + return Result.success( + mapOf( + "app" to "hospital-front", + "version" to (pkg?.implementationVersion ?: "1.0.0-dev"), + "hospital" to router.activeCode(), + "javaVersion" to System.getProperty("java.version"), + "startTime" to ManagementFactory.getRuntimeMXBean().startTime.toString(), + ) + ) + } + + // ================= 2. 配置管理 ================= + + /** 查看当前生效配置(密码脱敏) */ + @Mapping("/config") + fun config(ctx: Context): Result> { + checkToken(ctx) + val keys = listOf( + "hospital.active", "hospital.id", + "hm.type", "hm.ver", "hm.allsecondFlag", "hm.occFlag", + "server.port", "admin.token", "log.path", + ) + val conf = keys.associateWith { Solon.cfg().get(it, "") } + // 数据源脱敏(db1! 的 ! 在 cfg 键中已剥离,两种写法都尝试) + val ds = mapOf( + "db1.jdbcUrl" to (Solon.cfg().get("solon.dataSources.db1.jdbcUrl") + ?: Solon.cfg().get("solon.dataSources.db1!.jdbcUrl") ?: ""), + "db1.username" to (Solon.cfg().get("solon.dataSources.db1.username") + ?: Solon.cfg().get("solon.dataSources.db1!.username") ?: ""), + "db1.password" to "******", + ) + return Result.success(mapOf("config" to conf, "dataSource" to ds)) + } + + /** + * 列出 pac/conf 目录下的医院配置文件内容(供 B 端查看/修改)。 + */ + @Mapping("/config/file") + fun configFile(ctx: Context, @Param("hospital") hospital: String): Result { + checkToken(ctx) + val file = File("conf/app-$hospital.yml") + if (!file.exists()) { + throw BusinessException.paramMissing("配置文件 conf/app-$hospital.yml 不存在") + } + return Result.success(String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)) + } + + /** + * 写回医院配置文件(B 端修改后下发)。重启后生效。 + */ + @Mapping(value = "/config/file", method = [org.noear.solon.core.handle.MethodType.POST]) + fun saveConfigFile(ctx: Context, @Param("hospital") hospital: String, @Param(name = "content") content: String): Result { + checkToken(ctx) + if (!hospital.matches(Regex("[a-z]+"))) { + throw BusinessException.businessFailed("非法医院编码:$hospital") + } + val file = File("conf/app-$hospital.yml") + file.parentFile?.mkdirs() + Files.write(file.toPath(), content.toByteArray(StandardCharsets.UTF_8)) + return Result.success() + } + + // ================= 3. 日志管理 ================= + + /** 日志文件列表 */ + @Mapping("/log/files") + fun logFiles(ctx: Context): Result>> { + checkToken(ctx) + val dir = logDir + if (!dir.exists()) return Result.success(emptyList()) + return Result.success( + dir.listFiles { f -> f.isFile } + ?.sortedByDescending { it.lastModified() } + ?.take(50) + ?.map { mapOf("name" to it.name, "sizeBytes" to it.length(), "lastModified" to it.lastModified()) } + ?: emptyList() + ) + } + + /** 日志尾部 N 行(默认 200) */ + @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) + return Result.success(all.takeLast(n).joinToString("\n")) + } + + /** 日志全量下载 */ + @Mapping("/log/download") + fun logDownload(ctx: Context, @Param("file") file: String) { + checkToken(ctx) + val target = safeLogFile(file) + ctx.contentType("application/octet-stream") + ctx.headerSet("Content-Disposition", "attachment; filename=\"${target.name}\"") + Files.copy(target.toPath(), ctx.outputStream()) + } + + /** 限制只能读日志目录内的文件(防路径穿越) */ + private fun safeLogFile(name: String): File { + if (name.contains("..") || name.contains("/") || name.contains("\\")) { + throw BusinessException.businessFailed("非法文件名") + } + val f = File(logDir, name) + if (!f.exists()) throw BusinessException.paramMissing("日志文件不存在:$name") + return f + } + + // ================= 4. 进程管理 ================= + + /** + * 优雅重启:记录标记后延迟退出,Agent 检测到进程退出后拉起新进程。 + */ + @Mapping(value = "/restart", method = [org.noear.solon.core.handle.MethodType.POST]) + fun restart(ctx: Context, @Param("delayMs") delayMs: Long?): Result { + checkToken(ctx) + val delay = (delayMs ?: 1000L).coerceIn(0, 10000) + Thread { + Thread.sleep(delay) + Solon.stopBlock() // 触发 Solon 停机事件(优雅退出) + }.apply { isDaemon = true }.start() + return Result.success() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/api/order/OrderApi.kt b/src/main/kotlin/com/hospital/front/api/order/OrderApi.kt new file mode 100644 index 0000000..97f71fc --- /dev/null +++ b/src/main/kotlin/com/hospital/front/api/order/OrderApi.kt @@ -0,0 +1,179 @@ +package com.hospital.front.api.order + +import com.hospital.front.dto.Result +import com.hospital.front.infra.exception.BusinessException +import com.hospital.front.service.OrderService +import org.noear.solon.annotation.Body +import org.noear.solon.annotation.Controller +import org.noear.solon.annotation.Inject +import org.noear.solon.annotation.Mapping +import org.noear.solon.annotation.Param +import org.noear.solon.core.handle.MethodType +import org.noear.solon.annotation.Path + +/** + * 体检预约业务接口,路径前缀 /order。 + * + * 路径与出参结构保持与原 hospitalmiddle 一致(调用方契约,不可修改)。 + * 根路径前缀为 /order,各方法 Mapping 为相对路径。 + */ +@Controller +@Mapping("/order") +class OrderApi { + + @Inject + private lateinit var service: OrderService + + // ================= 1. 预约提交 / 取消 ================= + + /** 4. 提交预约保存 */ + @Mapping(value = "/saveOrder", method = [MethodType.POST]) + fun saveOrder(@Body body: Map): Result = + Result.success(service.saveOrder(body)) + + /** 5. 取消体检预约 */ + @Mapping(value = "/cancelOrder/{peId}", method = [MethodType.POST]) + fun cancelOrder( + @Path("peId") peId: String, + @Param("name") name: String, + @Param("idcard") idcard: String, + ): Result { + service.cancelOrder(peId, name, idcard) + return Result.success() + } + + // ================= 2. 体检状态 ================= + + /** 3. 体检状态查询(多个 peId 逗号分隔) */ + @Mapping("/getPeStatus/{peIds}") + fun getPeStatus(@Path("peIds") peIds: String): Result>> = + Result.success(service.getPhyexamStatus(peIds)) + + /** 13. 根据身份证号及年份获取当前体检状态及体检项状态 */ + @Mapping("/getPeStatusAndItemList/{idNo}/{year}") + fun getPeStatusAndItemList(@Path("idNo") idNo: String, @Path("year") year: String): Result?> = + Result.success(service.getPeStatusByIdnoAndYear(idNo, year)) + + // ================= 3. 体检项目 ================= + + /** 1. 查询员工体检项目 */ + @Mapping("/getEmpItem/{peId}") + fun getEmpItem(@Path("peId") peId: String): Result>> = + Result.success(service.getEmpItem(peId)) + + /** 2. 体检项目查询 */ + @Mapping("/getItemList") + fun getItemList(): Result>> = + Result.success(service.getItemList()) + + /** 6. 体检指标项数据获取 */ + @Mapping("/getPeItemList") + fun getPeItemList(): Result>> = + Result.success(service.getPeItemList()) + + /** 6.1 体检指标项及体检组合数据获取 */ + @Mapping("/getAllItemList") + fun getAllItemList(): Result>> = + Result.success(service.getAllItemList()) + + /** 7. 根据体检指标项查询所属体检组合项 */ + @Mapping("/getItemInfo/{peItemCode}") + fun getItemInfo(@Path("peItemCode") peItemCode: String): Result>> = + Result.success(service.getItemInfo(peItemCode)) + + // ================= 4. 体检结果 / 结论 / 建议 ================= + + /** 8. 根据体检编号获取员工体检结果 */ + @Mapping("/getResult/{peId}") + fun getResult(@Path("peId") peId: String): Result>> = + Result.success(service.getResult(peId)) + + /** 9. 体检结论(汇总) */ + @Mapping("/getTotalConclusion/{peId}") + fun getTotalConclusion(@Path("peId") peId: String): Result> = + Result.success(service.getTotalConclusion(peId)) + + /** 10. 体检建议(汇总) */ + @Mapping("/getTotalSuggest/{peId}") + fun getTotalSuggest(@Path("peId") peId: String): Result> = + Result.success(service.getTotalSuggest(peId)) + + /** 11. 体检结论(列表) */ + @Mapping("/getConclusion/{peId}") + fun getConclusion(@Path("peId") peId: String): Result>> = + Result.success(service.getConclusion(peId)) + + /** 12. 体检建议(列表) */ + @Mapping("/getSuggest/{peId}") + fun getSuggest(@Path("peId") peId: String): Result>> = + Result.success(service.getSuggest(peId)) + + // ================= 5. 按身份证 / 日期查询 ================= + + /** 14. 根据身份证号及年份获取体检结果 */ + @Mapping("/getResultByIdcardAndYear/{idNo}/{year}") + fun getResultByIdcardAndYear(@Path("idNo") idNo: String, @Path("year") year: String): Result>> = + Result.success(service.getResultByIdcardAndYear(idNo, year)) + + /** 14.1 根据身份证号及年份获取体检结果(全量) */ + @Mapping("/getResultByIdcardAndYearAll/{idNo}/{year}") + fun getResultByIdcardAndYearAll(@Path("idNo") idNo: String, @Path("year") year: String): Result>> = + Result.success(service.getResultByIdcardAndYearAll(idNo, year)) + + /** 15.1 根据体检编号获取体检结果 */ + @Mapping("/getResultByPeId/{peId}") + fun getResultByPeId(@Path("peId") peId: String): Result>> = + Result.success(service.getResultByPeId(peId)) + + /** 15.2 根据体检编号获取体检状态(简) */ + @Mapping("/getResultStatus/{peId}") + fun getResultStatus(@Path("peId") peId: String): Result>> = + Result.success(service.getResultByPeId(peId)) + + /** 15.3 根据 audit_date 查询当天的体检报告 */ + @Mapping("/getResultByAuditDate/{auditDate}") + fun getResultByAuditDate(@Path("auditDate") auditDate: String): Result>> = + Result.success(service.getResultByAuditDate(auditDate)) + + /** 16. 根据 id_code 或 pe_id 和审查年份查询体检报告 */ + @Mapping(value = "/getResultByIdNoOrPeId", method = [MethodType.POST]) + fun getResultByIdNoOrPeId( + @Param("idNo") idNo: String?, + @Param("peId") peId: String?, + @Param("auditYear") auditYear: String, + ): Result>> { + if (idNo.isNullOrBlank() && peId.isNullOrBlank()) { + throw BusinessException.paramMissing("身份证号或体检编号") + } + return Result.success(service.getResultByIdNoOrPeId(idNo, peId, auditYear)) + } + + /** 17. 根据身份证号查询当前年的 peId 和预约日期 */ + @Mapping("/getPeIdAndDateByIdcard/{idNo}") + fun getPeIdAndDateByIdcard(@Path("idNo") idNo: String): Result?> = + Result.success(service.getPeIdAndDateByIdcard(idNo)) + + /** 17.1 同上(数组) */ + @Mapping("/getPeIdAndDateByIdcardList/{idNo}") + fun getPeIdAndDateByIdcardList(@Path("idNo") idNo: String): Result>> = + Result.success(service.getPeIdAndDateByIdcardList(idNo)) + + // ================= 6. 单位 / 人员列表 ================= + + /** 18. 添加修改单位信息 */ + @Mapping(value = "/addUnit", method = [MethodType.POST]) + fun addUnit(@Body body: String): Result { + // Solon 对 List 泛型 body 反序列化支持不佳,此处用 snack3 手动解析 + @Suppress("UNCHECKED_CAST") + val units: List> = + if (body.isBlank()) emptyList() + else org.noear.snack.ONode.load(body).toObject(List::class.java) as List> + val errors = service.addUnits(units) + return if (errors != null) Result.error(errors) else Result.success() + } + + /** 19. 根据日期查询当天体检人员列表(去重),日期为空则查当天 */ + @Mapping("/getExamListByDate") + fun getExamListByDate(@Param("auditDate") auditDate: String?): Result>> = + Result.success(service.getExamListByDate(auditDate)) +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/dto/ProcResult.kt b/src/main/kotlin/com/hospital/front/dto/ProcResult.kt new file mode 100644 index 0000000..4ed17e7 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/dto/ProcResult.kt @@ -0,0 +1,17 @@ +package com.hospital.front.dto + +/** + * 医院存储过程执行结果。 + * + * @property resultCode 医院侧返回码("1" = 成功) + * @property errorMsg 错误信息(失败时) + * @property peId 预约成功后医院分配的体检编号(仅 saveOrder 有值) + */ +data class ProcResult( + val resultCode: String? = null, + val errorMsg: String? = null, + val peId: String? = null, +) { + /** 医院侧是否执行成功 */ + val isSuccess: Boolean get() = resultCode == "1" +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/dto/Result.kt b/src/main/kotlin/com/hospital/front/dto/Result.kt new file mode 100644 index 0000000..d746b36 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/dto/Result.kt @@ -0,0 +1,69 @@ +package com.hospital.front.dto + +/** + * 统一 API 响应包装。 + * + * 业务接口均使用此结构返回,便于前端统一处理。 + * + * @param T 数据类型 + * @property code 业务码(0=成功) + * @property msg 提示信息 + * @property data 业务数据 + */ +data class Result( + val code: Int, + val msg: String, + val data: T? = null, +) { + companion object { + /** 业务成功码 */ + const val SUCCESS_CODE = 0 + + /** 业务失败默认码 */ + const val ERROR_CODE = 5001 + + /** 参数缺失 */ + const val CODE_PARAM_MISSING = 1001 + + /** 参数格式错误 */ + const val CODE_PARAM_FORMAT = 1002 + + /** 业务校验失败 */ + const val CODE_BUSINESS = 1003 + + /** 存储过程调用失败 */ + const val CODE_PROC_FAILED = 2001 + + /** 数据库连接失败 */ + const val CODE_DB_UNREACHABLE = 2002 + + /** 医院系统返回业务失败 */ + const val CODE_HOSPITAL_FAILED = 2003 + + /** 鉴权失败 */ + const val CODE_AUTH_FAILED = 3001 + + /** Token 过期 */ + const val CODE_TOKEN_EXPIRED = 3002 + + /** + * 成功响应(无数据)。 + */ + fun success(): Result = Result(SUCCESS_CODE, "success", null) + + /** + * 成功响应。 + */ + fun success(data: T): Result = Result(SUCCESS_CODE, "success", data) + + /** + * 错误响应(默认错误码)。 + */ + fun error(msg: String): Result = Result(ERROR_CODE, msg, null) + + /** + * 错误响应(指定错误码)。 + */ + fun error(code: Int, msg: String): Result = Result(code, msg, null) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/infra/config/AppConfig.kt b/src/main/kotlin/com/hospital/front/infra/config/AppConfig.kt new file mode 100644 index 0000000..85b7ab5 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/infra/config/AppConfig.kt @@ -0,0 +1,23 @@ +package com.hospital.front.infra.config + +import org.noear.solon.annotation.BindProps +import org.noear.solon.annotation.Configuration +import org.noear.solon.annotation.Inject + +/** + * 应用配置(Solon @ConfigurationProperties)。 + * + * 加载 `app.yml` 与 `pac/conf/app-{编码}.yml` 中的 hospital.* 配置。 + * + * @author hospital-front重构 + */ +@BindProps(prefix = "hospital") +@Configuration +open class AppConfig { + + /** 启动激活哪家医院 */ + var active: String = "jinghe" + + /** 注册的医院编码清单(启动自检) */ + var adapters: List = emptyList() +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/infra/exception/GlobalExceptionHandler.kt b/src/main/kotlin/com/hospital/front/infra/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..5fcdd0e --- /dev/null +++ b/src/main/kotlin/com/hospital/front/infra/exception/GlobalExceptionHandler.kt @@ -0,0 +1,73 @@ +package com.hospital.front.infra.exception + +import com.hospital.front.dto.Result +import org.noear.solon.annotation.Component +import org.noear.solon.core.handle.Context +import org.noear.solon.core.handle.Filter +import org.noear.solon.core.handle.FilterChain +import org.slf4j.LoggerFactory + +/** + * 全局异常处理(Solon Filter)。 + * + * 将未捕获的异常统一包装为 Result 格式返回。 + * 业务异常请直接抛出 BusinessException。 + */ +@Component +class GlobalExceptionHandler : Filter { + + private val log = LoggerFactory.getLogger(GlobalExceptionHandler::class.java) + + override fun doFilter(ctx: Context, chain: FilterChain) { + try { + chain.doFilter(ctx) + } catch (e: BusinessException) { + log.warn("业务异常:code={}, msg={}", e.code, e.message) + renderResult(ctx, Result.error(e.code, e.message ?: "业务异常")) + } catch (e: IllegalArgumentException) { + log.warn("参数异常:{}", e.message) + renderResult(ctx, Result.error(Result.CODE_PARAM_FORMAT, e.message ?: "参数错误")) + } catch (e: Exception) { + log.error("系统异常", e) + renderResult(ctx, Result.error(Result.ERROR_CODE, "系统异常:" + (e.message ?: e.javaClass.simpleName))) + } + } + + private fun renderResult(ctx: Context, result: Result<*>) { + ctx.status(200) + ctx.contentType("application/json;charset=UTF-8") + // 简易 JSON 序列化(避免引入 fastjson 依赖) + val dataJson = result.data?.let { toJsonValue(it) } ?: "null" + ctx.output("""{"code":${result.code},"msg":${toJsonValue(result.msg)},"data":$dataJson}""") + } + + /** 极简 JSON 值序列化(字符串加引号,其它直接 toString) */ + private fun toJsonValue(v: Any?): String = when (v) { + null -> "null" + is Number, is Boolean -> v.toString() + else -> "\"" + v.toString().replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"" + } +} + +/** + * 业务异常。 + * + * 用于业务层显式抛出指定错误码的异常,会被 GlobalExceptionHandler 捕获并包装为 Result。 + * + * @param code 业务错误码(参见 Result 中的常量) + * @param message 错误描述 + */ +class BusinessException( + val code: Int, + override val message: String?, +) : RuntimeException(message) { + companion object { + fun of(code: Int, message: String?): BusinessException = BusinessException(code, message) + fun paramMissing(field: String): BusinessException = + BusinessException(Result.CODE_PARAM_MISSING, "参数缺失:$field") + fun businessFailed(msg: String): BusinessException = + BusinessException(Result.CODE_BUSINESS, msg) + fun procFailed(msg: String): BusinessException = + BusinessException(Result.CODE_PROC_FAILED, msg) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/infra/util/DateUtils.kt b/src/main/kotlin/com/hospital/front/infra/util/DateUtils.kt new file mode 100644 index 0000000..8676c84 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/infra/util/DateUtils.kt @@ -0,0 +1,42 @@ +package com.hospital.front.infra.util + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +/** + * 日期工具类(统一格式)。 + * + * 业务系统约定: +- 日期:yyyy-MM-dd +- 时间:yyyy-MM-dd HH:mm:ss +- 时区:GMT+8 + * + * @author hospital-front重构 + */ +object DateUtils { + + /** 日期格式 */ + val DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + /** 日期时间格式 */ + val DATETIME: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + + /** 当前日期字符串(yyyy-MM-dd) */ + fun today(): String = LocalDate.now().format(DATE) + + /** 当前时间字符串(yyyy-MM-dd HH:mm:ss) */ + fun now(): String = LocalDateTime.now().format(DATETIME) + + /** 解析日期字符串 */ + fun parseDate(text: String): LocalDate = LocalDate.parse(text, DATE) + + /** 解析日期时间字符串 */ + fun parseDateTime(text: String): LocalDateTime = LocalDateTime.parse(text, DATETIME) + + /** 格式化为日期字符串 */ + fun format(date: LocalDate): String = date.format(DATE) + + /** 格式化为日期时间字符串 */ + fun format(dateTime: LocalDateTime): String = dateTime.format(DATETIME) +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/mapper/bsm/BsmOrderMapper.kt b/src/main/kotlin/com/hospital/front/mapper/bsm/BsmOrderMapper.kt new file mode 100644 index 0000000..2807598 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/mapper/bsm/BsmOrderMapper.kt @@ -0,0 +1,101 @@ +package com.hospital.front.mapper.bsm + +import org.apache.ibatis.annotations.Mapper +import org.apache.ibatis.annotations.Param + +/** + * BSM 医院组 Mapper(Oracle 方言)。 + * + * 适用医院:xinglongyuan(兴隆园)/ jinghe(泾河)/ qingyang(庆阳) + * XML 位置:classpath:mapper/bsm/OrderMapper.xml + * + * 存储过程参数与医院 HIS 系统约定,不可修改。 + */ +@Mapper +interface BsmOrderMapper { + + /** 健康探测(SELECT 1) */ + fun ping(): Int + + /** 预约提交(存储过程 P_ADD_PE_REGIST,出参 resultCode/errorMsg/peId) */ + fun saveOrder(map: MutableMap) + + /** 取消预约(存储过程 P_DEL_PE_REGIST) */ + fun cancelOrder(map: MutableMap) + + /** 添加/修改单位 V1(存储过程 P_ADD_UNIT) */ + fun addUnit(map: MutableMap) + + /** 添加/修改单位 V2(存储过程 P_ADD_UNIT,8 入参版) */ + fun addUnitV2(map: MutableMap) + + /** 按体检预约 ID(逗号分隔)查询体检状态 */ + fun getPhyexamStatus(@Param("peIds") peIds: String): List> + + /** 医院所有体检项目(组合项) */ + fun getItemList(): List> + + /** 医院所有体检项目及指标项 */ + fun getAllItemList(): List> + + /** 医院所有体检指标项 */ + fun getPeItemList(): List> + + /** 员工体检项目 */ + fun getEmpItem(@Param("peId") peId: String): List> + + /** 按指标项 CODE 查询体检组合 */ + fun getItemInfo(@Param("peItemCode") peItemCode: String): List> + + /** 员工体检结果 */ + fun getResult(@Param("peId") peId: String): List> + + /** 体检结论(列表) */ + fun getConclusion(@Param("peId") peId: String): List> + + /** 体检结论(全量) */ + fun getConclusionAll(@Param("peId") peId: String): List> + + /** 体检建议(列表) */ + fun getSuggest(@Param("peId") peId: String): List> + + /** 体检建议(全量) */ + fun getSuggestAll(@Param("peId") peId: String): List> + + /** 按身份证号及年份查询体检状态 */ + fun getPeStatusByIdnoAndYear(@Param("idNo") idNo: String, @Param("year") year: String): Map? + + /** 按身份证号及年份查询体检结果 */ + fun getResultByIdcardAndYear(@Param("idNo") idNo: String, @Param("year") year: String): List> + + /** 按身份证号及年份查询体检结果(全量) */ + fun getResultByIdcardAndYearAll(@Param("idNo") idNo: String, @Param("year") year: String): List> + + /** 按体检编号查询体检结果 */ + fun getResultByPeId(@Param("peId") peId: String): List> + + /** 按审核日期查询体检报告(去重) */ + fun getDisResultByAuditDate(@Param("auditDate") auditDate: String): List> + + /** 按体检编号查询体检项目子表 */ + fun getItemByPeId(@Param("peId") peId: String): List> + + /** 按体检编号查询体检项目子表(全量) */ + fun getItemByPeIdAll(@Param("peId") peId: String): List> + + /** 按身份证号或体检编号 + 审核年份查询体检结果 */ + fun getResultByIdNoOrPeId( + @Param("idNo") idNo: String?, + @Param("peId") peId: String?, + @Param("auditYear") auditYear: String, + ): List> + + /** 按身份证号查询当年 peId 与预约日期 */ + fun getPeIdAndDateByIdcard(@Param("idNo") idNo: String): Map? + + /** 按身份证号查询当年 peId 与预约日期(数组) */ + fun getPeIdAndDateByIdcardList(@Param("idNo") idNo: String): List> + + /** 按审核日期查询当天体检人员列表(去重) */ + fun getExamListByDate(@Param("auditDate") auditDate: String?): List> +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/mapper/nx/NxOrderMapper.kt b/src/main/kotlin/com/hospital/front/mapper/nx/NxOrderMapper.kt new file mode 100644 index 0000000..0adfff4 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/mapper/nx/NxOrderMapper.kt @@ -0,0 +1,101 @@ +package com.hospital.front.mapper.nx + +import org.apache.ibatis.annotations.Mapper +import org.apache.ibatis.annotations.Param + +/** + * BSM 医院组 Mapper(Oracle 方言)。 + * + * 适用医院:ningxia(宁夏宝石花,SqlServer) + * XML 位置:classpath:mapper/nx/OrderMapper.xml + * + * 存储过程参数与医院 HIS 系统约定,不可修改。 + */ +@Mapper +interface NxOrderMapper { + + /** 健康探测(SELECT 1) */ + fun ping(): Int + + /** 预约提交(存储过程 P_ADD_PE_REGIST,出参 resultCode/errorMsg/peId) */ + fun saveOrder(map: MutableMap) + + /** 取消预约(存储过程 P_DEL_PE_REGIST) */ + fun cancelOrder(map: MutableMap) + + /** 添加/修改单位 V1(存储过程 P_ADD_UNIT) */ + fun addUnit(map: MutableMap) + + /** 添加/修改单位 V2(存储过程 P_ADD_UNIT,8 入参版) */ + fun addUnitV2(map: MutableMap) + + /** 按体检预约 ID(逗号分隔)查询体检状态 */ + fun getPhyexamStatus(@Param("peIds") peIds: String): List> + + /** 医院所有体检项目(组合项) */ + fun getItemList(): List> + + /** 医院所有体检项目及指标项 */ + fun getAllItemList(): List> + + /** 医院所有体检指标项 */ + fun getPeItemList(): List> + + /** 员工体检项目 */ + fun getEmpItem(@Param("peId") peId: String): List> + + /** 按指标项 CODE 查询体检组合 */ + fun getItemInfo(@Param("peItemCode") peItemCode: String): List> + + /** 员工体检结果 */ + fun getResult(@Param("peId") peId: String): List> + + /** 体检结论(列表) */ + fun getConclusion(@Param("peId") peId: String): List> + + /** 体检结论(全量) */ + fun getConclusionAll(@Param("peId") peId: String): List> + + /** 体检建议(列表) */ + fun getSuggest(@Param("peId") peId: String): List> + + /** 体检建议(全量) */ + fun getSuggestAll(@Param("peId") peId: String): List> + + /** 按身份证号及年份查询体检状态 */ + fun getPeStatusByIdnoAndYear(@Param("idNo") idNo: String, @Param("year") year: String): Map? + + /** 按身份证号及年份查询体检结果 */ + fun getResultByIdcardAndYear(@Param("idNo") idNo: String, @Param("year") year: String): List> + + /** 按身份证号及年份查询体检结果(全量) */ + fun getResultByIdcardAndYearAll(@Param("idNo") idNo: String, @Param("year") year: String): List> + + /** 按体检编号查询体检结果 */ + fun getResultByPeId(@Param("peId") peId: String): List> + + /** 按审核日期查询体检报告(去重) */ + fun getDisResultByAuditDate(@Param("auditDate") auditDate: String): List> + + /** 按体检编号查询体检项目子表 */ + fun getItemByPeId(@Param("peId") peId: String): List> + + /** 按体检编号查询体检项目子表(全量) */ + fun getItemByPeIdAll(@Param("peId") peId: String): List> + + /** 按身份证号或体检编号 + 审核年份查询体检结果 */ + fun getResultByIdNoOrPeId( + @Param("idNo") idNo: String?, + @Param("peId") peId: String?, + @Param("auditYear") auditYear: String, + ): List> + + /** 按身份证号查询当年 peId 与预约日期 */ + fun getPeIdAndDateByIdcard(@Param("idNo") idNo: String): Map? + + /** 按身份证号查询当年 peId 与预约日期(数组) */ + fun getPeIdAndDateByIdcardList(@Param("idNo") idNo: String): List> + + /** 按审核日期查询当天体检人员列表(去重) */ + fun getExamListByDate(@Param("auditDate") auditDate: String?): List> +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/mapper/ya/YaOrderMapper.kt b/src/main/kotlin/com/hospital/front/mapper/ya/YaOrderMapper.kt new file mode 100644 index 0000000..b323745 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/mapper/ya/YaOrderMapper.kt @@ -0,0 +1,101 @@ +package com.hospital.front.mapper.ya + +import org.apache.ibatis.annotations.Mapper +import org.apache.ibatis.annotations.Param + +/** + * BSM 医院组 Mapper(Oracle 方言)。 + * + * 适用医院:yanan(延安,SqlServer) + * XML 位置:classpath:mapper/ya/OrderMapper.xml + * + * 存储过程参数与医院 HIS 系统约定,不可修改。 + */ +@Mapper +interface YaOrderMapper { + + /** 健康探测(SELECT 1) */ + fun ping(): Int + + /** 预约提交(存储过程 P_ADD_PE_REGIST,出参 resultCode/errorMsg/peId) */ + fun saveOrder(map: MutableMap) + + /** 取消预约(存储过程 P_DEL_PE_REGIST) */ + fun cancelOrder(map: MutableMap) + + /** 添加/修改单位 V1(存储过程 P_ADD_UNIT) */ + fun addUnit(map: MutableMap) + + /** 添加/修改单位 V2(存储过程 P_ADD_UNIT,8 入参版) */ + fun addUnitV2(map: MutableMap) + + /** 按体检预约 ID(逗号分隔)查询体检状态 */ + fun getPhyexamStatus(@Param("peIds") peIds: String): List> + + /** 医院所有体检项目(组合项) */ + fun getItemList(): List> + + /** 医院所有体检项目及指标项 */ + fun getAllItemList(): List> + + /** 医院所有体检指标项 */ + fun getPeItemList(): List> + + /** 员工体检项目 */ + fun getEmpItem(@Param("peId") peId: String): List> + + /** 按指标项 CODE 查询体检组合 */ + fun getItemInfo(@Param("peItemCode") peItemCode: String): List> + + /** 员工体检结果 */ + fun getResult(@Param("peId") peId: String): List> + + /** 体检结论(列表) */ + fun getConclusion(@Param("peId") peId: String): List> + + /** 体检结论(全量) */ + fun getConclusionAll(@Param("peId") peId: String): List> + + /** 体检建议(列表) */ + fun getSuggest(@Param("peId") peId: String): List> + + /** 体检建议(全量) */ + fun getSuggestAll(@Param("peId") peId: String): List> + + /** 按身份证号及年份查询体检状态 */ + fun getPeStatusByIdnoAndYear(@Param("idNo") idNo: String, @Param("year") year: String): Map? + + /** 按身份证号及年份查询体检结果 */ + fun getResultByIdcardAndYear(@Param("idNo") idNo: String, @Param("year") year: String): List> + + /** 按身份证号及年份查询体检结果(全量) */ + fun getResultByIdcardAndYearAll(@Param("idNo") idNo: String, @Param("year") year: String): List> + + /** 按体检编号查询体检结果 */ + fun getResultByPeId(@Param("peId") peId: String): List> + + /** 按审核日期查询体检报告(去重) */ + fun getDisResultByAuditDate(@Param("auditDate") auditDate: String): List> + + /** 按体检编号查询体检项目子表 */ + fun getItemByPeId(@Param("peId") peId: String): List> + + /** 按体检编号查询体检项目子表(全量) */ + fun getItemByPeIdAll(@Param("peId") peId: String): List> + + /** 按身份证号或体检编号 + 审核年份查询体检结果 */ + fun getResultByIdNoOrPeId( + @Param("idNo") idNo: String?, + @Param("peId") peId: String?, + @Param("auditYear") auditYear: String, + ): List> + + /** 按身份证号查询当年 peId 与预约日期 */ + fun getPeIdAndDateByIdcard(@Param("idNo") idNo: String): Map? + + /** 按身份证号查询当年 peId 与预约日期(数组) */ + fun getPeIdAndDateByIdcardList(@Param("idNo") idNo: String): List> + + /** 按审核日期查询当天体检人员列表(去重) */ + fun getExamListByDate(@Param("auditDate") auditDate: String?): List> +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/service/OrderService.kt b/src/main/kotlin/com/hospital/front/service/OrderService.kt new file mode 100644 index 0000000..16aa515 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/service/OrderService.kt @@ -0,0 +1,260 @@ +package com.hospital.front.service + +import com.hospital.front.adapter.HospitalAdapter +import com.hospital.front.adapter.HospitalRouter +import com.hospital.front.infra.exception.BusinessException +import com.hospital.front.infra.util.DateUtils +import com.hospital.front.vo.OrderItemVo +import com.hospital.front.vo.OrderV2Vo +import org.noear.solon.Solon +import org.noear.solon.annotation.Component +import org.noear.solon.annotation.Inject +import org.slf4j.LoggerFactory + +/** + * 体检预约业务服务。 + * + * 通过 HospitalRouter 委托给当前激活医院的适配器, + * 并承载跨医院共有的组装/汇总逻辑(替代原 OrderServiceImpl 的重复代码)。 + */ +@Component +class OrderService { + + private val log = LoggerFactory.getLogger(OrderService::class.java) + + @Inject + private lateinit var router: HospitalRouter + + // ================= 配置开关(惰性读取,支持热更新) ================= + + /** VO 版本(V1 / V2),默认 V2 */ + private val hmVer: String get() = Solon.cfg().get("hm.ver", "V2")!! + + /** 宁夏:强制发送二级单位编码和名称 */ + private val allsecondFlag: Boolean get() = Solon.cfg().get("hm.allsecondFlag", "false") == "true" + + /** 健康体检不发送职业相关信息 */ + private val occFlag: Boolean get() = Solon.cfg().get("hm.occFlag", "false") == "true" + + /** 当前激活医院的适配器 */ + private fun adapter(): HospitalAdapter = router.active() + + // ================= 体检状态字典 ================= + + /** 体检状态名 → 字典数字 */ + private val peStatusDict = mapOf("预约" to "1", "报到" to "2", "检中" to "3", "初审" to "4", "终审" to "5") + + // ================= 写操作 ================= + + /** + * 提交预约:入参 Map(V1/V2 VO 结构)→ 拼接管道符字符串 → 调医院存储过程。 + * + * @return 成功时返回 peId + */ + fun saveOrder(body: Map): String { + var vo = convertOrderV2(body) + if (allsecondFlag) vo = vo.applyAllsecondFlag() + if (occFlag) vo = vo.applyOccFlag() + + val peInfoStr = vo.toPeInfoStr() + val peItemStr = OrderItemVo.toPeItemStr(vo.itemList) + log.info("提交预约:peInfoStr={}, peItemStr={}", peInfoStr, peItemStr) + + val result = adapter().saveOrder(peInfoStr, peItemStr) + return if (result.isSuccess) { + result.peId ?: throw BusinessException.procFailed("医院未返回体检编号") + } else { + throwHospitalFailed(result.errorMsg) + } + } + + /** 取消预约 */ + fun cancelOrder(peId: String, name: String, idcard: String) { + log.info("取消预约:peId={}, name={}", peId, name) + val result = adapter().cancelOrder(peId, name, idcard) + if (!result.isSuccess) throwHospitalFailed(result.errorMsg) + } + + /** 批量添加/修改单位;返回失败信息(全部成功返回 null) */ + fun addUnits(units: List>): String? { + val errors = StringBuilder() + val useV2 = hmVer == "V2" + for ((index, raw) in units.withIndex()) { + try { + val unit = convertUnit(raw) + val result = adapter().addUnit(unit, useV2) + if (!result.isSuccess) errors.append(result.errorMsg ?: "第${index + 1}条单位同步失败;") + } catch (e: Exception) { + log.warn("addUnit 第{}条处理异常:{}", index + 1, e.message) + errors.append("第${index + 1}条异常:${e.message};") + } + } + return errors.toString().ifEmpty { null } + } + + // ================= 查询操作(直通适配器) ================= + + fun getEmpItem(peId: String) = adapter().getEmpItem(peId) + fun getItemList() = adapter().getItemList() + fun getAllItemList() = adapter().getAllItemList() + fun getPeItemList() = adapter().getPeItemList() + fun getItemInfo(peItemCode: String) = adapter().getItemInfo(peItemCode) + fun getResult(peId: String) = adapter().getResult(peId) + fun getConclusion(peId: String) = adapter().getConclusion(peId) + fun getSuggest(peId: String) = adapter().getSuggest(peId) + fun getResultByIdcardAndYear(idNo: String, year: String) = adapter().getResultByIdcardAndYear(idNo, year) + fun getResultByIdcardAndYearAll(idNo: String, year: String) = adapter().getResultByIdcardAndYearAll(idNo, year) + fun getResultByPeId(peId: String) = adapter().getResultByPeId(peId) + + /** 按审核日期查询体检报告(去重主表) */ + fun getResultByAuditDate(auditDate: String) = adapter().getDisResultByAuditDate(auditDate) + fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String) = + adapter().getResultByIdNoOrPeId(idNo, peId, auditYear) + fun getPeIdAndDateByIdcard(idNo: String) = adapter().getPeIdAndDateByIdcard(idNo) + fun getPeIdAndDateByIdcardList(idNo: String) = adapter().getPeIdAndDateByIdcardList(idNo) + + /** + * 体检状态查询:按医院返回的 PE_STATUS 名转字典数字。 + */ + fun getPhyexamStatus(peIds: String): List> = + adapter().getPhyexamStatus(peIds).map { row -> + mapOf( + "peId" to (row["PE_ID"]?.toString() ?: ""), + "peStatus" to (peStatusDict[row["PE_STATUS"]?.toString() ?: ""] ?: ""), + ) + } + + /** + * 按日期查询当天体检人员列表(日期空则当天)。 + */ + fun getExamListByDate(auditDate: String?) = + adapter().getExamListByDate(auditDate ?: DateUtils.today()) + + /** + * 体检结论(汇总为单字符串)。 + */ + fun getTotalConclusion(peId: String): Map = + mapOf("totalConclusion" to buildSummary(adapter().getConclusion(peId))) + + /** 体检结论(汇总,全量来源) */ + fun getTotalConclusionAll(peId: String): Map = + mapOf("totalConclusion" to buildSummary(adapter().getConclusionAll(peId))) + + /** + * 体检建议(汇总)。宁夏(nx)仅取首条 GUIDE_CONTENT。 + */ + fun getTotalSuggest(peId: String): Map = + mapOf("totalSuggest" to buildSuggest(adapter().getSuggest(peId), adapter().hmType() == "nx")) + + /** 体检建议(汇总,全量来源) */ + fun getTotalSuggestAll(peId: String): Map = + mapOf("totalSuggest" to buildSuggest(adapter().getSuggestAll(peId), adapter().hmType() == "nx")) + + /** + * 按身份证号 + 年份查询体检状态及项目(含宁夏体检报告兜底)。 + */ + fun getPeStatusByIdnoAndYear(idNo: String, year: String): Map? { + val map = adapter().getPeStatusByIdnoAndYear(idNo, year) + return if (map != null && map["PE_ID"] != null) { + val result = map.toMutableMap() + result["peItemList"] = adapter().getEmpItem(map["PE_ID"].toString()) + result + } else { + // 宁夏:从体检报告继续查 + if (adapter().hmType() == "nx") { + val rows = adapter().getResultByIdcardAndYear(idNo, year) + if (rows.isNotEmpty()) { + val row = rows.first() + val auditDate = row["AUDIT_DATE"]?.toString() + buildMap { + put("PE_ID", row["PE_ID"]) + put("PE_PRE_DATE", auditDate?.takeIf { it.length >= 10 }?.substring(0, 10) ?: auditDate) + put("peItemList", adapter().getEmpItem(row["PE_ID"].toString())) + } + } else null + } else null + } + } + + // ================= 私有工具 ================= + + /** 医院侧业务失败:抛出 2003 业务异常 */ + private fun throwHospitalFailed(errorMsg: String?): Nothing = + if (!errorMsg.isNullOrEmpty()) throw BusinessException.of(2003, errorMsg) + else throw BusinessException.procFailed("医院系统返回业务失败") + + /** 结论汇总:1.科室:结论;
*/ + private fun buildSummary(rows: List>): String = + rows.mapIndexed { i, row -> + val dept = row["PE_DEPT_NAME"]?.toString()?.takeIf { it.isNotEmpty() }?.let { "$it:" } ?: "" + "${i + 1}.$dept${row["CONCLUSION_TEXT"] ?: ""};
" + }.joinToString("") + + /** 建议汇总:宁夏仅取首条 GUIDE_CONTENT;其余 1.建议项:内容;
*/ + private fun buildSuggest(rows: List>, nxStyle: Boolean): String = + if (nxStyle) { + rows.firstOrNull()?.get("GUIDE_CONTENT")?.toString() ?: "" + } else { + rows.filter { !it["GUIDE_CONTENT"].toString().isNullOrEmpty() } + .mapIndexed { i, row -> "${i + 1}.${row["REC_CONTENT"] ?: ""}:${row["GUIDE_CONTENT"] ?: ""};
" } + .joinToString("") + } + + /** Map → OrderV2Vo(数值/空值容错) */ + private fun convertOrderV2(body: Map): OrderV2Vo = OrderV2Vo( + orderDate = body["orderDate"]?.toString(), + secondDepartId = body["secondDepartId"]?.toString(), + secondDepartName = body["secondDepartName"]?.toString(), + thirdDepartName = body["thirdDepartName"]?.toString(), + name = body["name"]?.toString(), + sexName = body["sexName"]?.toString(), + birthDay = body["birthDay"]?.toString(), + age = (body["age"] as? Number)?.toInt(), + idCard = body["idCard"]?.toString(), + marrigeStatusName = body["marrigeStatusName"]?.toString(), + country = body["country"]?.toString(), + nationName = body["nationName"]?.toString(), + birthplace = body["birthplace"]?.toString(), + position = body["position"]?.toString(), + profession = body["profession"]?.toString(), + feeType = body["feeType"]?.toString(), + liveSpace = body["liveSpace"]?.toString(), + zipCode = body["zipCode"]?.toString(), + mobile = body["mobile"]?.toString(), + email = body["email"]?.toString(), + empSysno = body["empSysno"]?.toString(), + medicalType = body["medicalType"]?.toString(), + medicalClass = body["medicalClass"]?.toString(), + workShape = body["workShape"]?.toString(), + workDate = body["workDate"]?.toString(), + jobLevel = body["jobLevel"]?.toString(), + workPlace = body["workPlace"]?.toString(), + baseSite = body["baseSite"]?.toString(), + workTypeCode = body["workTypeCode"]?.toString(), + workTypeOther = body["workTypeOther"]?.toString(), + education = body["education"]?.toString(), + title = body["title"]?.toString(), + oldDepartName = body["oldDepartName"]?.toString(), + harmReasonCode = body["harmReasonCode"]?.toString(), + harmReasonOther = body["harmReasonOther"]?.toString(), + harmDate = body["harmDate"]?.toString(), + defendCancer = body["defendCancer"]?.toString(), + itemList = (body["itemList"] as? List<*>)?.mapNotNull { item -> + (item as? Map<*, *>)?.let { + OrderItemVo(it["itemNo"]?.toString(), it["itemName"]?.toString()) + } + }, + ) + + /** Map → UnitVo */ + private fun convertUnit(raw: Map) = com.hospital.front.vo.UnitVo( + unitCode = raw["unitCode"]?.toString(), + unitName = raw["unitName"]?.toString(), + parentUnitCode = raw["parentUnitCode"]?.toString(), + address = raw["address"]?.toString(), + connecter1 = raw["connecter1"]?.toString(), + phone1 = raw["phone1"]?.toString(), + connecter2 = raw["connecter2"]?.toString(), + phone2 = raw["phone2"]?.toString(), + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/task/HeartbeatTask.kt b/src/main/kotlin/com/hospital/front/task/HeartbeatTask.kt new file mode 100644 index 0000000..39af386 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/task/HeartbeatTask.kt @@ -0,0 +1,33 @@ +package com.hospital.front.task + +import com.hospital.front.adapter.HospitalRouter +import org.noear.solon.annotation.Component +import org.noear.solon.annotation.Inject +import org.noear.solon.scheduling.annotation.Scheduled +import org.slf4j.LoggerFactory + +/** + * 心跳任务(占位)。 + * + * 后续 Step 6 实现 Agent 后,本任务会上报心跳到 B 端。 + * 当前仅打印日志,验证 @Scheduled 工作正常。 + * + * @author hospital-front重构 + */ +@Component +class HeartbeatTask { + + private val log = LoggerFactory.getLogger(HeartbeatTask::class.java) + + @Inject + private lateinit var router: HospitalRouter + + /** + * 每 60 秒一次心跳(默认 cron:0/60)。 + * Step 6 将改为上报 B 端的 WS 消息。 + */ + @Scheduled(cron = "0 * * * * *") + fun heartbeat() { + log.debug("[Heartbeat] hospital={}, uptime ok", router.activeCode()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/vo/MedicalMiddleResultVo.kt b/src/main/kotlin/com/hospital/front/vo/MedicalMiddleResultVo.kt new file mode 100644 index 0000000..aba537c --- /dev/null +++ b/src/main/kotlin/com/hospital/front/vo/MedicalMiddleResultVo.kt @@ -0,0 +1,30 @@ +package com.hospital.front.vo + +/** + * 体检结果传输 VO(Mapper XML resultType)。 + * + * 字段名与原 hospitalmiddle 的 MedicalMiddleResultVo 保持一致(XML 映射契约)。 + * 数据库日期列已由 SQL 转为字符串(to_char / CONVERT)。 + */ +data class MedicalMiddleResultVo( + var idNo: String? = null, + var peResult: String? = null, + var hospitalId: String? = null, + var unitCode: String? = null, + var peQueueDate: String? = null, + var printContext: String? = null, + var peItemName: String? = null, + var peId: String? = null, + var name: String? = null, + var itemAssemName: String? = null, + var peItemCode: String? = null, + var auditDate: String? = null, + var itemAssemCode: String? = null, + var unitName: String? = null, + var peVisitId: String? = null, + var unit: String? = null, + var conclusion: String? = null, + var suggest: String? = null, + /** 子表:体检项目结果列表(汇总接口组装用) */ + var resultVoList: List? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/com/hospital/front/vo/OrderVo.kt b/src/main/kotlin/com/hospital/front/vo/OrderVo.kt new file mode 100644 index 0000000..370dc39 --- /dev/null +++ b/src/main/kotlin/com/hospital/front/vo/OrderVo.kt @@ -0,0 +1,169 @@ +package com.hospital.front.vo + +/** + * 预约订单体检项目条目。 + * + * JSON 字段名与原 hospitalmiddle 的 OrderItemVo 保持一致(调用方契约,不可修改)。 + */ +data class OrderItemVo( + val itemNo: String? = null, + val itemName: String? = null, +) { + companion object { + /** + * 拼接子表字符串:多项之间用 & 分隔,项内 编号|名称 分隔。 + */ + fun toPeItemStr(items: List?): String = + items?.joinToString("&") { "${it.itemNo ?: ""}|${it.itemName ?: ""}" } ?: "" + } +} + +/** + * V1 版预约订单 VO(hm.ver=V1 时使用)。 + * + * 字段名与字段顺序均与原 hospitalmiddle 的 OrderVo(Java)保持一致, + * 字段顺序即存储过程 P_ADD_PE_REGIST 的 peInfoStr 竖线分隔顺序,不可调整。 + */ +data class OrderVo( + val orderDate: String? = null, // 1 预约日期 yyyy-MM-dd + val secondDepartId: String? = null, // 2 所属单位编码 + val secondDepartName: String? = null, // 3 所属单位名称 + val thirdDepartName: String? = null, // 4 所属部门 + val name: String? = null, // 5 姓名 + val sexName: String? = null, // 6 性别(男/女) + val birthDay: String? = null, // 7 出生日期 yyyy-MM-dd + val age: Int? = null, // 8 年龄 + val idCard: String? = null, // 9 身份证号 + val marrigeStatusName: String? = null, // 10 婚姻 + val country: String? = null, // 11 国家 + val nationName: String? = null, // 12 民族 + val birthplace: String? = null, // 13 出生地 + val position: String? = null, // 14 身份 + val profession: String? = null, // 15 职业 + val feeType: String? = null, // 16 费别 + val liveSpace: String? = null, // 17 家庭住址 + val zipCode: String? = null, // 18 邮编 + val mobile: String? = null, // 19 移动电话 + val email: String? = null, // 20 邮箱 + val empSysno: String? = null, // 21 员工编号 + val medicalType: String? = null, // 22 体检类型(职业/健康) + val medicalClass: String? = null, // 23 体检类别(岗前/在岗期间) + val workShape: String? = null, // 24 用工形式(合同/劳务) + val workDate: String? = null, // 25 入职时间 yyyy-MM-dd + val jobLevel: String? = null, // 26 职务级别 + val workPlace: String? = null, // 27 工作地 + val baseSite: String? = null, // 28 基层站 + val workType: String? = null, // 29 职业工种(V1 单字段) + val education: String? = null, // 30 学历 + val title: String? = null, // 31 职称 + val oldDepartName: String? = null, // 32 原单位 + val harmReason: String? = null, // 33 危害因素(V1 单字段) + val defendCancer: String? = null, // 34 防癌项 + val itemList: List? = null, +) { + /** + * 按存储过程约定顺序拼接主表字符串(竖线分隔,空值为空串)。 + */ + fun toPeInfoStr(): String = listOf( + orderDate, secondDepartId, secondDepartName, thirdDepartName, name, + sexName, birthDay, age, idCard, marrigeStatusName, + country, nationName, birthplace, position, profession, + feeType, liveSpace, zipCode, mobile, email, + empSysno, medicalType, medicalClass, workShape, workDate, + jobLevel, workPlace, baseSite, workType, education, + title, oldDepartName, harmReason, defendCancer, + ).joinToString("|") { it?.toString() ?: "" } +} + +/** + * V2 版预约订单 VO(hm.ver=V2 时使用)。 + * + * 与 V1 的差异:工种拆为 编码+其它;危害因素拆为 编码+其它+接害时间。 + */ +data class OrderV2Vo( + val orderDate: String? = null, // 1 预约日期 yyyy-MM-dd + val secondDepartId: String? = null, // 2 所属单位编码 + val secondDepartName: String? = null, // 3 所属单位名称 + val thirdDepartName: String? = null, // 4 所属部门 + val name: String? = null, // 5 姓名 + val sexName: String? = null, // 6 性别(男/女) + val birthDay: String? = null, // 7 出生日期 yyyy-MM-dd + val age: Int? = null, // 8 年龄 + val idCard: String? = null, // 9 身份证号 + val marrigeStatusName: String? = null, // 10 婚姻 + val country: String? = null, // 11 国家 + val nationName: String? = null, // 12 民族 + val birthplace: String? = null, // 13 出生地 + val position: String? = null, // 14 身份 + val profession: String? = null, // 15 职业 + val feeType: String? = null, // 16 费别 + val liveSpace: String? = null, // 17 家庭住址 + val zipCode: String? = null, // 18 邮编 + val mobile: String? = null, // 19 移动电话 + val email: String? = null, // 20 邮箱 + val empSysno: String? = null, // 21 员工编号 + val medicalType: String? = null, // 22 体检类型(职业/健康) + val medicalClass: String? = null, // 23 体检类别(岗前/在岗期间) + val workShape: String? = null, // 24 用工形式(合同/劳务) + val workDate: String? = null, // 25 入职时间 yyyy-MM-dd + val jobLevel: String? = null, // 26 职务级别 + val workPlace: String? = null, // 27 工作地 + val baseSite: String? = null, // 28 基层站 + val workTypeCode: String? = null, // 29 职业工种编码(V2) + val workTypeOther: String? = null, // 29b 其它工种(V2) + val education: String? = null, // 30 学历 + val title: String? = null, // 31 职称 + val oldDepartName: String? = null, // 32 原单位 + val harmReasonCode: String? = null, // 33 危害因素编码(V2) + val harmReasonOther: String? = null, // 33b 其它危害因素(V2) + val harmDate: String? = null, // 33c 接害时间 yyyy-MM-dd(V2) + val defendCancer: String? = null, // 34 防癌项 + val itemList: List? = null, +) { + /** + * 按存储过程约定顺序拼接主表字符串(竖线分隔,空值为空串)。 + */ + fun toPeInfoStr(): String = listOf( + orderDate, secondDepartId, secondDepartName, thirdDepartName, name, + sexName, birthDay, age, idCard, marrigeStatusName, + country, nationName, birthplace, position, profession, + feeType, liveSpace, zipCode, mobile, email, + empSysno, medicalType, medicalClass, workShape, workDate, + jobLevel, workPlace, baseSite, workTypeCode, workTypeOther, + education, title, oldDepartName, harmReasonCode, harmReasonOther, + harmDate, defendCancer, + ).joinToString("|") { it?.toString() ?: "" } + + /** + * allsecondFlag 特殊处理(宁夏):二级单位编码 9 位截前 6 位,名称截"-"之前。 + */ + fun applyAllsecondFlag(): OrderV2Vo { + val newId = secondDepartId?.takeIf { it.length == 9 }?.substring(0, 6) ?: secondDepartId + val newName = secondDepartName?.takeIf { it.contains("-") }?.substringBefore("-") ?: secondDepartName + return copy(secondDepartId = newId, secondDepartName = newName) + } + + /** + * occFlag 特殊处理:健康体检不发送职业相关信息(危害因素、工种、接害时间)。 + */ + fun applyOccFlag(): OrderV2Vo = + if (medicalType == "职业") this + else copy( + harmDate = null, harmReasonCode = null, harmReasonOther = null, + workTypeCode = null, workTypeOther = null, + ) +} + +/** + * 单位信息 VO(addUnit 接口入参,字段名与原 UnitVo 一致)。 + */ +data class UnitVo( + val unitCode: String? = null, // 单位编码 + val unitName: String? = null, // 单位名称 + val parentUnitCode: String? = null, // 上级体检单位编号(V2) + val address: String? = null, // 单位地址(V2) + val connecter1: String? = null, // 联系人1(V2) + val phone1: String? = null, // 电话1(V2) + val connecter2: String? = null, // 联系人2(V2) + val phone2: String? = null, // 电话2(V2) +) \ No newline at end of file diff --git a/src/main/resources/app-dev.yml b/src/main/resources/app-dev.yml new file mode 100644 index 0000000..ad38d67 --- /dev/null +++ b/src/main/resources/app-dev.yml @@ -0,0 +1,19 @@ +# ==================================== +# 开发环境配置(H2 内存数据库 Mock) +# 启动:java -Dsolon.env=dev -Dsolon.config.load=app.yml,app-dev.yml -jar hospital-front-1.0.0.jar +# ==================================== +# 本机无法直连医院数据库时使用;H2 以 Oracle 兼容模式运行 + +hospital: + active: dev # 启动激活"开发医院" + +hm: + type: bsm + ver: V2 + +solon.dataSources: + db1!: + class: "org.h2.jdbcx.JdbcDataSource" + url: "jdbc:h2:mem:dev;MODE=Oracle;DB_CLOSE_DELAY=-1" + username: sa + password: "" \ No newline at end of file diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml new file mode 100644 index 0000000..0aa9d61 --- /dev/null +++ b/src/main/resources/app.yml @@ -0,0 +1,63 @@ +# ==================================== +# Solon 主配置(框架级 + MyBatis 公共配置) +# ==================================== +# 文件编码:UTF-8 +# 数据源在各医院配置(pac/conf/app-{医院}.yml)中以 solon.dataSources.db1 定义 + +server: + port: 8082 + +solon: + app: + name: "hospital-front" + group: "hospital" + +# 应用配置 +hospital: + active: "jinghe" # 启动激活哪家医院 + adapters: # 注册的医院清单(启动自检) + - "xinglongyuan" + - "qingyang" + - "ningxia" + - "yanan" + - "jinghe" + +# ==================================== +# 业务开关 +# ==================================== +hm: + type: "bsm" # Mapper 方言:bsm(Oracle)/ nx / ya(SqlServer) + ver: "V2" # VO 版本 + allsecondFlag: false # 宁夏医院强制发送二级单位编码和名称 + occFlag: false # 健康体检不发送职业相关信息 + +# ==================================== +# MyBatis-Plus 公共配置(db1 为唯一数据源,随医院配置注入) +# mappers 条目两种形式(Solon mybatis 约定): +# - 接口包名:扫描注册 Mapper 接口 +# - .xml 资源路径:XMLMapperBuilder 加载语句(与接口包同路径) +# ==================================== +mybatis.db1: + typeAliases: + - "com.hospital.front.vo" + mappers: + - "com.hospital.front.mapper.bsm" + - "com.hospital.front.mapper.nx" + - "com.hospital.front.mapper.ya" + - "com/hospital/front/mapper/bsm/OrderMapper.xml" + - "com/hospital/front/mapper/nx/OrderMapper.xml" + - "com/hospital/front/mapper/ya/OrderMapper.xml" + configuration: + mapUnderscoreToCamelCase: true + logImpl: "org.apache.ibatis.logging.slf4j.Slf4jImpl" + globalConfig: + banner: false + +# ==================================== +# 日志 +# ==================================== +logging: + level: + root: INFO + com.hospital.front: INFO + org.noear: WARN \ No newline at end of file diff --git a/src/main/resources/com/hospital/front/mapper/bsm/BsmOrderMapper.xml b/src/main/resources/com/hospital/front/mapper/bsm/BsmOrderMapper.xml new file mode 100644 index 0000000..4af715c --- /dev/null +++ b/src/main/resources/com/hospital/front/mapper/bsm/BsmOrderMapper.xml @@ -0,0 +1,420 @@ + + + + + + + + + + + + + + + + {call P_ADD_PE_REGIST( + #{peInfoStr,mode=IN,jdbcType=VARCHAR}, + #{peItemStr,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR}, + #{peId,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + {call P_DEL_PE_REGIST( + #{peId,mode=IN,jdbcType=VARCHAR}, + #{name,mode=IN,jdbcType=VARCHAR}, + #{idcard,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + + {call P_ADD_UNIT( + #{unitCode,mode=IN,jdbcType=VARCHAR}, + #{unitName,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + + + + + + {call P_ADD_UNIT( + #{unitCode,mode=IN,jdbcType=VARCHAR}, + #{unitName,mode=IN,jdbcType=VARCHAR}, + #{parentUnitCode,mode=IN,jdbcType=VARCHAR}, + #{address,mode=IN,jdbcType=VARCHAR}, + #{connecter1,mode=IN,jdbcType=VARCHAR}, + #{phone1,mode=IN,jdbcType=VARCHAR}, + #{connecter2,mode=IN,jdbcType=VARCHAR}, + #{phone2,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/com/hospital/front/mapper/nx/NxOrderMapper.xml b/src/main/resources/com/hospital/front/mapper/nx/NxOrderMapper.xml new file mode 100644 index 0000000..5d2924a --- /dev/null +++ b/src/main/resources/com/hospital/front/mapper/nx/NxOrderMapper.xml @@ -0,0 +1,416 @@ + + + + + + + + + + + + + + + + {call P_ADD_PE_REGIST( + #{peInfoStr,mode=IN,jdbcType=VARCHAR}, + #{peItemStr,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR}, + #{peId,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + {call P_DEL_PE_REGIST( + #{peId,mode=IN,jdbcType=VARCHAR}, + #{name,mode=IN,jdbcType=VARCHAR}, + #{idcard,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + + + + + + + {call P_ADD_UNIT( + #{unitCode,mode=IN,jdbcType=VARCHAR}, + #{unitName,mode=IN,jdbcType=VARCHAR}, + #{parentUnitCode,mode=IN,jdbcType=VARCHAR}, + #{address,mode=IN,jdbcType=VARCHAR}, + #{connecter1,mode=IN,jdbcType=VARCHAR}, + #{phone1,mode=IN,jdbcType=VARCHAR}, + #{connecter2,mode=IN,jdbcType=VARCHAR}, + #{phone2,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/com/hospital/front/mapper/ya/YaOrderMapper.xml b/src/main/resources/com/hospital/front/mapper/ya/YaOrderMapper.xml new file mode 100644 index 0000000..687ca56 --- /dev/null +++ b/src/main/resources/com/hospital/front/mapper/ya/YaOrderMapper.xml @@ -0,0 +1,415 @@ + + + + + + + + + + + + + + + + {call P_OrderRegister_YARMYY( + #{peInfoStr,mode=IN,jdbcType=VARCHAR}, + #{peItemStr,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR}, + #{peId,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + {call P_CANCELORDER( + #{peId,mode=IN,jdbcType=VARCHAR}, + #{name,mode=IN,jdbcType=VARCHAR}, + #{idcard,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + {call P_ORGINFOUPDATE( + #{unitCode,mode=IN,jdbcType=VARCHAR}, + #{unitName,mode=IN,jdbcType=VARCHAR}, + #{resultCode,mode=OUT,jdbcType=VARCHAR}, + #{errorMsg,mode=OUT,jdbcType=VARCHAR} + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..2eaa0f2 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{40} - %msg%n + + + + + + ${LOG_PATH}/info.log + + ${LOG_PATH}/info.%d{yyyy-MM-dd}.%i.log.zip + 50MB + 30 + 5GB + + + %date [%thread] %-5level [%logger{50}] %file:%line - %msg%n + + + + + + ${LOG_PATH}/error.log + + ${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log.zip + 50MB + 30 + 2GB + + + %date [%thread] %-5level [%logger{50}] %file:%line - %msg%n + + + ERROR + + + + + + 2048 + 0 + true + + + + + 1024 + 0 + true + + + + + + + + + + + + + + + + + + + \ No newline at end of file