refactor: Kotlin→Java 迁移 + Agent 拆分为独立 Python 项目

- 业务代码全部迁移至 src/main/java(Lombok 替代 Kotlin 数据类),pom 移除 Kotlin 依赖
- Agent(WS客户端/看门狗/日志流/升级)拆出至 hospital-front-agent 独立 Python 仓库,本仓库删除 agent 包
- pac/ 部署目录移除:app-jinghe.yml 迁入 resources,部署脚本与配置由 agent 仓库及部署文档承接
- BsmOrderMapper.xml SQL 调整、app.yml 更新

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:22:01 +08:00
co-authored by Claude
parent 15ab717317
commit d659c4c030
74 changed files with 3432 additions and 3250 deletions
-49
View File
@@ -1,49 +0,0 @@
# 外置配置示例(部署时拷贝到 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
-29
View File
@@ -1,29 +0,0 @@
# ====================================
# 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 用 .batLinux 用 .sh
main.start.cmd=bin/start.bat
# 心跳间隔(秒)
heartbeat.sec=30
# 是否守护主程序(宕机自动拉起)
guard.enable=true
-18
View File
@@ -1,18 +0,0 @@
# 宁夏宝石花医院 → 本地 SqlServer 测试库(mac204nx 方言)
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
-32
View File
@@ -1,32 +0,0 @@
# ====================================
# 庆阳医院(先与 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
# 数据源(HikariCPSolon 默认连接池;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 自动识别
-32
View File
@@ -1,32 +0,0 @@
# ====================================
# 兴隆园医院 配置
# 文件路径: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
# 数据源(HikariCPSolon 默认连接池;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 自动识别
-18
View File
@@ -1,18 +0,0 @@
# 延安医院 → 本地 SqlServer 测试库(mac204ya 方言)
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
+9 -53
View File
@@ -29,8 +29,6 @@
<java.version>17</java.version> <java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<kotlin.version>2.0.21</kotlin.version>
<kotlin.jvmTarget>17</kotlin.jvmTarget>
<!-- 数据库驱动(solon-parent 不管理) --> <!-- 数据库驱动(solon-parent 不管理) -->
<ojdbc.version>23.3.0.23.09</ojdbc.version> <ojdbc.version>23.3.0.23.09</ojdbc.version>
@@ -42,6 +40,14 @@
</properties> </properties>
<dependencies> <dependencies>
<!-- ========== Lombok(简化 POJOgetter/setter/toString 等) ========== -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.34</version>
<scope>provided</scope>
</dependency>
<!-- ========== Solon 核心 ========== --> <!-- ========== Solon 核心 ========== -->
<dependency> <dependency>
<groupId>org.noear</groupId> <groupId>org.noear</groupId>
@@ -134,13 +140,6 @@
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<!-- ========== Kotlin 标准库 ========== -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
<!-- ========== JSON 解析(Solon 生态标准库) ========== --> <!-- ========== JSON 解析(Solon 生态标准库) ========== -->
<dependency> <dependency>
<groupId>org.noear</groupId> <groupId>org.noear</groupId>
@@ -153,55 +152,12 @@
<artifactId>solon-serialization-snack3</artifactId> <artifactId>solon-serialization-snack3</artifactId>
</dependency> </dependency>
<!-- ========== 测试 ========== -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
<finalName>hospital-front-${project.version}</finalName> <finalName>hospital-front-${project.version}</finalName>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins> <plugins>
<!-- Kotlin 编译 -->
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<configuration>
<compilerPlugins>
<plugin>spring</plugin>
</compilerPlugins>
<jvmTarget>${kotlin.jvmTarget}</jvmTarget>
</configuration>
<executions>
<execution>
<id>compile</id>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
<!-- 打 fat jar(包含所有依赖) --> <!-- 打 fat jar(包含所有依赖) -->
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
@@ -217,7 +173,7 @@
<createDependencyReducedPom>false</createDependencyReducedPom> <createDependencyReducedPom>false</createDependencyReducedPom>
<transformers> <transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.hospital.front.HospitalFrontAppKt</mainClass> <mainClass>com.hospital.front.HospitalFrontApp</mainClass>
</transformer> </transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/> <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers> </transformers>
@@ -0,0 +1,64 @@
package com.hospital.front;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
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
public class HospitalFrontApp {
/**
* 主入口。
*/
public static void main(String[] args) {
// TODO 临时诊断代码:打印完整异常因果链,定位注入失败根因后移除
try {
Solon.start(HospitalFrontApp.class, args);
} catch (Throwable e) {
Throwable t = e;
int depth = 0;
while (t != null && depth < 10) {
System.err.println("=== 因果链[" + depth + "]: " + t.getClass().getName() + ": " + t.getMessage());
StackTraceElement[] stackTrace = t.getStackTrace();
for (int i = 0; i < Math.min(10, stackTrace.length); i++) {
System.err.println(" at " + stackTrace[i]);
}
t = t.getCause();
depth++;
}
System.exit(1);
}
long pid = ProcessHandle.current().pid();
System.out.println("[HospitalFront] 启动完成,PID=" + pid);
// 写 PID 文件(供 start.bat stop / Agent 升级流程强杀使用)
try {
File dir = new File("pid");
if (!dir.exists()) {
dir.mkdirs();
}
Files.write(Paths.get(dir.getPath(), "hospital-front.pid"), String.valueOf(pid).getBytes());
} catch (Exception ignored) {
}
// 注册停机钩子:清理 PID + 记录优雅退出日志
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
new File("pid/hospital-front.pid").delete();
} catch (Exception ignored) {
}
System.out.println("[HospitalFront] 优雅停机中...");
}));
}
}
@@ -0,0 +1,203 @@
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;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* BSM 方言适配器基类(Oracle)。
*
* 适用医院:xinglongyuan(兴隆园)/ jinghe(泾河)/ qingyang(庆阳)。
* 由具体医院子类提供 code / displayName,其余行为完全一致。
*/
public abstract class BsmAdapterBase implements HospitalAdapter {
/** 数据库 MapperSolon 按类型注入) */
@Inject
protected BsmOrderMapper mapper;
@Override
public String hmType() {
return "bsm";
}
@Override
public Result<Boolean> healthCheck() {
try {
return Result.success(mapper.ping() == 1);
} catch (Exception e) {
return Result.error(Result.CODE_DB_UNREACHABLE, "数据库连接失败:" + e.getMessage());
}
}
@Override
public ProcResult saveOrder(String peInfoStr, String peItemStr) {
Map<String, Object> map = new HashMap<>();
map.put("peInfoStr", peInfoStr);
map.put("peItemStr", peItemStr);
mapper.saveOrder(map);
return new ProcResult(
str(map.get("resultCode")),
str(map.get("errorMsg")),
str(map.get("peId"))
);
}
@Override
public ProcResult cancelOrder(String peId, String name, String idcard) {
Map<String, Object> map = new HashMap<>();
map.put("peId", peId);
map.put("name", name);
map.put("idcard", idcard);
mapper.cancelOrder(map);
return new ProcResult(
str(map.get("resultCode")),
str(map.get("errorMsg")),
null
);
}
@Override
public ProcResult addUnit(UnitVo unit, boolean useV2) {
Map<String, Object> map = new HashMap<>();
map.put("unitCode", unit.getUnitCode());
map.put("unitName", unit.getUnitName());
map.put("parentUnitCode", unit.getParentUnitCode());
map.put("address", unit.getAddress());
map.put("connecter1", unit.getConnecter1());
map.put("phone1", unit.getPhone1());
map.put("connecter2", unit.getConnecter2());
map.put("phone2", unit.getPhone2());
if (useV2) {
mapper.addUnitV2(map);
} else {
mapper.addUnit(map);
}
return new ProcResult(
str(map.get("resultCode")),
str(map.get("errorMsg")),
null
);
}
@Override
public List<Map<String, Object>> getPhyexamStatus(String peIds) {
return mapper.getPhyexamStatus(peIds);
}
@Override
public List<Map<String, Object>> getItemList() {
return mapper.getItemList();
}
@Override
public List<Map<String, Object>> getAllItemList() {
return mapper.getAllItemList();
}
@Override
public List<Map<String, Object>> getPeItemList() {
return mapper.getPeItemList();
}
@Override
public List<Map<String, Object>> getItemInfo(String peItemCode) {
return mapper.getItemInfo(peItemCode);
}
@Override
public List<Map<String, Object>> getEmpItem(String peId) {
return mapper.getEmpItem(peId);
}
@Override
public List<Map<String, Object>> getResult(String peId) {
return mapper.getResult(peId);
}
@Override
public List<Map<String, Object>> getConclusion(String peId) {
return mapper.getConclusion(peId);
}
@Override
public List<Map<String, Object>> getConclusionAll(String peId) {
return mapper.getConclusionAll(peId);
}
@Override
public List<Map<String, Object>> getSuggest(String peId) {
return mapper.getSuggest(peId);
}
@Override
public List<Map<String, Object>> getSuggestAll(String peId) {
return mapper.getSuggestAll(peId);
}
@Override
public Map<String, Object> getPeStatusByIdnoAndYear(String idNo, String year) {
return mapper.getPeStatusByIdnoAndYear(idNo, year);
}
@Override
public List<Map<String, Object>> getResultByIdcardAndYear(String idNo, String year) {
return mapper.getResultByIdcardAndYear(idNo, year);
}
@Override
public List<Map<String, Object>> getResultByIdcardAndYearAll(String idNo, String year) {
return mapper.getResultByIdcardAndYearAll(idNo, year);
}
@Override
public List<Map<String, Object>> getResultByPeId(String peId) {
return mapper.getResultByPeId(peId);
}
@Override
public List<Map<String, Object>> getDisResultByAuditDate(String auditDate) {
return mapper.getDisResultByAuditDate(auditDate);
}
@Override
public List<Map<String, Object>> getItemByPeId(String peId) {
return mapper.getItemByPeId(peId);
}
@Override
public List<Map<String, Object>> getItemByPeIdAll(String peId) {
return mapper.getItemByPeIdAll(peId);
}
@Override
public List<Map<String, Object>> getResultByIdNoOrPeId(String idNo, String peId, String auditYear) {
return mapper.getResultByIdNoOrPeId(idNo, peId, auditYear);
}
@Override
public Map<String, Object> getPeIdAndDateByIdcard(String idNo) {
return mapper.getPeIdAndDateByIdcard(idNo);
}
@Override
public List<Map<String, Object>> getPeIdAndDateByIdcardList(String idNo) {
return mapper.getPeIdAndDateByIdcardList(idNo);
}
@Override
public List<Map<String, Object>> getExamListByDate(String auditDate) {
return mapper.getExamListByDate(auditDate);
}
/** 等价于 Kotlin 的 map[key]?.toString():值为 null 时返回 null,否则转字符串 */
private static String str(Object value) {
return value == null ? null : String.valueOf(value);
}
}
@@ -0,0 +1,165 @@
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;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 开发环境适配器(hospital.active=dev 时激活)。
*
* 不连接任何数据库:查询一律返回空列表,存储过程一律返回失败。
* 用于本机无医院数据库时的启动与联调。
*/
@Component
public class DevAdapter implements HospitalAdapter {
@Override
public String code() {
return "dev";
}
@Override
public String displayName() {
return "开发环境(Mock";
}
@Override
public String hmType() {
return "dev";
}
@Override
public Result<Boolean> healthCheck() {
return Result.success(true);
}
@Override
public ProcResult saveOrder(String peInfoStr, String peItemStr) {
return new ProcResult("0", "开发环境不支持预约提交", null);
}
@Override
public ProcResult cancelOrder(String peId, String name, String idcard) {
return new ProcResult("0", "开发环境不支持取消预约", null);
}
@Override
public ProcResult addUnit(UnitVo unit, boolean useV2) {
return new ProcResult("0", "开发环境不支持单位同步", null);
}
@Override
public List<Map<String, Object>> getPhyexamStatus(String peIds) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getItemList() {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getAllItemList() {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getPeItemList() {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getItemInfo(String peItemCode) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getEmpItem(String peId) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getResult(String peId) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getConclusion(String peId) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getConclusionAll(String peId) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getSuggest(String peId) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getSuggestAll(String peId) {
return Collections.emptyList();
}
@Override
public Map<String, Object> getPeStatusByIdnoAndYear(String idNo, String year) {
return null;
}
@Override
public List<Map<String, Object>> getResultByIdcardAndYear(String idNo, String year) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getResultByIdcardAndYearAll(String idNo, String year) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getResultByPeId(String peId) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getDisResultByAuditDate(String auditDate) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getItemByPeId(String peId) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getItemByPeIdAll(String peId) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getResultByIdNoOrPeId(String idNo, String peId, String auditYear) {
return Collections.emptyList();
}
@Override
public Map<String, Object> getPeIdAndDateByIdcard(String idNo) {
return null;
}
@Override
public List<Map<String, Object>> getPeIdAndDateByIdcardList(String idNo) {
return Collections.emptyList();
}
@Override
public List<Map<String, Object>> getExamListByDate(String auditDate) {
return Collections.emptyList();
}
}
@@ -0,0 +1,106 @@
package com.hospital.front.adapter;
import com.hospital.front.dto.ProcResult;
import com.hospital.front.dto.Result;
import com.hospital.front.vo.UnitVo;
import java.util.List;
import java.util.Map;
/**
* 医院适配器接口(策略模式)。
*
* 5 家医院各自实现本接口,通过 HospitalRouter 按医院编码路由。
* 所有数据库差异(Oracle/SqlServer 方言、存储过程名)均封装在实现内;
* 存储过程的入参出参契约由医院 HIS 系统约定,不可修改。
*
* 查询类方法统一返回 Map 列表(列名与医院视图/存储过程保持一致)。
*/
public interface HospitalAdapter {
/** 医院编码(拼音):xinglongyuan / qingyang / ningxia / yanan / jinghe / dev */
String code();
/** 医院中文名(日志与展示用) */
String displayName();
/** Mapper 方言类型:bsmOracle/ nx / yaSqlServer */
String hmType();
/** 轻量健康检查(SELECT 1 探测) */
Result<Boolean> healthCheck();
// ================= 存储过程(写) =================
/** 提交预约(P_ADD_PE_REGIST */
ProcResult saveOrder(String peInfoStr, String peItemStr);
/** 取消预约(P_DEL_PE_REGIST */
ProcResult cancelOrder(String peId, String name, String idcard);
/** 添加/修改单位(P_ADD_UNIT */
ProcResult addUnit(UnitVo unit, boolean useV2);
// ================= 查询(视图) =================
/** 按体检预约 ID(逗号分隔多个)查询体检状态 */
List<Map<String, Object>> getPhyexamStatus(String peIds);
/** 医院所有体检项目(组合项) */
List<Map<String, Object>> getItemList();
/** 医院所有体检项目及指标项 */
List<Map<String, Object>> getAllItemList();
/** 医院所有体检指标项 */
List<Map<String, Object>> getPeItemList();
/** 按指标项 CODE 查询所属体检组合 */
List<Map<String, Object>> getItemInfo(String peItemCode);
/** 员工体检项目 */
List<Map<String, Object>> getEmpItem(String peId);
/** 员工体检结果 */
List<Map<String, Object>> getResult(String peId);
/** 体检结论(列表 / 全量) */
List<Map<String, Object>> getConclusion(String peId);
List<Map<String, Object>> getConclusionAll(String peId);
/** 体检建议(列表 / 全量) */
List<Map<String, Object>> getSuggest(String peId);
List<Map<String, Object>> getSuggestAll(String peId);
/** 按身份证号 + 年份查询体检状态 */
Map<String, Object> getPeStatusByIdnoAndYear(String idNo, String year);
/** 按身份证号 + 年份查询体检结果(默认 / 全量) */
List<Map<String, Object>> getResultByIdcardAndYear(String idNo, String year);
List<Map<String, Object>> getResultByIdcardAndYearAll(String idNo, String year);
/** 按体检编号查询体检结果 */
List<Map<String, Object>> getResultByPeId(String peId);
/** 按审核日期查询体检报告(去重主表) */
List<Map<String, Object>> getDisResultByAuditDate(String auditDate);
/** 按体检编号查询体检项目子表(默认 / 全量) */
List<Map<String, Object>> getItemByPeId(String peId);
List<Map<String, Object>> getItemByPeIdAll(String peId);
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
List<Map<String, Object>> getResultByIdNoOrPeId(String idNo, String peId, String auditYear);
/** 按身份证号查询当年 peId 与预约日期(单条 / 列表) */
Map<String, Object> getPeIdAndDateByIdcard(String idNo);
List<Map<String, Object>> getPeIdAndDateByIdcardList(String idNo);
/** 按审核日期查询当天体检人员列表(去重;日期空则当天) */
List<Map<String, Object>> getExamListByDate(String auditDate);
}
@@ -0,0 +1,75 @@
package com.hospital.front.adapter;
import org.noear.solon.Solon;
import org.noear.solon.annotation.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 医院适配器路由。
*
* 通过 Solon.context().getBeanOfType 在首次使用时动态获取所有 HospitalAdapter 实现,
* 避免依赖注入时序问题。
*/
@Component
public class HospitalRouter {
/** 启动激活的医院编码(默认 jinghe) */
private final String activeCode = Solon.cfg().get("hospital.active", "jinghe");
/** 适配器映射(懒加载,等价于 Kotlin 的 by lazy */
private volatile Map<String, HospitalAdapter> map;
public HospitalRouter() {
System.out.println("[HospitalRouter] 启动激活医院:" + activeCode);
}
/**
* 动态拉取所有 HospitalAdapter 实现(每次调用都重新拉取,避免 bean 注入时序问题)。
*/
private List<HospitalAdapter> listAdapters() {
return Solon.context().getBeansOfType(HospitalAdapter.class);
}
/** 获取适配器映射(首次调用时构建) */
private Map<String, HospitalAdapter> getMap() {
if (map == null) {
synchronized (this) {
if (map == null) {
map = new HashMap<>();
for (HospitalAdapter adapter : listAdapters()) {
map.put(adapter.code(), adapter);
}
}
}
}
return map;
}
/** 获取指定医院编码的适配器 */
public HospitalAdapter get(String code) {
HospitalAdapter adapter = getMap().get(code);
if (adapter == null) {
throw new IllegalStateException("未注册医院适配器:" + code + "(已注册:" + getMap().keySet() + "");
}
return adapter;
}
/** 获取启动激活的医院适配器 */
public HospitalAdapter active() {
return get(activeCode);
}
/** 当前激活医院编码 */
public String activeCode() {
return activeCode;
}
/** 所有已注册的医院编码 */
public Set<String> registeredCodes() {
return getMap().keySet();
}
}
@@ -0,0 +1,20 @@
package com.hospital.front.adapter;
import org.noear.solon.annotation.Component;
/**
* 泾河医院适配器(Oracle,bsm 方言,默认激活医院)。
*/
@Component
public class JingheAdapter extends BsmAdapterBase {
@Override
public String code() {
return "jinghe";
}
@Override
public String displayName() {
return "泾河医院";
}
}
@@ -0,0 +1,23 @@
package com.hospital.front.adapter;
import org.noear.solon.annotation.Component;
/**
* 宁夏宝石花医院适配器(SqlServer,nx 方言)。
*
* 业务开关(在 OrderService 中按配置生效):
* - hm.allsecondFlag=true:强制二级单位编码/名称截断
*/
@Component
public class NingxiaAdapter extends NxAdapterBase {
@Override
public String code() {
return "ningxia";
}
@Override
public String displayName() {
return "宁夏宝石花医院";
}
}
@@ -0,0 +1,199 @@
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;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* NX 方言适配器基类(SqlServer)。
*
* 适用医院:ningxia(宁夏宝石花)。
*/
public abstract class NxAdapterBase implements HospitalAdapter {
/** 数据库 MapperSolon 按类型注入) */
@Inject
protected NxOrderMapper mapper;
@Override
public String hmType() {
return "nx";
}
@Override
public Result<Boolean> healthCheck() {
try {
return Result.success(mapper.ping() == 1);
} catch (Exception e) {
return Result.error(Result.CODE_DB_UNREACHABLE, "数据库连接失败:" + e.getMessage());
}
}
@Override
public ProcResult saveOrder(String peInfoStr, String peItemStr) {
Map<String, Object> map = new HashMap<>();
map.put("peInfoStr", peInfoStr);
map.put("peItemStr", peItemStr);
mapper.saveOrder(map);
return new ProcResult(
str(map.get("resultCode")),
str(map.get("errorMsg")),
str(map.get("peId"))
);
}
@Override
public ProcResult cancelOrder(String peId, String name, String idcard) {
Map<String, Object> map = new HashMap<>();
map.put("peId", peId);
map.put("name", name);
map.put("idcard", idcard);
mapper.cancelOrder(map);
return new ProcResult(
str(map.get("resultCode")),
str(map.get("errorMsg")),
null
);
}
@Override
public ProcResult addUnit(UnitVo unit, boolean useV2) {
// 宁夏使用 addUnitV2(原 OrderServiceImpl 中 nx 分支固定走 V2
Map<String, Object> map = new HashMap<>();
map.put("unitCode", unit.getUnitCode());
map.put("unitName", unit.getUnitName());
map.put("parentUnitCode", unit.getParentUnitCode());
map.put("address", unit.getAddress());
map.put("connecter1", unit.getConnecter1());
map.put("phone1", unit.getPhone1());
map.put("connecter2", unit.getConnecter2());
map.put("phone2", unit.getPhone2());
mapper.addUnitV2(map);
return new ProcResult(
str(map.get("resultCode")),
str(map.get("errorMsg")),
null
);
}
@Override
public List<Map<String, Object>> getPhyexamStatus(String peIds) {
return mapper.getPhyexamStatus(peIds);
}
@Override
public List<Map<String, Object>> getItemList() {
return mapper.getItemList();
}
@Override
public List<Map<String, Object>> getAllItemList() {
return mapper.getAllItemList();
}
@Override
public List<Map<String, Object>> getPeItemList() {
return mapper.getPeItemList();
}
@Override
public List<Map<String, Object>> getItemInfo(String peItemCode) {
return mapper.getItemInfo(peItemCode);
}
@Override
public List<Map<String, Object>> getEmpItem(String peId) {
return mapper.getEmpItem(peId);
}
@Override
public List<Map<String, Object>> getResult(String peId) {
return mapper.getResult(peId);
}
@Override
public List<Map<String, Object>> getConclusion(String peId) {
return mapper.getConclusion(peId);
}
@Override
public List<Map<String, Object>> getConclusionAll(String peId) {
return mapper.getConclusionAll(peId);
}
@Override
public List<Map<String, Object>> getSuggest(String peId) {
return mapper.getSuggest(peId);
}
@Override
public List<Map<String, Object>> getSuggestAll(String peId) {
return mapper.getSuggestAll(peId);
}
@Override
public Map<String, Object> getPeStatusByIdnoAndYear(String idNo, String year) {
return mapper.getPeStatusByIdnoAndYear(idNo, year);
}
@Override
public List<Map<String, Object>> getResultByIdcardAndYear(String idNo, String year) {
return mapper.getResultByIdcardAndYear(idNo, year);
}
@Override
public List<Map<String, Object>> getResultByIdcardAndYearAll(String idNo, String year) {
return mapper.getResultByIdcardAndYearAll(idNo, year);
}
@Override
public List<Map<String, Object>> getResultByPeId(String peId) {
return mapper.getResultByPeId(peId);
}
@Override
public List<Map<String, Object>> getDisResultByAuditDate(String auditDate) {
return mapper.getDisResultByAuditDate(auditDate);
}
@Override
public List<Map<String, Object>> getItemByPeId(String peId) {
return mapper.getItemByPeId(peId);
}
@Override
public List<Map<String, Object>> getItemByPeIdAll(String peId) {
return mapper.getItemByPeIdAll(peId);
}
@Override
public List<Map<String, Object>> getResultByIdNoOrPeId(String idNo, String peId, String auditYear) {
return mapper.getResultByIdNoOrPeId(idNo, peId, auditYear);
}
@Override
public Map<String, Object> getPeIdAndDateByIdcard(String idNo) {
return mapper.getPeIdAndDateByIdcard(idNo);
}
@Override
public List<Map<String, Object>> getPeIdAndDateByIdcardList(String idNo) {
return mapper.getPeIdAndDateByIdcardList(idNo);
}
@Override
public List<Map<String, Object>> getExamListByDate(String auditDate) {
return mapper.getExamListByDate(auditDate);
}
/** 等价于 Kotlin 的 map[key]?.toString():值为 null 时返回 null,否则转字符串 */
private static String str(Object value) {
return value == null ? null : String.valueOf(value);
}
}
@@ -0,0 +1,22 @@
package com.hospital.front.adapter;
import org.noear.solon.annotation.Component;
/**
* 庆阳医院适配器(Oracle,bsm 方言)。
*
* 数据库连接暂与泾河一致,正式环境就绪后调整 pac/conf/app-qingyang.yml。
*/
@Component
public class QingyangAdapter extends BsmAdapterBase {
@Override
public String code() {
return "qingyang";
}
@Override
public String displayName() {
return "庆阳医院";
}
}
@@ -0,0 +1,20 @@
package com.hospital.front.adapter;
import org.noear.solon.annotation.Component;
/**
* 兴隆园医院适配器(Oracle,bsm 方言)。
*/
@Component
public class XingLongYuanAdapter extends BsmAdapterBase {
@Override
public String code() {
return "xinglongyuan";
}
@Override
public String displayName() {
return "兴隆园医院";
}
}
@@ -0,0 +1,202 @@
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;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* YA 方言适配器基类(SqlServer)。
*
* 适用医院:yanan(延安)。
*/
public abstract class YaAdapterBase implements HospitalAdapter {
/** 数据库 MapperSolon 按类型注入) */
@Inject
protected YaOrderMapper mapper;
@Override
public String hmType() {
return "ya";
}
@Override
public Result<Boolean> healthCheck() {
try {
return Result.success(mapper.ping() == 1);
} catch (Exception e) {
return Result.error(Result.CODE_DB_UNREACHABLE, "数据库连接失败:" + e.getMessage());
}
}
@Override
public ProcResult saveOrder(String peInfoStr, String peItemStr) {
Map<String, Object> map = new HashMap<>();
map.put("peInfoStr", peInfoStr);
map.put("peItemStr", peItemStr);
mapper.saveOrder(map);
return new ProcResult(
str(map.get("resultCode")),
str(map.get("errorMsg")),
str(map.get("peId"))
);
}
@Override
public ProcResult cancelOrder(String peId, String name, String idcard) {
Map<String, Object> map = new HashMap<>();
map.put("peId", peId);
map.put("name", name);
map.put("idcard", idcard);
mapper.cancelOrder(map);
return new ProcResult(
str(map.get("resultCode")),
str(map.get("errorMsg")),
null
);
}
@Override
public ProcResult addUnit(UnitVo unit, boolean useV2) {
Map<String, Object> map = new HashMap<>();
map.put("unitCode", unit.getUnitCode());
map.put("unitName", unit.getUnitName());
map.put("parentUnitCode", unit.getParentUnitCode());
map.put("address", unit.getAddress());
map.put("connecter1", unit.getConnecter1());
map.put("phone1", unit.getPhone1());
map.put("connecter2", unit.getConnecter2());
map.put("phone2", unit.getPhone2());
if (useV2) {
mapper.addUnitV2(map);
} else {
mapper.addUnit(map);
}
return new ProcResult(
str(map.get("resultCode")),
str(map.get("errorMsg")),
null
);
}
@Override
public List<Map<String, Object>> getPhyexamStatus(String peIds) {
return mapper.getPhyexamStatus(peIds);
}
@Override
public List<Map<String, Object>> getItemList() {
return mapper.getItemList();
}
@Override
public List<Map<String, Object>> getAllItemList() {
return mapper.getAllItemList();
}
@Override
public List<Map<String, Object>> getPeItemList() {
return mapper.getPeItemList();
}
@Override
public List<Map<String, Object>> getItemInfo(String peItemCode) {
return mapper.getItemInfo(peItemCode);
}
@Override
public List<Map<String, Object>> getEmpItem(String peId) {
return mapper.getEmpItem(peId);
}
@Override
public List<Map<String, Object>> getResult(String peId) {
return mapper.getResult(peId);
}
@Override
public List<Map<String, Object>> getConclusion(String peId) {
return mapper.getConclusion(peId);
}
@Override
public List<Map<String, Object>> getConclusionAll(String peId) {
return mapper.getConclusionAll(peId);
}
@Override
public List<Map<String, Object>> getSuggest(String peId) {
return mapper.getSuggest(peId);
}
@Override
public List<Map<String, Object>> getSuggestAll(String peId) {
return mapper.getSuggestAll(peId);
}
@Override
public Map<String, Object> getPeStatusByIdnoAndYear(String idNo, String year) {
return mapper.getPeStatusByIdnoAndYear(idNo, year);
}
@Override
public List<Map<String, Object>> getResultByIdcardAndYear(String idNo, String year) {
return mapper.getResultByIdcardAndYear(idNo, year);
}
@Override
public List<Map<String, Object>> getResultByIdcardAndYearAll(String idNo, String year) {
return mapper.getResultByIdcardAndYearAll(idNo, year);
}
@Override
public List<Map<String, Object>> getResultByPeId(String peId) {
return mapper.getResultByPeId(peId);
}
@Override
public List<Map<String, Object>> getDisResultByAuditDate(String auditDate) {
return mapper.getDisResultByAuditDate(auditDate);
}
@Override
public List<Map<String, Object>> getItemByPeId(String peId) {
return mapper.getItemByPeId(peId);
}
@Override
public List<Map<String, Object>> getItemByPeIdAll(String peId) {
return mapper.getItemByPeIdAll(peId);
}
@Override
public List<Map<String, Object>> getResultByIdNoOrPeId(String idNo, String peId, String auditYear) {
return mapper.getResultByIdNoOrPeId(idNo, peId, auditYear);
}
@Override
public Map<String, Object> getPeIdAndDateByIdcard(String idNo) {
return mapper.getPeIdAndDateByIdcard(idNo);
}
@Override
public List<Map<String, Object>> getPeIdAndDateByIdcardList(String idNo) {
return mapper.getPeIdAndDateByIdcardList(idNo);
}
@Override
public List<Map<String, Object>> getExamListByDate(String auditDate) {
return mapper.getExamListByDate(auditDate);
}
/** 等价于 Kotlin 的 map[key]?.toString():值为 null 时返回 null,否则转字符串 */
private static String str(Object value) {
return value == null ? null : String.valueOf(value);
}
}
@@ -0,0 +1,20 @@
package com.hospital.front.adapter;
import org.noear.solon.annotation.Component;
/**
* 延安医院适配器(SqlServer,ya 方言)。
*/
@Component
public class YananAdapter extends YaAdapterBase {
@Override
public String code() {
return "yanan";
}
@Override
public String displayName() {
return "延安医院";
}
}
@@ -0,0 +1,276 @@
package com.hospital.front.api.admin;
import java.io.File;
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
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 org.noear.solon.core.handle.MethodType;
import com.hospital.front.adapter.HospitalRouter;
import com.hospital.front.dto.Result;
import com.hospital.front.infra.exception.BusinessException;
/**
* 主程序被控端 API/api/admin)。
*
* 供同机 Agent(环回地址)调用,实现远程运维:
* - 健康检查 / 版本查询 / JVM 指标
* - 配置查看(脱敏)
* - 日志文件列表 / 尾部查看 / 全量下载
* - 优雅重启
*
* 鉴权:请求头 X-Admin-Token 与配置 admin.token 比对(未配置时放行,仅本机使用)。
*/
@Controller
@Mapping("/api/admin")
public class AdminApi {
@Inject
private HospitalRouter router;
/** 日志目录(与 logback.xml 的 log.path 保持一致) */
private File getLogDir() {
return new File(Solon.cfg().get("log.path", "./log"));
}
// ================= 鉴权 =================
private void checkToken(Context ctx) {
String expected = Solon.cfg().get("admin.token", "");
if (expected != null && !expected.trim().isEmpty() && !expected.equals(ctx.header("X-Admin-Token"))) {
throw BusinessException.of(Result.CODE_AUTH_FAILED, "管理接口鉴权失败");
}
}
// ================= 1. 健康与指标 =================
/** 健康检查(含数据库探测) */
@Mapping("/health")
public Result<Map<String, Object>> health(Context ctx) {
checkToken(ctx);
Runtime mem = Runtime.getRuntime();
Result<Boolean> db = router.active().healthCheck();
Map<String, Object> out = new LinkedHashMap<>();
out.put("status", db.getCode() == 0 ? "UP" : "DEGRADED");
out.put("hospital", router.activeCode());
out.put("hospitalName", router.active().displayName());
out.put("registeredHospitals", router.registeredCodes());
Map<String, Object> database = new HashMap<>();
database.put("ok", db.getCode() == 0);
database.put("msg", db.getMsg());
out.put("database", database);
Map<String, Object> jvm = new HashMap<>();
jvm.put("totalMemoryMb", mem.totalMemory() / 1048576);
jvm.put("freeMemoryMb", mem.freeMemory() / 1048576);
jvm.put("maxMemoryMb", mem.maxMemory() / 1048576);
jvm.put("availableProcessors", mem.availableProcessors());
out.put("jvm", jvm);
out.put("uptimeSec", ManagementFactory.getRuntimeMXBean().getUptime() / 1000);
out.put("pid", ProcessHandle.current().pid());
return Result.success(out);
}
/** 版本信息(读 MANIFEST */
@Mapping("/version")
public Result<Map<String, String>> version(Context ctx) {
checkToken(ctx);
Package pkg = this.getClass().getPackage();
Map<String, String> out = new LinkedHashMap<>();
out.put("app", "hospital-front");
out.put("version", pkg != null && pkg.getImplementationVersion() != null ? pkg.getImplementationVersion() : "1.0.0-dev");
out.put("hospital", router.activeCode());
out.put("javaVersion", System.getProperty("java.version"));
out.put("startTime", String.valueOf(ManagementFactory.getRuntimeMXBean().getStartTime()));
return Result.success(out);
}
// ================= 2. 配置管理 =================
/** 查看当前生效配置(密码脱敏) */
@Mapping("/config")
public Result<Map<String, Object>> config(Context ctx) {
checkToken(ctx);
List<String> keys = Arrays.asList(
"hospital.active", "hospital.id",
"hm.type", "hm.ver", "hm.allsecondFlag", "hm.occFlag",
"server.port", "admin.token", "log.path");
Map<String, Object> conf = new LinkedHashMap<>();
for (String key : keys) {
conf.put(key, Solon.cfg().get(key, ""));
}
// 数据源脱敏(db1! 的 ! 在 cfg 键中已剥离,两种写法都尝试)
Map<String, Object> ds = new LinkedHashMap<>();
ds.put("db1.jdbcUrl", orDefault(
Solon.cfg().get("solon.dataSources.db1.jdbcUrl"),
orDefault(Solon.cfg().get("solon.dataSources.db1!.jdbcUrl"), "")));
ds.put("db1.username", orDefault(
Solon.cfg().get("solon.dataSources.db1.username"),
orDefault(Solon.cfg().get("solon.dataSources.db1!.username"), "")));
ds.put("db1.password", "******");
Map<String, Object> out = new HashMap<>();
out.put("config", conf);
out.put("dataSource", ds);
return Result.success(out);
}
/** Kotlin elvis 的等价实现 */
private static String orDefault(String value, String fallback) {
return value != null ? value : fallback;
}
/**
* 列出 pac/conf 目录下的医院配置文件内容(供 B 端查看/修改)。
*/
@Mapping("/config/file")
public Result<String> configFile(Context ctx, @Param("hospital") String hospital) {
checkToken(ctx);
File file = new File("conf/app-" + hospital + ".yml");
if (!file.exists()) {
throw BusinessException.paramMissing("配置文件 conf/app-" + hospital + ".yml 不存在");
}
try {
return Result.success(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 写回医院配置文件(B 端修改后下发)。重启后生效。
*/
@Mapping(value = "/config/file", method = {MethodType.POST})
public Result<?> saveConfigFile(Context ctx, @Param("hospital") String hospital, @Param(name = "content") String content) {
checkToken(ctx);
if (!Pattern.matches("[a-z]+", hospital)) {
throw BusinessException.businessFailed("非法医院编码:" + hospital);
}
File file = new File("conf/app-" + hospital + ".yml");
if (file.getParentFile() != null) {
file.getParentFile().mkdirs();
}
try {
Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new RuntimeException(e);
}
return Result.success();
}
// ================= 3. 日志管理 =================
/** 日志文件列表 */
@Mapping("/log/files")
public Result<List<Map<String, Object>>> logFiles(Context ctx) {
checkToken(ctx);
File dir = getLogDir();
if (!dir.exists()) {
return Result.success(new ArrayList<>());
}
File[] files = dir.listFiles(File::isFile);
if (files == null) {
return Result.success(new ArrayList<>());
}
Arrays.sort(files, (a, b) -> Long.compare(b.lastModified(), a.lastModified()));
List<Map<String, Object>> out = new ArrayList<>();
for (File f : files) {
if (out.size() >= 50) {
break;
}
Map<String, Object> item = new HashMap<>();
item.put("name", f.getName());
item.put("sizeBytes", f.length());
item.put("lastModified", f.lastModified());
out.add(item);
}
return Result.success(out);
}
/** 日志尾部 N 行(默认 200);容错解码(历史日志可能混有 GBK 内容) */
@Mapping("/log/tail")
public Result<String> logTail(Context ctx, @Param("file") String file, @Param("lines") Integer lines) {
checkToken(ctx);
File target = safeLogFile(file);
int n = Math.max(1, Math.min(lines != null ? lines : 200, 2000));
java.nio.charset.CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPLACE);
try {
String text = decoder.decode(ByteBuffer.wrap(Files.readAllBytes(target.toPath()))).toString();
String[] all = text.split("\n", -1);
int from = Math.max(0, all.length - n);
StringBuilder sb = new StringBuilder();
for (int i = from; i < all.length; i++) {
if (i > from) {
sb.append('\n');
}
sb.append(all[i]);
}
return Result.success(sb.toString());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** 日志全量下载 */
@Mapping("/log/download")
public void logDownload(Context ctx, @Param("file") String file) {
checkToken(ctx);
File target = safeLogFile(file);
ctx.contentType("application/octet-stream");
ctx.headerSet("Content-Disposition", "attachment; filename=\"" + target.getName() + "\"");
try {
Files.copy(target.toPath(), ctx.outputStream());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** 限制只能读日志目录内的文件(防路径穿越) */
private File safeLogFile(String name) {
if (name.contains("..") || name.contains("/") || name.contains("\\")) {
throw BusinessException.businessFailed("非法文件名");
}
File f = new File(getLogDir(), name);
if (!f.exists()) {
throw BusinessException.paramMissing("日志文件不存在:" + name);
}
return f;
}
// ================= 4. 进程管理 =================
/**
* 优雅重启:记录标记后延迟退出,Agent 检测到进程退出后拉起新进程。
*/
@Mapping(value = "/restart", method = {MethodType.POST})
public Result<?> restart(Context ctx, @Param("delayMs") Long delayMs) {
checkToken(ctx);
long delay = Math.max(0, Math.min(delayMs != null ? delayMs : 1000L, 10000L));
Thread t = new Thread(() -> {
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Solon.stopBlock(); // 触发 Solon 停机事件(优雅退出)
});
t.setDaemon(true);
t.start();
return Result.success();
}
}
@@ -0,0 +1,216 @@
package com.hospital.front.api.order;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
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.annotation.Path;
import org.noear.solon.core.handle.MethodType;
import org.noear.snack.ONode;
import com.hospital.front.dto.Result;
import com.hospital.front.infra.exception.BusinessException;
import com.hospital.front.service.OrderService;
/**
* 体检预约业务接口,路径前缀 /order。
*
* 路径与出参结构保持与原 hospitalmiddle 一致(调用方契约,不可修改)。
* 根路径前缀为 /order,各方法 Mapping 为相对路径。
*/
@Controller
@Mapping("/order")
public class OrderApi {
@Inject
private OrderService service;
// ================= 1. 预约提交 / 取消 =================
/** 4. 提交预约保存 */
@Mapping(value = "/saveOrder", method = {MethodType.POST})
public Result<String> saveOrder(@Body String body) {
// Solon 对 Map 泛型 body 反序列化支持不佳,用 snack3 手动解析
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) ONode.load(body).toObject(Map.class);
return Result.success(service.saveOrder(map));
}
/** 5. 取消体检预约 */
@Mapping(value = "/cancelOrder/{peId}", method = {MethodType.POST})
public Result<?> cancelOrder(
@Path("peId") String peId,
@Param("name") String name,
@Param("idcard") String idcard) {
service.cancelOrder(peId, name, idcard);
return Result.success();
}
// ================= 2. 体检状态 =================
/** 3. 体检状态查询(多个 peId 逗号分隔) */
@Mapping("/getPeStatus/{peIds}")
public Result<List<Map<String, String>>> getPeStatus(@Path("peIds") String peIds) {
return Result.success(service.getPhyexamStatus(peIds));
}
/** 13. 根据身份证号及年份获取当前体检状态及体检项状态 */
@Mapping("/getPeStatusAndItemList/{idNo}/{year}")
public Result<Map<String, Object>> getPeStatusAndItemList(@Path("idNo") String idNo, @Path("year") String year) {
return Result.success(service.getPeStatusByIdnoAndYear(idNo, year));
}
// ================= 3. 体检项目 =================
/** 1. 查询员工体检项目 */
@Mapping("/getEmpItem/{peId}")
public Result<List<Map<String, Object>>> getEmpItem(@Path("peId") String peId) {
return Result.success(service.getEmpItem(peId));
}
/** 2. 体检项目查询 */
@Mapping("/getItemList")
public Result<List<Map<String, Object>>> getItemList() {
return Result.success(service.getItemList());
}
/** 6. 体检指标项数据获取 */
@Mapping("/getPeItemList")
public Result<List<Map<String, Object>>> getPeItemList() {
return Result.success(service.getPeItemList());
}
/** 6.1 体检指标项及体检组合数据获取 */
@Mapping("/getAllItemList")
public Result<List<Map<String, Object>>> getAllItemList() {
return Result.success(service.getAllItemList());
}
/** 7. 根据体检指标项查询所属体检组合项 */
@Mapping("/getItemInfo/{peItemCode}")
public Result<List<Map<String, Object>>> getItemInfo(@Path("peItemCode") String peItemCode) {
return Result.success(service.getItemInfo(peItemCode));
}
// ================= 4. 体检结果 / 结论 / 建议 =================
/** 8. 根据体检编号获取员工体检结果 */
@Mapping("/getResult/{peId}")
public Result<List<Map<String, Object>>> getResult(@Path("peId") String peId) {
return Result.success(service.getResult(peId));
}
/** 9. 体检结论(汇总) */
@Mapping("/getTotalConclusion/{peId}")
public Result<Map<String, String>> getTotalConclusion(@Path("peId") String peId) {
return Result.success(service.getTotalConclusion(peId));
}
/** 10. 体检建议(汇总) */
@Mapping("/getTotalSuggest/{peId}")
public Result<Map<String, String>> getTotalSuggest(@Path("peId") String peId) {
return Result.success(service.getTotalSuggest(peId));
}
/** 11. 体检结论(列表) */
@Mapping("/getConclusion/{peId}")
public Result<List<Map<String, Object>>> getConclusion(@Path("peId") String peId) {
return Result.success(service.getConclusion(peId));
}
/** 12. 体检建议(列表) */
@Mapping("/getSuggest/{peId}")
public Result<List<Map<String, Object>>> getSuggest(@Path("peId") String peId) {
return Result.success(service.getSuggest(peId));
}
// ================= 5. 按身份证 / 日期查询 =================
/** 14. 根据身份证号及年份获取体检结果 */
@Mapping("/getResultByIdcardAndYear/{idNo}/{year}")
public Result<List<Map<String, Object>>> getResultByIdcardAndYear(@Path("idNo") String idNo, @Path("year") String year) {
return Result.success(service.getResultByIdcardAndYear(idNo, year));
}
/** 14.1 根据身份证号及年份获取体检结果(全量) */
@Mapping("/getResultByIdcardAndYearAll/{idNo}/{year}")
public Result<List<Map<String, Object>>> getResultByIdcardAndYearAll(@Path("idNo") String idNo, @Path("year") String year) {
return Result.success(service.getResultByIdcardAndYearAll(idNo, year));
}
/** 15.1 根据体检编号获取体检结果 */
@Mapping("/getResultByPeId/{peId}")
public Result<List<Map<String, Object>>> getResultByPeId(@Path("peId") String peId) {
return Result.success(service.getResultByPeId(peId));
}
/** 15.2 根据体检编号获取体检状态(简) */
@Mapping("/getResultStatus/{peId}")
public Result<List<Map<String, Object>>> getResultStatus(@Path("peId") String peId) {
return Result.success(service.getResultByPeId(peId));
}
/** 15.3 根据 audit_date 查询当天的体检报告 */
@Mapping("/getResultByAuditDate/{auditDate}")
public Result<List<Map<String, Object>>> getResultByAuditDate(@Path("auditDate") String auditDate) {
return Result.success(service.getResultByAuditDate(auditDate));
}
/** 16. 根据 id_code 或 pe_id 和审查年份查询体检报告 */
@Mapping(value = "/getResultByIdNoOrPeId", method = {MethodType.POST})
public Result<List<Map<String, Object>>> getResultByIdNoOrPeId(
@Param(value = "idNo", required = false) String idNo,
@Param(value = "peId", required = false) String peId,
@Param("auditYear") String auditYear) {
if (isNullOrBlank(idNo) && isNullOrBlank(peId)) {
throw BusinessException.paramMissing("身份证号或体检编号");
}
return Result.success(service.getResultByIdNoOrPeId(idNo, peId, auditYear));
}
/** 17. 根据身份证号查询当前年的 peId 和预约日期 */
@Mapping("/getPeIdAndDateByIdcard/{idNo}")
public Result<Map<String, Object>> getPeIdAndDateByIdcard(@Path("idNo") String idNo) {
return Result.success(service.getPeIdAndDateByIdcard(idNo));
}
/** 17.1 同上(数组) */
@Mapping("/getPeIdAndDateByIdcardList/{idNo}")
public Result<List<Map<String, Object>>> getPeIdAndDateByIdcardList(@Path("idNo") String idNo) {
return Result.success(service.getPeIdAndDateByIdcardList(idNo));
}
// ================= 6. 单位 / 人员列表 =================
/** 18. 添加修改单位信息 */
@Mapping(value = "/addUnit", method = {MethodType.POST})
public Result<?> addUnit(@Body String body) {
// Solon 对 List 泛型 body 反序列化支持不佳,此处用 snack3 手动解析
List<Map<String, Object>> units;
if (body == null || body.trim().isEmpty()) {
units = new ArrayList<>();
} else {
@SuppressWarnings("unchecked")
List<Map<String, Object>> parsed = (List<Map<String, Object>>) ONode.load(body).toObject(List.class);
units = parsed;
}
String errors = service.addUnits(units);
return errors != null ? Result.error(errors) : Result.success();
}
/** 19. 根据日期查询当天体检人员列表(去重),日期为空则查当天 */
@Mapping("/getExamListByDate")
public Result<List<Map<String, Object>>> getExamListByDate(@Param("auditDate") String auditDate) {
return Result.success(service.getExamListByDate(auditDate));
}
/** Kotlin isNullOrBlank 的等价实现 */
private static boolean isNullOrBlank(String s) {
return s == null || s.trim().isEmpty();
}
}
@@ -0,0 +1,71 @@
package com.hospital.front.dto;
/**
* 医院存储过程执行结果。
*
* @author hospital-front重构
*/
public class ProcResult {
/** 医院侧返回码("1" = 成功) */
private String resultCode;
/** 错误信息(失败时) */
private String errorMsg;
/** 预约成功后医院分配的体检编号(仅 saveOrder 有值) */
private String peId;
/**
* 无参构造器(各字段默认为 null,与 Kotlin 默认参数语义一致)。
*/
public ProcResult() {
}
/**
* 全参构造器。
*
* @param resultCode 医院侧返回码("1" = 成功)
* @param errorMsg 错误信息(失败时)
* @param peId 预约成功后医院分配的体检编号
*/
public ProcResult(String resultCode, String errorMsg, String peId) {
this.resultCode = resultCode;
this.errorMsg = errorMsg;
this.peId = peId;
}
/** 医院侧是否执行成功 */
public boolean isSuccess() {
return "1".equals(resultCode);
}
public String getResultCode() {
return resultCode;
}
public void setResultCode(String resultCode) {
this.resultCode = resultCode;
}
public String getErrorMsg() {
return errorMsg;
}
public void setErrorMsg(String errorMsg) {
this.errorMsg = errorMsg;
}
public String getPeId() {
return peId;
}
public void setPeId(String peId) {
this.peId = peId;
}
@Override
public String toString() {
return "ProcResult(resultCode=" + resultCode + ", errorMsg=" + errorMsg + ", peId=" + peId + ")";
}
}
@@ -0,0 +1,119 @@
package com.hospital.front.dto;
/**
* 统一 API 响应包装。
*
* 业务接口均使用此结构返回,便于前端统一处理。
*
* @param <T> 数据类型
* @author hospital-front重构
*/
public class Result<T> {
/** 业务码(0=成功) */
private final int code;
/** 提示信息 */
private final String msg;
/** 业务数据 */
private final T data;
/**
* 全参构造器。
*
* @param code 业务码(0=成功)
* @param msg 提示信息
* @param data 业务数据(可为 null
*/
public Result(int code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
/**
* 两参构造器(data 默认为 null,与 Kotlin 默认参数语义一致)。
*
* @param code 业务码
* @param msg 提示信息
*/
public Result(int code, String msg) {
this(code, msg, null);
}
/** 业务成功码 */
public static final int SUCCESS_CODE = 0;
/** 业务失败默认码 */
public static final int ERROR_CODE = 5001;
/** 参数缺失 */
public static final int CODE_PARAM_MISSING = 1001;
/** 参数格式错误 */
public static final int CODE_PARAM_FORMAT = 1002;
/** 业务校验失败 */
public static final int CODE_BUSINESS = 1003;
/** 存储过程调用失败 */
public static final int CODE_PROC_FAILED = 2001;
/** 数据库连接失败 */
public static final int CODE_DB_UNREACHABLE = 2002;
/** 医院系统返回业务失败 */
public static final int CODE_HOSPITAL_FAILED = 2003;
/** 鉴权失败 */
public static final int CODE_AUTH_FAILED = 3001;
/** Token 过期 */
public static final int CODE_TOKEN_EXPIRED = 3002;
/**
* 成功响应(无数据)。
*/
public static <T> Result<T> success() {
return new Result<>(SUCCESS_CODE, "success", null);
}
/**
* 成功响应。
*/
public static <T> Result<T> success(T data) {
return new Result<>(SUCCESS_CODE, "success", data);
}
/**
* 错误响应(默认错误码)。
*/
public static <T> Result<T> error(String msg) {
return new Result<>(ERROR_CODE, msg, null);
}
/**
* 错误响应(指定错误码)。
*/
public static <T> Result<T> error(int code, String msg) {
return new Result<>(code, msg, null);
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
public T getData() {
return data;
}
@Override
public String toString() {
return "Result(code=" + code + ", msg=" + msg + ", data=" + data + ")";
}
}
@@ -0,0 +1,41 @@
package com.hospital.front.infra.config;
import java.util.ArrayList;
import java.util.List;
import org.noear.solon.annotation.BindProps;
import org.noear.solon.annotation.Configuration;
/**
* 应用配置(Solon @ConfigurationProperties)。
*
* 加载 `app.yml` 与 `pac/conf/app-{编码}.yml` 中的 hospital.* 配置。
*
* @author hospital-front重构
*/
@BindProps(prefix = "hospital")
@Configuration
public class AppConfig {
/** 启动激活哪家医院 */
private String active = "jinghe";
/** 注册的医院编码清单(启动自检) */
private List<String> adapters = new ArrayList<>();
public String getActive() {
return active;
}
public void setActive(String active) {
this.active = active;
}
public List<String> getAdapters() {
return adapters;
}
public void setAdapters(List<String> adapters) {
this.adapters = adapters;
}
}
@@ -0,0 +1,58 @@
package com.hospital.front.infra.exception;
import com.hospital.front.dto.Result;
/**
* 业务异常。
*
* 用于业务层显式抛出指定错误码的异常,会被 GlobalExceptionHandler 捕获并包装为 Result。
*
* @author hospital-front重构
*/
public class BusinessException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** 业务错误码(参见 Result 中的常量) */
private final int code;
/**
* 构造业务异常。
*
* @param code 业务错误码
* @param message 错误描述
*/
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
/**
* 静态工厂方法。
*
* @param code 业务错误码
* @param message 错误描述
*/
public static BusinessException of(int code, String message) {
return new BusinessException(code, message);
}
/** 参数缺失异常 */
public static BusinessException paramMissing(String field) {
return new BusinessException(Result.CODE_PARAM_MISSING, "参数缺失:" + field);
}
/** 业务校验失败异常 */
public static BusinessException businessFailed(String msg) {
return new BusinessException(Result.CODE_BUSINESS, msg);
}
/** 存储过程调用失败异常 */
public static BusinessException procFailed(String msg) {
return new BusinessException(Result.CODE_PROC_FAILED, msg);
}
public int getCode() {
return code;
}
}
@@ -0,0 +1,70 @@
package com.hospital.front.infra.exception;
import lombok.extern.slf4j.Slf4j;
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;
/**
* 全局异常处理(Solon Filter)。
*
* 将未捕获的异常统一包装为 Result 格式返回。
* 业务异常请直接抛出 BusinessException。
*/
@Slf4j
@Component
public class GlobalExceptionHandler implements Filter {
@Override
public void doFilter(Context ctx, FilterChain chain) {
try {
chain.doFilter(ctx);
} catch (BusinessException e) {
String msg = e.getMessage() != null ? e.getMessage() : "业务异常";
log.warn("业务异常:code={}, msg={}", e.getCode(), e.getMessage());
renderResult(ctx, Result.error(e.getCode(), msg));
} catch (IllegalArgumentException e) {
String msg = e.getMessage() != null ? e.getMessage() : "参数错误";
log.warn("参数异常:{}", e.getMessage());
renderResult(ctx, Result.error(Result.CODE_PARAM_FORMAT, msg));
} catch (Exception e) {
log.error("系统异常", e);
String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
renderResult(ctx, Result.error(Result.ERROR_CODE, "系统异常:" + msg));
} catch (Throwable t) {
// 兜底:捕获 Error 及其它 Throwable(如 OOM),保证响应不悬挂
log.error("严重异常(Throwable", t);
renderResult(ctx, Result.error(Result.ERROR_CODE, "严重异常:" + t.getClass().getSimpleName()));
}
}
/**
* 输出 Result JSON 响应。
*/
private void renderResult(Context ctx, Result<?> result) {
ctx.status(200);
ctx.contentType("application/json;charset=UTF-8");
// 简易 JSON 序列化(避免引入 fastjson 依赖)
String dataJson = result.getData() != null ? toJsonValue(result.getData()) : "null";
ctx.output("{\"code\":" + result.getCode()
+ ",\"msg\":" + toJsonValue(result.getMsg())
+ ",\"data\":" + dataJson + "}");
}
/**
* 极简 JSON 值序列化(字符串加引号,其它直接 toString)。
*/
private String toJsonValue(Object v) {
if (v == null) {
return "null";
}
if (v instanceof Number || v instanceof Boolean) {
return v.toString();
}
return "\"" + v.toString().replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
}
}
@@ -0,0 +1,58 @@
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重构
*/
public final class DateUtils {
/** 日期格式 */
public static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/** 日期时间格式 */
public static final DateTimeFormatter DATETIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 工具类禁止实例化 */
private DateUtils() {
}
/** 当前日期字符串(yyyy-MM-dd */
public static String today() {
return LocalDate.now().format(DATE);
}
/** 当前时间字符串(yyyy-MM-dd HH:mm:ss */
public static String now() {
return LocalDateTime.now().format(DATETIME);
}
/** 解析日期字符串 */
public static LocalDate parseDate(String text) {
return LocalDate.parse(text, DATE);
}
/** 解析日期时间字符串 */
public static LocalDateTime parseDateTime(String text) {
return LocalDateTime.parse(text, DATETIME);
}
/** 格式化为日期字符串 */
public static String format(LocalDate date) {
return date.format(DATE);
}
/** 格式化为日期时间字符串 */
public static String format(LocalDateTime dateTime) {
return dateTime.format(DATETIME);
}
}
@@ -0,0 +1,104 @@
package com.hospital.front.mapper.bsm;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* BSM 医院组 MapperOracle 方言)。
*
* 适用医院:xinglongyuan(兴隆园)/ jinghe(泾河)/ qingyang(庆阳)
* XML 位置:classpath:mapper/bsm/OrderMapper.xml
*
* 存储过程参数与医院 HIS 系统约定,不可修改。
*/
@Mapper
public interface BsmOrderMapper {
/** 健康探测(SELECT 1 */
int ping();
/** 预约提交(存储过程 P_ADD_PE_REGIST,出参 resultCode/errorMsg/peId */
void saveOrder(Map<String, Object> map);
/** 取消预约(存储过程 P_DEL_PE_REGIST */
void cancelOrder(Map<String, Object> map);
/** 添加/修改单位 V1(存储过程 P_ADD_UNIT */
void addUnit(Map<String, Object> map);
/** 添加/修改单位 V2(存储过程 P_ADD_UNIT8 入参版) */
void addUnitV2(Map<String, Object> map);
/** 按体检预约 ID(逗号分隔)查询体检状态 */
List<Map<String, Object>> getPhyexamStatus(@Param("peIds") String peIds);
/** 医院所有体检项目(组合项) */
List<Map<String, Object>> getItemList();
/** 医院所有体检项目及指标项 */
List<Map<String, Object>> getAllItemList();
/** 医院所有体检指标项 */
List<Map<String, Object>> getPeItemList();
/** 员工体检项目 */
List<Map<String, Object>> getEmpItem(@Param("peId") String peId);
/** 按指标项 CODE 查询体检组合 */
List<Map<String, Object>> getItemInfo(@Param("peItemCode") String peItemCode);
/** 员工体检结果 */
List<Map<String, Object>> getResult(@Param("peId") String peId);
/** 体检结论(列表) */
List<Map<String, Object>> getConclusion(@Param("peId") String peId);
/** 体检结论(全量) */
List<Map<String, Object>> getConclusionAll(@Param("peId") String peId);
/** 体检建议(列表) */
List<Map<String, Object>> getSuggest(@Param("peId") String peId);
/** 体检建议(全量) */
List<Map<String, Object>> getSuggestAll(@Param("peId") String peId);
/** 按身份证号及年份查询体检状态 */
Map<String, Object> getPeStatusByIdnoAndYear(@Param("idNo") String idNo, @Param("year") String year);
/** 按身份证号及年份查询体检结果 */
List<Map<String, Object>> getResultByIdcardAndYear(@Param("idNo") String idNo, @Param("year") String year);
/** 按身份证号及年份查询体检结果(全量) */
List<Map<String, Object>> getResultByIdcardAndYearAll(@Param("idNo") String idNo, @Param("year") String year);
/** 按体检编号查询体检结果 */
List<Map<String, Object>> getResultByPeId(@Param("peId") String peId);
/** 按审核日期查询体检报告(去重) */
List<Map<String, Object>> getDisResultByAuditDate(@Param("auditDate") String auditDate);
/** 按体检编号查询体检项目子表 */
List<Map<String, Object>> getItemByPeId(@Param("peId") String peId);
/** 按体检编号查询体检项目子表(全量) */
List<Map<String, Object>> getItemByPeIdAll(@Param("peId") String peId);
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
List<Map<String, Object>> getResultByIdNoOrPeId(
@Param("idNo") String idNo,
@Param("peId") String peId,
@Param("auditYear") String auditYear
);
/** 按身份证号查询当年 peId 与预约日期 */
Map<String, Object> getPeIdAndDateByIdcard(@Param("idNo") String idNo);
/** 按身份证号查询当年 peId 与预约日期(数组) */
List<Map<String, Object>> getPeIdAndDateByIdcardList(@Param("idNo") String idNo);
/** 按审核日期查询当天体检人员列表(去重) */
List<Map<String, Object>> getExamListByDate(@Param("auditDate") String auditDate);
}
@@ -0,0 +1,104 @@
package com.hospital.front.mapper.nx;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* BSM 医院组 MapperOracle 方言)。
*
* 适用医院:ningxia(宁夏宝石花,SqlServer
* XML 位置:classpath:mapper/nx/OrderMapper.xml
*
* 存储过程参数与医院 HIS 系统约定,不可修改。
*/
@Mapper
public interface NxOrderMapper {
/** 健康探测(SELECT 1 */
int ping();
/** 预约提交(存储过程 P_ADD_PE_REGIST,出参 resultCode/errorMsg/peId */
void saveOrder(Map<String, Object> map);
/** 取消预约(存储过程 P_DEL_PE_REGIST */
void cancelOrder(Map<String, Object> map);
/** 添加/修改单位 V1(存储过程 P_ADD_UNIT */
void addUnit(Map<String, Object> map);
/** 添加/修改单位 V2(存储过程 P_ADD_UNIT8 入参版) */
void addUnitV2(Map<String, Object> map);
/** 按体检预约 ID(逗号分隔)查询体检状态 */
List<Map<String, Object>> getPhyexamStatus(@Param("peIds") String peIds);
/** 医院所有体检项目(组合项) */
List<Map<String, Object>> getItemList();
/** 医院所有体检项目及指标项 */
List<Map<String, Object>> getAllItemList();
/** 医院所有体检指标项 */
List<Map<String, Object>> getPeItemList();
/** 员工体检项目 */
List<Map<String, Object>> getEmpItem(@Param("peId") String peId);
/** 按指标项 CODE 查询体检组合 */
List<Map<String, Object>> getItemInfo(@Param("peItemCode") String peItemCode);
/** 员工体检结果 */
List<Map<String, Object>> getResult(@Param("peId") String peId);
/** 体检结论(列表) */
List<Map<String, Object>> getConclusion(@Param("peId") String peId);
/** 体检结论(全量) */
List<Map<String, Object>> getConclusionAll(@Param("peId") String peId);
/** 体检建议(列表) */
List<Map<String, Object>> getSuggest(@Param("peId") String peId);
/** 体检建议(全量) */
List<Map<String, Object>> getSuggestAll(@Param("peId") String peId);
/** 按身份证号及年份查询体检状态 */
Map<String, Object> getPeStatusByIdnoAndYear(@Param("idNo") String idNo, @Param("year") String year);
/** 按身份证号及年份查询体检结果 */
List<Map<String, Object>> getResultByIdcardAndYear(@Param("idNo") String idNo, @Param("year") String year);
/** 按身份证号及年份查询体检结果(全量) */
List<Map<String, Object>> getResultByIdcardAndYearAll(@Param("idNo") String idNo, @Param("year") String year);
/** 按体检编号查询体检结果 */
List<Map<String, Object>> getResultByPeId(@Param("peId") String peId);
/** 按审核日期查询体检报告(去重) */
List<Map<String, Object>> getDisResultByAuditDate(@Param("auditDate") String auditDate);
/** 按体检编号查询体检项目子表 */
List<Map<String, Object>> getItemByPeId(@Param("peId") String peId);
/** 按体检编号查询体检项目子表(全量) */
List<Map<String, Object>> getItemByPeIdAll(@Param("peId") String peId);
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
List<Map<String, Object>> getResultByIdNoOrPeId(
@Param("idNo") String idNo,
@Param("peId") String peId,
@Param("auditYear") String auditYear
);
/** 按身份证号查询当年 peId 与预约日期 */
Map<String, Object> getPeIdAndDateByIdcard(@Param("idNo") String idNo);
/** 按身份证号查询当年 peId 与预约日期(数组) */
List<Map<String, Object>> getPeIdAndDateByIdcardList(@Param("idNo") String idNo);
/** 按审核日期查询当天体检人员列表(去重) */
List<Map<String, Object>> getExamListByDate(@Param("auditDate") String auditDate);
}
@@ -0,0 +1,104 @@
package com.hospital.front.mapper.ya;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* BSM 医院组 MapperOracle 方言)。
*
* 适用医院:yanan(延安,SqlServer
* XML 位置:classpath:mapper/ya/OrderMapper.xml
*
* 存储过程参数与医院 HIS 系统约定,不可修改。
*/
@Mapper
public interface YaOrderMapper {
/** 健康探测(SELECT 1 */
int ping();
/** 预约提交(存储过程 P_ADD_PE_REGIST,出参 resultCode/errorMsg/peId */
void saveOrder(Map<String, Object> map);
/** 取消预约(存储过程 P_DEL_PE_REGIST */
void cancelOrder(Map<String, Object> map);
/** 添加/修改单位 V1(存储过程 P_ADD_UNIT */
void addUnit(Map<String, Object> map);
/** 添加/修改单位 V2(存储过程 P_ADD_UNIT8 入参版) */
void addUnitV2(Map<String, Object> map);
/** 按体检预约 ID(逗号分隔)查询体检状态 */
List<Map<String, Object>> getPhyexamStatus(@Param("peIds") String peIds);
/** 医院所有体检项目(组合项) */
List<Map<String, Object>> getItemList();
/** 医院所有体检项目及指标项 */
List<Map<String, Object>> getAllItemList();
/** 医院所有体检指标项 */
List<Map<String, Object>> getPeItemList();
/** 员工体检项目 */
List<Map<String, Object>> getEmpItem(@Param("peId") String peId);
/** 按指标项 CODE 查询体检组合 */
List<Map<String, Object>> getItemInfo(@Param("peItemCode") String peItemCode);
/** 员工体检结果 */
List<Map<String, Object>> getResult(@Param("peId") String peId);
/** 体检结论(列表) */
List<Map<String, Object>> getConclusion(@Param("peId") String peId);
/** 体检结论(全量) */
List<Map<String, Object>> getConclusionAll(@Param("peId") String peId);
/** 体检建议(列表) */
List<Map<String, Object>> getSuggest(@Param("peId") String peId);
/** 体检建议(全量) */
List<Map<String, Object>> getSuggestAll(@Param("peId") String peId);
/** 按身份证号及年份查询体检状态 */
Map<String, Object> getPeStatusByIdnoAndYear(@Param("idNo") String idNo, @Param("year") String year);
/** 按身份证号及年份查询体检结果 */
List<Map<String, Object>> getResultByIdcardAndYear(@Param("idNo") String idNo, @Param("year") String year);
/** 按身份证号及年份查询体检结果(全量) */
List<Map<String, Object>> getResultByIdcardAndYearAll(@Param("idNo") String idNo, @Param("year") String year);
/** 按体检编号查询体检结果 */
List<Map<String, Object>> getResultByPeId(@Param("peId") String peId);
/** 按审核日期查询体检报告(去重) */
List<Map<String, Object>> getDisResultByAuditDate(@Param("auditDate") String auditDate);
/** 按体检编号查询体检项目子表 */
List<Map<String, Object>> getItemByPeId(@Param("peId") String peId);
/** 按体检编号查询体检项目子表(全量) */
List<Map<String, Object>> getItemByPeIdAll(@Param("peId") String peId);
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
List<Map<String, Object>> getResultByIdNoOrPeId(
@Param("idNo") String idNo,
@Param("peId") String peId,
@Param("auditYear") String auditYear
);
/** 按身份证号查询当年 peId 与预约日期 */
Map<String, Object> getPeIdAndDateByIdcard(@Param("idNo") String idNo);
/** 按身份证号查询当年 peId 与预约日期(数组) */
List<Map<String, Object>> getPeIdAndDateByIdcardList(@Param("idNo") String idNo);
/** 按审核日期查询当天体检人员列表(去重) */
List<Map<String, Object>> getExamListByDate(@Param("auditDate") String auditDate);
}
@@ -0,0 +1,406 @@
package com.hospital.front.service;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.noear.solon.Solon;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
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 com.hospital.front.vo.UnitVo;
/**
* 体检预约业务服务。
*
* 通过 HospitalRouter 委托给当前激活医院的适配器,
* 并承载跨医院共有的组装/汇总逻辑(替代原 OrderServiceImpl 的重复代码)。
*/
@Slf4j
@Component
public class OrderService {
@Inject
private HospitalRouter router;
// ================= 配置开关(惰性读取,支持热更新) =================
/** VO 版本(V1 / V2),默认 V2 */
private String getHmVer() {
return Solon.cfg().get("hm.ver", "V2");
}
/** 宁夏:强制发送二级单位编码和名称 */
private boolean isAllsecondFlag() {
return "true".equals(Solon.cfg().get("hm.allsecondFlag", "false"));
}
/** 健康体检不发送职业相关信息 */
private boolean isOccFlag() {
return "true".equals(Solon.cfg().get("hm.occFlag", "false"));
}
/** 当前激活医院的适配器 */
private HospitalAdapter adapter() {
return router.active();
}
// ================= 体检状态字典 =================
/** 体检状态名 → 字典数字 */
private static final Map<String, String> PE_STATUS_DICT;
static {
Map<String, String> m = new HashMap<>();
m.put("预约", "1");
m.put("报到", "2");
m.put("检中", "3");
m.put("初审", "4");
m.put("终审", "5");
PE_STATUS_DICT = m;
}
// ================= 写操作 =================
/**
* 提交预约:入参 Map(V1/V2 VO 结构)→ 拼接管道符字符串 → 调医院存储过程。
*
* @return 成功时返回 peId
*/
public String saveOrder(Map<String, Object> body) {
OrderV2Vo vo = convertOrderV2(body);
if (isAllsecondFlag()) {
vo = vo.applyAllsecondFlag();
}
if (isOccFlag()) {
vo = vo.applyOccFlag();
}
String peInfoStr = vo.toPeInfoStr();
String peItemStr = OrderItemVo.toPeItemStr(vo.getItemList()); // companion 无 @JvmStatic,需经 Companion 调用
log.info("提交预约:peInfoStr={}, peItemStr={}", peInfoStr, peItemStr);
com.hospital.front.dto.ProcResult result = adapter().saveOrder(peInfoStr, peItemStr);
if (result.isSuccess()) {
if (result.getPeId() == null) {
throw BusinessException.procFailed("医院未返回体检编号");
}
return result.getPeId();
} else {
throwHospitalFailed(result.getErrorMsg());
// 不可达,仅为满足 Java 返回类型要求
throw new IllegalStateException("unreachable");
}
}
/** 取消预约 */
public void cancelOrder(String peId, String name, String idcard) {
log.info("取消预约:peId={}, name={}", peId, name);
com.hospital.front.dto.ProcResult result = adapter().cancelOrder(peId, name, idcard);
if (!result.isSuccess()) {
throwHospitalFailed(result.getErrorMsg());
}
}
/** 批量添加/修改单位;返回失败信息(全部成功返回 null) */
public String addUnits(List<Map<String, Object>> units) {
StringBuilder errors = new StringBuilder();
boolean useV2 = "V2".equals(getHmVer());
for (int index = 0; index < units.size(); index++) {
Map<String, Object> raw = units.get(index);
try {
UnitVo unit = convertUnit(raw);
com.hospital.front.dto.ProcResult result = adapter().addUnit(unit, useV2);
if (!result.isSuccess()) {
errors.append(result.getErrorMsg() != null ? result.getErrorMsg() : "" + (index + 1) + "条单位同步失败;");
}
} catch (Exception e) {
log.warn("addUnit 第{}条处理异常:{}", index + 1, e.getMessage());
errors.append("").append(index + 1).append("条异常:").append(e.getMessage()).append("");
}
}
return errors.length() == 0 ? null : errors.toString();
}
// ================= 查询操作(直通适配器) =================
public List<Map<String, Object>> getEmpItem(String peId) {
return adapter().getEmpItem(peId);
}
public List<Map<String, Object>> getItemList() {
return adapter().getItemList();
}
public List<Map<String, Object>> getAllItemList() {
return adapter().getAllItemList();
}
public List<Map<String, Object>> getPeItemList() {
return adapter().getPeItemList();
}
public List<Map<String, Object>> getItemInfo(String peItemCode) {
return adapter().getItemInfo(peItemCode);
}
public List<Map<String, Object>> getResult(String peId) {
return adapter().getResult(peId);
}
public List<Map<String, Object>> getConclusion(String peId) {
return adapter().getConclusion(peId);
}
public List<Map<String, Object>> getSuggest(String peId) {
return adapter().getSuggest(peId);
}
public List<Map<String, Object>> getResultByIdcardAndYear(String idNo, String year) {
return adapter().getResultByIdcardAndYear(idNo, year);
}
public List<Map<String, Object>> getResultByIdcardAndYearAll(String idNo, String year) {
return adapter().getResultByIdcardAndYearAll(idNo, year);
}
public List<Map<String, Object>> getResultByPeId(String peId) {
return adapter().getResultByPeId(peId);
}
/** 按审核日期查询体检报告(去重主表) */
public List<Map<String, Object>> getResultByAuditDate(String auditDate) {
return adapter().getDisResultByAuditDate(auditDate);
}
public List<Map<String, Object>> getResultByIdNoOrPeId(String idNo, String peId, String auditYear) {
return adapter().getResultByIdNoOrPeId(idNo, peId, auditYear);
}
public Map<String, Object> getPeIdAndDateByIdcard(String idNo) {
return adapter().getPeIdAndDateByIdcard(idNo);
}
public List<Map<String, Object>> getPeIdAndDateByIdcardList(String idNo) {
return adapter().getPeIdAndDateByIdcardList(idNo);
}
/**
* 体检状态查询:按医院返回的 PE_STATUS 名转字典数字。
*/
public List<Map<String, String>> getPhyexamStatus(String peIds) {
List<Map<String, String>> out = new ArrayList<>();
for (Map<String, Object> row : adapter().getPhyexamStatus(peIds)) {
Map<String, String> item = new HashMap<>();
item.put("peId", row.get("PE_ID") != null ? String.valueOf(row.get("PE_ID")) : "");
String statusName = row.get("PE_STATUS") != null ? String.valueOf(row.get("PE_STATUS")) : "";
item.put("peStatus", PE_STATUS_DICT.getOrDefault(statusName, ""));
out.add(item);
}
return out;
}
/**
* 按日期查询当天体检人员列表(日期空则当天)。
*/
public List<Map<String, Object>> getExamListByDate(String auditDate) {
return adapter().getExamListByDate(auditDate != null ? auditDate : DateUtils.today());
}
/**
* 体检结论(汇总为单字符串)。
*/
public Map<String, String> getTotalConclusion(String peId) {
Map<String, String> map = new HashMap<>();
map.put("totalConclusion", buildSummary(adapter().getConclusion(peId)));
return map;
}
/** 体检结论(汇总,全量来源) */
public Map<String, String> getTotalConclusionAll(String peId) {
Map<String, String> map = new HashMap<>();
map.put("totalConclusion", buildSummary(adapter().getConclusionAll(peId)));
return map;
}
/**
* 体检建议(汇总)。宁夏(nx)仅取首条 GUIDE_CONTENT。
*/
public Map<String, String> getTotalSuggest(String peId) {
Map<String, String> map = new HashMap<>();
map.put("totalSuggest", buildSuggest(adapter().getSuggest(peId), "nx".equals(adapter().hmType())));
return map;
}
/** 体检建议(汇总,全量来源) */
public Map<String, String> getTotalSuggestAll(String peId) {
Map<String, String> map = new HashMap<>();
map.put("totalSuggest", buildSuggest(adapter().getSuggestAll(peId), "nx".equals(adapter().hmType())));
return map;
}
/**
* 按身份证号 + 年份查询体检状态及项目(含宁夏体检报告兜底)。
*/
public Map<String, Object> getPeStatusByIdnoAndYear(String idNo, String year) {
Map<String, Object> map = adapter().getPeStatusByIdnoAndYear(idNo, year);
if (map != null && map.get("PE_ID") != null) {
Map<String, Object> result = new LinkedHashMap<>(map);
result.put("peItemList", adapter().getEmpItem(String.valueOf(map.get("PE_ID"))));
return result;
} else {
// 宁夏:从体检报告继续查
if ("nx".equals(adapter().hmType())) {
List<Map<String, Object>> rows = adapter().getResultByIdcardAndYear(idNo, year);
if (!rows.isEmpty()) {
Map<String, Object> row = rows.get(0);
String auditDate = row.get("AUDIT_DATE") != null ? String.valueOf(row.get("AUDIT_DATE")) : null;
Map<String, Object> out = new LinkedHashMap<>();
out.put("PE_ID", row.get("PE_ID"));
out.put("PE_PRE_DATE",
auditDate != null && auditDate.length() >= 10 ? auditDate.substring(0, 10) : auditDate);
out.put("peItemList", adapter().getEmpItem(String.valueOf(row.get("PE_ID"))));
return out;
} else {
return null;
}
} else {
return null;
}
}
}
// ================= 私有工具 =================
/** 医院侧业务失败:抛出 2003 业务异常 */
private void throwHospitalFailed(String errorMsg) {
if (errorMsg != null && !errorMsg.isEmpty()) {
throw BusinessException.of(2003, errorMsg);
} else {
throw BusinessException.procFailed("医院系统返回业务失败");
}
}
/** 结论汇总:1.科室:结论;<br/> */
private String buildSummary(List<Map<String, Object>> rows) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
String deptName = row.get("PE_DEPT_NAME") != null ? String.valueOf(row.get("PE_DEPT_NAME")) : "";
String dept = !deptName.isEmpty() ? deptName + ":" : "";
Object conclusion = row.get("CONCLUSION_TEXT");
sb.append(i + 1).append(".").append(dept).append(conclusion != null ? conclusion : "").append(";<br/>");
}
return sb.toString();
}
/** 建议汇总:宁夏仅取首条 GUIDE_CONTENT;其余 1.建议项:内容;<br/> */
private String buildSuggest(List<Map<String, Object>> rows, boolean nxStyle) {
if (nxStyle) {
if (rows.isEmpty() || rows.get(0).get("GUIDE_CONTENT") == null) {
return "";
}
return String.valueOf(rows.get(0).get("GUIDE_CONTENT"));
} else {
StringBuilder sb = new StringBuilder();
int i = 1;
for (Map<String, Object> row : rows) {
String guide = row.get("GUIDE_CONTENT") != null ? String.valueOf(row.get("GUIDE_CONTENT")) : "";
if (guide.isEmpty()) {
continue;
}
Object rec = row.get("REC_CONTENT");
sb.append(i).append(".").append(rec != null ? rec : "").append(":").append(guide).append(";<br/>");
i++;
}
return sb.toString();
}
}
/** Map → OrderV2Vo(数值/空值容错) */
private OrderV2Vo convertOrderV2(Map<String, Object> body) {
List<OrderItemVo> itemList = null;
Object rawList = body.get("itemList");
if (rawList instanceof List) {
itemList = new ArrayList<>();
for (Object item : (List<?>) rawList) {
if (item instanceof Map) {
Map<?, ?> m = (Map<?, ?>) item;
itemList.add(new OrderItemVo(
m.get("itemNo") != null ? String.valueOf(m.get("itemNo")) : null,
m.get("itemName") != null ? String.valueOf(m.get("itemName")) : null));
}
}
}
return new OrderV2Vo(
str(body, "orderDate"),
str(body, "secondDepartId"),
str(body, "secondDepartName"),
str(body, "thirdDepartName"),
str(body, "name"),
str(body, "sexName"),
str(body, "birthDay"),
body.get("age") instanceof Number ? ((Number) body.get("age")).intValue() : null,
str(body, "idCard"),
str(body, "marrigeStatusName"),
str(body, "country"),
str(body, "nationName"),
str(body, "birthplace"),
str(body, "position"),
str(body, "profession"),
str(body, "feeType"),
str(body, "liveSpace"),
str(body, "zipCode"),
str(body, "mobile"),
str(body, "email"),
str(body, "empSysno"),
str(body, "medicalType"),
str(body, "medicalClass"),
str(body, "workShape"),
str(body, "workDate"),
str(body, "jobLevel"),
str(body, "workPlace"),
str(body, "baseSite"),
str(body, "workTypeCode"),
str(body, "workTypeOther"),
str(body, "education"),
str(body, "title"),
str(body, "oldDepartName"),
str(body, "harmReasonCode"),
str(body, "harmReasonOther"),
str(body, "harmDate"),
str(body, "defendCancer"),
itemList);
}
/** 取字符串值(null 安全) */
private static String str(Map<String, Object> map, String key) {
Object v = map.get(key);
return v != null ? String.valueOf(v) : null;
}
/** Map → UnitVo */
private UnitVo convertUnit(Map<String, Object> raw) {
return new UnitVo(
str(raw, "unitCode"),
str(raw, "unitName"),
str(raw, "parentUnitCode"),
str(raw, "address"),
str(raw, "connecter1"),
str(raw, "phone1"),
str(raw, "connecter2"),
str(raw, "phone2"));
}
}
@@ -1,10 +1,12 @@
package com.hospital.front.task package com.hospital.front.task;
import com.hospital.front.adapter.HospitalRouter import lombok.extern.slf4j.Slf4j;
import org.noear.solon.annotation.Component
import org.noear.solon.annotation.Inject import org.noear.solon.annotation.Component;
import org.noear.solon.scheduling.annotation.Scheduled import org.noear.solon.annotation.Inject;
import org.slf4j.LoggerFactory import org.noear.solon.scheduling.annotation.Scheduled;
import com.hospital.front.adapter.HospitalRouter;
/** /**
* 心跳任务占位 * 心跳任务占位
@@ -14,20 +16,20 @@ import org.slf4j.LoggerFactory
* *
* @author hospital-front重构 * @author hospital-front重构
*/ */
@Slf4j
@Component @Component
class HeartbeatTask { public class HeartbeatTask {
private val log = LoggerFactory.getLogger(HeartbeatTask::class.java)
@Inject @Inject
private lateinit var router: HospitalRouter private HospitalRouter router;
/** /**
* 60 秒一次心跳默认 cron0/60 * 60 秒一次心跳默认 cron0/60
* Step 6 将改为上报 B 端的 WS 消息 * Step 6 将改为上报 B 端的 WS 消息
*/ */
@Scheduled(cron = "0 * * * * *") @Scheduled(cron = "0 * * * * *")
fun heartbeat() { public void heartbeat() {
log.debug("[Heartbeat] hospital={}, uptime ok", router.activeCode()) log.debug("[Heartbeat] hospital={}, uptime ok", router.activeCode());
} }
} }
@@ -0,0 +1,70 @@
package com.hospital.front.task;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.noear.solon.Solon;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.scheduling.annotation.Scheduled;
import com.hospital.front.adapter.HospitalRouter;
/**
* Uptime-Kuma 监控心跳推送。
*
* 定时检查数据库连接是否正常,正常则向 Kuma 服务器 push 心跳。
* 配置(app.yml 或医院配置中):
* kuma.enable=true
* kuma.cron=0 *\/3 * * * *
* kuma.url=https://monitor.rk-health.com:9200/api/push/uzBrX74NTC?status=up&msg=OK
*
* 沿用原 hospitalmiddle 的 UptimeKumaTask 逻辑,适配 Solon。
*/
@Slf4j
@Component
public class UptimeKumaTask {
@Inject
private HospitalRouter router;
private final OkHttpClient http = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
/**
* 定时 push 心跳(cron 由配置 kuma.cron 控制,默认每 3 分钟)。
* 仅当 kuma.enable=true 且 kuma.url 非空时执行。
*/
@Scheduled(cron = "${kuma.cron:0 */3 * * * *}")
public void kumaPush() {
boolean enable = "true".equals(Solon.cfg().get("kuma.enable", "false"));
String kumaUrl = Solon.cfg().get("kuma.url", "");
if (!enable || kumaUrl == null || kumaUrl.isEmpty()) {
return;
}
// 检查数据库连接
com.hospital.front.dto.Result<Boolean> health = router.active().healthCheck();
if (health.getCode() == 0) {
try {
Response resp = http.newCall(new Request.Builder().url(kumaUrl).get().build()).execute();
if (resp.isSuccessful()) {
log.info("[Kuma] 心跳推送成功:{}", kumaUrl);
} else {
log.warn("[Kuma] 心跳推送失败:HTTP {}", resp.code());
}
resp.close();
} catch (Exception e) {
log.warn("[Kuma] 心跳推送异常:{}", e.getMessage());
}
} else {
log.warn("[Kuma] 数据库异常,跳过心跳:{}", health.getMsg());
}
}
}
@@ -0,0 +1,35 @@
package com.hospital.front.vo;
import java.util.List;
import lombok.Data;
/**
* 体检结果传输 VOMapper XML resultType)。
*
* 字段名与原 hospitalmiddle 的 MedicalMiddleResultVo 保持一致(XML 映射契约)。
* 数据库日期列已由 SQL 转为字符串(to_char / CONVERT)。
*/
@Data
public class MedicalMiddleResultVo {
private String idNo;
private String peResult;
private String hospitalId;
private String unitCode;
private String peQueueDate;
private String printContext;
private String peItemName;
private String peId;
private String name;
private String itemAssemName;
private String peItemCode;
private String auditDate;
private String itemAssemCode;
private String unitName;
private String peVisitId;
private String unit;
private String conclusion;
private String suggest;
/** 子表:体检项目结果列表(汇总接口组装用) */
private List<MedicalMiddleResultVo> resultVoList;
}
@@ -0,0 +1,57 @@
package com.hospital.front.vo;
import java.util.List;
/**
* 预约订单体检项目条目。
*
* JSON 字段名与原 hospitalmiddle 的 OrderItemVo 保持一致(调用方契约,不可修改)。
*/
public class OrderItemVo {
private String itemNo;
private String itemName;
public OrderItemVo() {
}
public OrderItemVo(String itemNo, String itemName) {
this.itemNo = itemNo;
this.itemName = itemName;
}
public String getItemNo() {
return itemNo;
}
public void setItemNo(String itemNo) {
this.itemNo = itemNo;
}
public String getItemName() {
return itemName;
}
public void setItemName(String itemName) {
this.itemName = itemName;
}
/**
* 拼接子表字符串:多项之间用 & 分隔,项内 编号|名称 分隔。
*/
public static String toPeItemStr(List<OrderItemVo> items) {
if (items == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < items.size(); i++) {
if (i > 0) {
sb.append('&');
}
OrderItemVo it = items.get(i);
sb.append(it.itemNo == null ? "" : it.itemNo)
.append('|')
.append(it.itemName == null ? "" : it.itemName);
}
return sb.toString();
}
}
@@ -0,0 +1,230 @@
package com.hospital.front.vo;
/**
* V2 版预约订单 VOhm.ver=V2 时使用)。
*
* 与 V1 的差异:工种拆为 编码+其它;危害因素拆为 编码+其它+接害时间。
*/
public class OrderV2Vo {
private String orderDate; // 1 预约日期 yyyy-MM-dd
private String secondDepartId; // 2 所属单位编码
private String secondDepartName; // 3 所属单位名称
private String thirdDepartName; // 4 所属部门
private String name; // 5 姓名
private String sexName; // 6 性别(男/女)
private String birthDay; // 7 出生日期 yyyy-MM-dd
private Integer age; // 8 年龄
private String idCard; // 9 身份证号
private String marrigeStatusName; // 10 婚姻
private String country; // 11 国家
private String nationName; // 12 民族
private String birthplace; // 13 出生地
private String position; // 14 身份
private String profession; // 15 职业
private String feeType; // 16 费别
private String liveSpace; // 17 家庭住址
private String zipCode; // 18 邮编
private String mobile; // 19 移动电话
private String email; // 20 邮箱
private String empSysno; // 21 员工编号
private String medicalType; // 22 体检类型(职业/健康)
private String medicalClass; // 23 体检类别(岗前/在岗期间)
private String workShape; // 24 用工形式(合同/劳务)
private String workDate; // 25 入职时间 yyyy-MM-dd
private String jobLevel; // 26 职务级别
private String workPlace; // 27 工作地
private String baseSite; // 28 基层站
private String workTypeCode; // 29 职业工种编码(V2
private String workTypeOther; // 29b 其它工种(V2
private String education; // 30 学历
private String title; // 31 职称
private String oldDepartName; // 32 原单位
private String harmReasonCode; // 33 危害因素编码(V2
private String harmReasonOther; // 33b 其它危害因素(V2
private String harmDate; // 33c 接害时间 yyyy-MM-ddV2
private String defendCancer; // 34 防癌项
private java.util.List<OrderItemVo> itemList;
/**
* 全参构造器(保持与 Kotlin data class 相同的字段顺序,供 OrderService 组装用)。
*/
public OrderV2Vo(String orderDate, String secondDepartId, String secondDepartName, String thirdDepartName,
String name, String sexName, String birthDay, Integer age, String idCard,
String marrigeStatusName, String country, String nationName, String birthplace,
String position, String profession, String feeType, String liveSpace, String zipCode,
String mobile, String email, String empSysno, String medicalType, String medicalClass,
String workShape, String workDate, String jobLevel, String workPlace, String baseSite,
String workTypeCode, String workTypeOther, String education, String title,
String oldDepartName, String harmReasonCode, String harmReasonOther, String harmDate,
String defendCancer, java.util.List<OrderItemVo> itemList) {
this.orderDate = orderDate;
this.secondDepartId = secondDepartId;
this.secondDepartName = secondDepartName;
this.thirdDepartName = thirdDepartName;
this.name = name;
this.sexName = sexName;
this.birthDay = birthDay;
this.age = age;
this.idCard = idCard;
this.marrigeStatusName = marrigeStatusName;
this.country = country;
this.nationName = nationName;
this.birthplace = birthplace;
this.position = position;
this.profession = profession;
this.feeType = feeType;
this.liveSpace = liveSpace;
this.zipCode = zipCode;
this.mobile = mobile;
this.email = email;
this.empSysno = empSysno;
this.medicalType = medicalType;
this.medicalClass = medicalClass;
this.workShape = workShape;
this.workDate = workDate;
this.jobLevel = jobLevel;
this.workPlace = workPlace;
this.baseSite = baseSite;
this.workTypeCode = workTypeCode;
this.workTypeOther = workTypeOther;
this.education = education;
this.title = title;
this.oldDepartName = oldDepartName;
this.harmReasonCode = harmReasonCode;
this.harmReasonOther = harmReasonOther;
this.harmDate = harmDate;
this.defendCancer = defendCancer;
this.itemList = itemList;
}
public OrderV2Vo() {
}
public String getOrderDate() { return orderDate; }
public void setOrderDate(String v) { this.orderDate = v; }
public String getSecondDepartId() { return secondDepartId; }
public void setSecondDepartId(String v) { this.secondDepartId = v; }
public String getSecondDepartName() { return secondDepartName; }
public void setSecondDepartName(String v) { this.secondDepartName = v; }
public String getThirdDepartName() { return thirdDepartName; }
public void setThirdDepartName(String v) { this.thirdDepartName = v; }
public String getName() { return name; }
public void setName(String v) { this.name = v; }
public String getSexName() { return sexName; }
public void setSexName(String v) { this.sexName = v; }
public String getBirthDay() { return birthDay; }
public void setBirthDay(String v) { this.birthDay = v; }
public Integer getAge() { return age; }
public void setAge(Integer v) { this.age = v; }
public String getIdCard() { return idCard; }
public void setIdCard(String v) { this.idCard = v; }
public String getMarrigeStatusName() { return marrigeStatusName; }
public void setMarrigeStatusName(String v) { this.marrigeStatusName = v; }
public String getCountry() { return country; }
public void setCountry(String v) { this.country = v; }
public String getNationName() { return nationName; }
public void setNationName(String v) { this.nationName = v; }
public String getBirthplace() { return birthplace; }
public void setBirthplace(String v) { this.birthplace = v; }
public String getPosition() { return position; }
public void setPosition(String v) { this.position = v; }
public String getProfession() { return profession; }
public void setProfession(String v) { this.profession = v; }
public String getFeeType() { return feeType; }
public void setFeeType(String v) { this.feeType = v; }
public String getLiveSpace() { return liveSpace; }
public void setLiveSpace(String v) { this.liveSpace = v; }
public String getZipCode() { return zipCode; }
public void setZipCode(String v) { this.zipCode = v; }
public String getMobile() { return mobile; }
public void setMobile(String v) { this.mobile = v; }
public String getEmail() { return email; }
public void setEmail(String v) { this.email = v; }
public String getEmpSysno() { return empSysno; }
public void setEmpSysno(String v) { this.empSysno = v; }
public String getMedicalType() { return medicalType; }
public void setMedicalType(String v) { this.medicalType = v; }
public String getMedicalClass() { return medicalClass; }
public void setMedicalClass(String v) { this.medicalClass = v; }
public String getWorkShape() { return workShape; }
public void setWorkShape(String v) { this.workShape = v; }
public String getWorkDate() { return workDate; }
public void setWorkDate(String v) { this.workDate = v; }
public String getJobLevel() { return jobLevel; }
public void setJobLevel(String v) { this.jobLevel = v; }
public String getWorkPlace() { return workPlace; }
public void setWorkPlace(String v) { this.workPlace = v; }
public String getBaseSite() { return baseSite; }
public void setBaseSite(String v) { this.baseSite = v; }
public String getWorkTypeCode() { return workTypeCode; }
public void setWorkTypeCode(String v) { this.workTypeCode = v; }
public String getWorkTypeOther() { return workTypeOther; }
public void setWorkTypeOther(String v) { this.workTypeOther = v; }
public String getEducation() { return education; }
public void setEducation(String v) { this.education = v; }
public String getTitle() { return title; }
public void setTitle(String v) { this.title = v; }
public String getOldDepartName() { return oldDepartName; }
public void setOldDepartName(String v) { this.oldDepartName = v; }
public String getHarmReasonCode() { return harmReasonCode; }
public void setHarmReasonCode(String v) { this.harmReasonCode = v; }
public String getHarmReasonOther() { return harmReasonOther; }
public void setHarmReasonOther(String v) { this.harmReasonOther = v; }
public String getHarmDate() { return harmDate; }
public void setHarmDate(String v) { this.harmDate = v; }
public String getDefendCancer() { return defendCancer; }
public void setDefendCancer(String v) { this.defendCancer = v; }
public java.util.List<OrderItemVo> getItemList() { return itemList; }
public void setItemList(java.util.List<OrderItemVo> v) { this.itemList = v; }
/**
* 按存储过程约定顺序拼接主表字符串(竖线分隔,空值为空串)。
*/
public String toPeInfoStr() {
Object[] fields = {
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,
};
StringBuilder sb = new StringBuilder();
for (int i = 0; i < fields.length; i++) {
if (i > 0) {
sb.append('|');
}
sb.append(fields[i] == null ? "" : String.valueOf(fields[i]));
}
return sb.toString();
}
/**
* allsecondFlag 特殊处理(宁夏):二级单位编码 9 位截前 6 位,名称截"-"之前。
*/
public OrderV2Vo applyAllsecondFlag() {
if (secondDepartId != null && secondDepartId.length() == 9) {
secondDepartId = secondDepartId.substring(0, 6);
}
if (secondDepartName != null && secondDepartName.contains("-")) {
secondDepartName = secondDepartName.substring(0, secondDepartName.indexOf('-'));
}
return this;
}
/**
* occFlag 特殊处理:健康体检不发送职业相关信息(危害因素、工种、接害时间)。
*/
public OrderV2Vo applyOccFlag() {
if (!"职业".equals(medicalType)) {
harmDate = null;
harmReasonCode = null;
harmReasonOther = null;
workTypeCode = null;
workTypeOther = null;
}
return this;
}
}
@@ -0,0 +1,139 @@
package com.hospital.front.vo;
/**
* V1 版预约订单 VOhm.ver=V1 时使用)。
*
* 字段名与字段顺序均与原 hospitalmiddle 的 OrderVoJava)保持一致,
* 字段顺序即存储过程 P_ADD_PE_REGIST 的 peInfoStr 竖线分隔顺序,不可调整。
*/
public class OrderVo {
private String orderDate; // 1 预约日期 yyyy-MM-dd
private String secondDepartId; // 2 所属单位编码
private String secondDepartName; // 3 所属单位名称
private String thirdDepartName; // 4 所属部门
private String name; // 5 姓名
private String sexName; // 6 性别(男/女)
private String birthDay; // 7 出生日期 yyyy-MM-dd
private Integer age; // 8 年龄
private String idCard; // 9 身份证号
private String marrigeStatusName; // 10 婚姻
private String country; // 11 国家
private String nationName; // 12 民族
private String birthplace; // 13 出生地
private String position; // 14 身份
private String profession; // 15 职业
private String feeType; // 16 费别
private String liveSpace; // 17 家庭住址
private String zipCode; // 18 邮编
private String mobile; // 19 移动电话
private String email; // 20 邮箱
private String empSysno; // 21 员工编号
private String medicalType; // 22 体检类型(职业/健康)
private String medicalClass; // 23 体检类别(岗前/在岗期间)
private String workShape; // 24 用工形式(合同/劳务)
private String workDate; // 25 入职时间 yyyy-MM-dd
private String jobLevel; // 26 职务级别
private String workPlace; // 27 工作地
private String baseSite; // 28 基层站
private String workType; // 29 职业工种(V1 单字段)
private String education; // 30 学历
private String title; // 31 职称
private String oldDepartName; // 32 原单位
private String harmReason; // 33 危害因素(V1 单字段)
private String defendCancer; // 34 防癌项
private java.util.List<OrderItemVo> itemList;
public String getOrderDate() { return orderDate; }
public void setOrderDate(String v) { this.orderDate = v; }
public String getSecondDepartId() { return secondDepartId; }
public void setSecondDepartId(String v) { this.secondDepartId = v; }
public String getSecondDepartName() { return secondDepartName; }
public void setSecondDepartName(String v) { this.secondDepartName = v; }
public String getThirdDepartName() { return thirdDepartName; }
public void setThirdDepartName(String v) { this.thirdDepartName = v; }
public String getName() { return name; }
public void setName(String v) { this.name = v; }
public String getSexName() { return sexName; }
public void setSexName(String v) { this.sexName = v; }
public String getBirthDay() { return birthDay; }
public void setBirthDay(String v) { this.birthDay = v; }
public Integer getAge() { return age; }
public void setAge(Integer v) { this.age = v; }
public String getIdCard() { return idCard; }
public void setIdCard(String v) { this.idCard = v; }
public String getMarrigeStatusName() { return marrigeStatusName; }
public void setMarrigeStatusName(String v) { this.marrigeStatusName = v; }
public String getCountry() { return country; }
public void setCountry(String v) { this.country = v; }
public String getNationName() { return nationName; }
public void setNationName(String v) { this.nationName = v; }
public String getBirthplace() { return birthplace; }
public void setBirthplace(String v) { this.birthplace = v; }
public String getPosition() { return position; }
public void setPosition(String v) { this.position = v; }
public String getProfession() { return profession; }
public void setProfession(String v) { this.profession = v; }
public String getFeeType() { return feeType; }
public void setFeeType(String v) { this.feeType = v; }
public String getLiveSpace() { return liveSpace; }
public void setLiveSpace(String v) { this.liveSpace = v; }
public String getZipCode() { return zipCode; }
public void setZipCode(String v) { this.zipCode = v; }
public String getMobile() { return mobile; }
public void setMobile(String v) { this.mobile = v; }
public String getEmail() { return email; }
public void setEmail(String v) { this.email = v; }
public String getEmpSysno() { return empSysno; }
public void setEmpSysno(String v) { this.empSysno = v; }
public String getMedicalType() { return medicalType; }
public void setMedicalType(String v) { this.medicalType = v; }
public String getMedicalClass() { return medicalClass; }
public void setMedicalClass(String v) { this.medicalClass = v; }
public String getWorkShape() { return workShape; }
public void setWorkShape(String v) { this.workShape = v; }
public String getWorkDate() { return workDate; }
public void setWorkDate(String v) { this.workDate = v; }
public String getJobLevel() { return jobLevel; }
public void setJobLevel(String v) { this.jobLevel = v; }
public String getWorkPlace() { return workPlace; }
public void setWorkPlace(String v) { this.workPlace = v; }
public String getBaseSite() { return baseSite; }
public void setBaseSite(String v) { this.baseSite = v; }
public String getWorkType() { return workType; }
public void setWorkType(String v) { this.workType = v; }
public String getEducation() { return education; }
public void setEducation(String v) { this.education = v; }
public String getTitle() { return title; }
public void setTitle(String v) { this.title = v; }
public String getOldDepartName() { return oldDepartName; }
public void setOldDepartName(String v) { this.oldDepartName = v; }
public String getHarmReason() { return harmReason; }
public void setHarmReason(String v) { this.harmReason = v; }
public String getDefendCancer() { return defendCancer; }
public void setDefendCancer(String v) { this.defendCancer = v; }
public java.util.List<OrderItemVo> getItemList() { return itemList; }
public void setItemList(java.util.List<OrderItemVo> v) { this.itemList = v; }
/**
* 按存储过程约定顺序拼接主表字符串(竖线分隔,空值为空串)。
*/
public String toPeInfoStr() {
Object[] fields = {
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,
};
StringBuilder sb = new StringBuilder();
for (int i = 0; i < fields.length; i++) {
if (i > 0) {
sb.append('|');
}
sb.append(fields[i] == null ? "" : String.valueOf(fields[i]));
}
return sb.toString();
}
}
@@ -0,0 +1,50 @@
package com.hospital.front.vo;
/**
* 单位信息 VO(addUnit 接口入参,字段名与原 UnitVo 一致)。
*/
public class UnitVo {
private String unitCode; // 单位编码
private String unitName; // 单位名称
private String parentUnitCode; // 上级体检单位编号(V2
private String address; // 单位地址(V2
private String connecter1; // 联系人1V2
private String phone1; // 电话1V2
private String connecter2; // 联系人2V2
private String phone2; // 电话2V2
/**
* 全参构造器(保持与 Kotlin data class 相同的字段顺序,供 OrderService 组装用)。
*/
public UnitVo(String unitCode, String unitName, String parentUnitCode, String address,
String connecter1, String phone1, String connecter2, String phone2) {
this.unitCode = unitCode;
this.unitName = unitName;
this.parentUnitCode = parentUnitCode;
this.address = address;
this.connecter1 = connecter1;
this.phone1 = phone1;
this.connecter2 = connecter2;
this.phone2 = phone2;
}
public UnitVo() {
}
public String getUnitCode() { return unitCode; }
public void setUnitCode(String v) { this.unitCode = v; }
public String getUnitName() { return unitName; }
public void setUnitName(String v) { this.unitName = v; }
public String getParentUnitCode() { return parentUnitCode; }
public void setParentUnitCode(String v) { this.parentUnitCode = v; }
public String getAddress() { return address; }
public void setAddress(String v) { this.address = v; }
public String getConnecter1() { return connecter1; }
public void setConnecter1(String v) { this.connecter1 = v; }
public String getPhone1() { return phone1; }
public void setPhone1(String v) { this.phone1 = v; }
public String getConnecter2() { return connecter2; }
public void setConnecter2(String v) { this.connecter2 = v; }
public String getPhone2() { return phone2; }
public void setPhone2(String v) { this.phone2 = v; }
}
@@ -1,56 +0,0 @@
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.batLinux: 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",
)
}
}
}
@@ -1,74 +0,0 @@
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 停机中...")
dispatcher.shutdownLogStream()
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()
}
@@ -1,113 +0,0 @@
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)
}
/** 日志实时流管理(懒加载) */
private val logStream: LogStreamManager by lazy {
LogStreamManager(config, mainAppClient, client)
}
/** 日志实时流(供 AgentMain 停机时统一释放) */
fun shutdownLogStream() = logStream.stopAll()
/** 分发并执行命令 */
fun dispatch(type: String, payloadJson: String?) {
log.info("收到命令:{}", type)
val payload = payloadJson?.let { runCatching { ONode.load(it) }.getOrNull() }
val result: Map<String, Any?> = 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))
}
"LOG_TAIL_CTRL" -> {
val action = payload?.get("action")?.getString() ?: "start"
val file = payload?.get("file")?.getString() ?: "info.log"
logStream.control(action, file)
}
"RESTART" -> {
val ok = mainAppClient.restart()
mapOf("ok" to ok, "msg" to if (ok) "已触发优雅重启,等待 ProcessGuard 拉起" else "重启请求失败")
}
"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))
}
}
@@ -1,119 +0,0 @@
package com.hospital.agent.cmd
import com.hospital.agent.AgentConfig
import com.hospital.agent.ipc.MainAppClient
import com.hospital.agent.ws.AgentClient
import org.noear.snack.ONode
import org.slf4j.LoggerFactory
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
/**
* 日志实时推送管理器。
*
* B 端通过 LOG_TAIL_CTRL 命令开启/停止某设备某日志文件的实时流:
* - 开启后每 2 秒从主程序 /api/admin/log/tail 拉取增量行
* - 新行打包为 LOG_BATCH 上报 B 端(B 端转发给浏览器展示)
* - 每个文件独立游标(已发送行数),支持多文件并行
*
* 增量算法:tail(max(offset+FETCH, 400)) 行,取 offset 之后的部分;
* 日志滚动(文件变小/重置)时自动重置游标。
*/
class LogStreamManager(
private val config: AgentConfig,
private val mainAppClient: MainAppClient,
private val client: AgentClient,
) {
private val log = LoggerFactory.getLogger(LogStreamManager::class.java)
/** 单流状态:文件名 → 已发送行数 */
private val cursors = ConcurrentHashMap<String, Int>()
/** 运行中的流:文件名 → 是否激活 */
private val active = ConcurrentHashMap<String, Boolean>()
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
companion object {
/** 轮询间隔(秒) */
private const val POLL_SEC = 2L
/** 单次拉取上限(行) */
private const val FETCH_MAX = 400
}
/**
* 控制命令入口。
*
* @param action start / stop
* @param file 日志文件名(info.log / error.log
*/
fun control(action: String, file: String): Map<String, Any?> {
return when (action) {
"start" -> {
if (active.putIfAbsent(file, true) != null) {
return mapOf("ok" to true, "msg" to "流已存在:$file")
}
cursors.remove(file)
scheduler.scheduleWithFixedDelay({ poll(file) }, 0, POLL_SEC, TimeUnit.SECONDS)
log.info("日志流开启:{}", file)
mapOf("ok" to true, "msg" to "日志流已开启:$file")
}
"stop" -> {
active.remove(file)
cursors.remove(file)
log.info("日志流停止:{}", file)
mapOf("ok" to true, "msg" to "日志流已停止:$file")
}
else -> mapOf("ok" to false, "msg" to "未知 action$action(支持 start/stop")
}
}
/** 停止全部流(Agent 停机时调用) */
fun stopAll() {
active.clear()
cursors.clear()
scheduler.shutdownNow()
}
/**
* 单轮增量拉取:取尾部行,比对游标,新行打包上报。
*/
private fun poll(file: String) {
if (active[file] != true) return
try {
val offset = cursors[file] ?: 0
// 多拉一段,保证能覆盖到 offset 之前的已有内容
val fetch = (offset + 100).coerceAtLeast(FETCH_MAX)
val raw = mainAppClient.logTail(file, fetch)
log.debug("日志流轮询:file={}, offset={}, fetch={}, rawLen={}", file, offset, fetch, raw?.length ?: -1)
if (raw == null) return
val node = ONode.load(raw)
if (node.get("code").getInt() != 0) return
val content = node.get("data").getString() ?: return
if (content.isEmpty()) return
val lines = content.split('\n')
// 日志滚动检测:行数比游标还少很多 → 文件已重置
if (lines.size < offset) {
cursors[file] = 0
return
}
if (lines.size <= offset) return // 无新行
val fresh = lines.drop(offset).filter { it.isNotBlank() }
cursors[file] = lines.size
if (fresh.isEmpty()) return
client.send("LOG_BATCH", mapOf(
"file" to file,
"lines" to fresh,
"ts" to System.currentTimeMillis() / 1000,
))
} catch (e: Exception) {
log.warn("日志流轮询失败({}):{}", file, e.message)
}
}
}
@@ -1,296 +0,0 @@
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<String, Any?> = 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<String, Any?> {
log.error("升级失败:{}", msg)
return mapOf("ok" to false, "version" to version, "msg" to msg)
}
}
@@ -1,101 +0,0 @@
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<String, Any?> {
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<String, Any?> {
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
}
}
@@ -1,93 +0,0 @@
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)
}
}
}
}
@@ -1,145 +0,0 @@
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()
}
@@ -1,40 +0,0 @@
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<String>) {
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] 优雅停机中...")
})
}
@@ -1,89 +0,0 @@
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 {
/** 数据库 MapperSolon 按类型注入) */
@Inject
protected lateinit var mapper: BsmOrderMapper
override fun hmType(): String = "bsm"
override fun healthCheck(): Result<Boolean> = 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<String, Any?>("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<String, Any?>("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<String, Any?>(
"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)
}
@@ -1,56 +0,0 @@
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<Boolean> = 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<Map<String, Any?>> = emptyList()
override fun getItemList(): List<Map<String, Any?>> = emptyList()
override fun getAllItemList(): List<Map<String, Any?>> = emptyList()
override fun getPeItemList(): List<Map<String, Any?>> = emptyList()
override fun getItemInfo(peItemCode: String): List<Map<String, Any?>> = emptyList()
override fun getEmpItem(peId: String): List<Map<String, Any?>> = emptyList()
override fun getResult(peId: String): List<Map<String, Any?>> = emptyList()
override fun getConclusion(peId: String): List<Map<String, Any?>> = emptyList()
override fun getConclusionAll(peId: String): List<Map<String, Any?>> = emptyList()
override fun getSuggest(peId: String): List<Map<String, Any?>> = emptyList()
override fun getSuggestAll(peId: String): List<Map<String, Any?>> = emptyList()
override fun getPeStatusByIdnoAndYear(idNo: String, year: String): Map<String, Any?>? = null
override fun getResultByIdcardAndYear(idNo: String, year: String): List<Map<String, Any?>> = emptyList()
override fun getResultByIdcardAndYearAll(idNo: String, year: String): List<Map<String, Any?>> = emptyList()
override fun getResultByPeId(peId: String): List<Map<String, Any?>> = emptyList()
override fun getDisResultByAuditDate(auditDate: String): List<Map<String, Any?>> = emptyList()
override fun getItemByPeId(peId: String): List<Map<String, Any?>> = emptyList()
override fun getItemByPeIdAll(peId: String): List<Map<String, Any?>> = emptyList()
override fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String): List<Map<String, Any?>> = emptyList()
override fun getPeIdAndDateByIdcard(idNo: String): Map<String, Any?>? = null
override fun getPeIdAndDateByIdcardList(idNo: String): List<Map<String, Any?>> = emptyList()
override fun getExamListByDate(auditDate: String?): List<Map<String, Any?>> = emptyList()
}
@@ -1,100 +0,0 @@
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 方言类型:bsmOracle/ nx / yaSqlServer */
fun hmType(): String
/** 轻量健康检查(SELECT 1 探测) */
fun healthCheck(): Result<Boolean>
// ================= 存储过程(写) =================
/** 提交预约(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<Map<String, Any?>>
/** 医院所有体检项目(组合项) */
fun getItemList(): List<Map<String, Any?>>
/** 医院所有体检项目及指标项 */
fun getAllItemList(): List<Map<String, Any?>>
/** 医院所有体检指标项 */
fun getPeItemList(): List<Map<String, Any?>>
/** 按指标项 CODE 查询所属体检组合 */
fun getItemInfo(peItemCode: String): List<Map<String, Any?>>
/** 员工体检项目 */
fun getEmpItem(peId: String): List<Map<String, Any?>>
/** 员工体检结果 */
fun getResult(peId: String): List<Map<String, Any?>>
/** 体检结论(列表 / 全量) */
fun getConclusion(peId: String): List<Map<String, Any?>>
fun getConclusionAll(peId: String): List<Map<String, Any?>>
/** 体检建议(列表 / 全量) */
fun getSuggest(peId: String): List<Map<String, Any?>>
fun getSuggestAll(peId: String): List<Map<String, Any?>>
/** 按身份证号 + 年份查询体检状态 */
fun getPeStatusByIdnoAndYear(idNo: String, year: String): Map<String, Any?>?
/** 按身份证号 + 年份查询体检结果(默认 / 全量) */
fun getResultByIdcardAndYear(idNo: String, year: String): List<Map<String, Any?>>
fun getResultByIdcardAndYearAll(idNo: String, year: String): List<Map<String, Any?>>
/** 按体检编号查询体检结果 */
fun getResultByPeId(peId: String): List<Map<String, Any?>>
/** 按审核日期查询体检报告(去重主表) */
fun getDisResultByAuditDate(auditDate: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表(默认 / 全量) */
fun getItemByPeId(peId: String): List<Map<String, Any?>>
fun getItemByPeIdAll(peId: String): List<Map<String, Any?>>
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String): List<Map<String, Any?>>
/** 按身份证号查询当年 peId 与预约日期(单条 / 列表) */
fun getPeIdAndDateByIdcard(idNo: String): Map<String, Any?>?
fun getPeIdAndDateByIdcardList(idNo: String): List<Map<String, Any?>>
/** 按审核日期查询当天体检人员列表(去重;日期空则当天) */
fun getExamListByDate(auditDate: String?): List<Map<String, Any?>>
}
@@ -1,43 +0,0 @@
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<HospitalAdapter> =
Solon.context().getBeansOfType(HospitalAdapter::class.java)
private val map: Map<String, HospitalAdapter> 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<String> = map.keys
}
@@ -1,14 +0,0 @@
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 = "泾河医院"
}
@@ -1,17 +0,0 @@
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 = "宁夏宝石花医院"
}
@@ -1,89 +0,0 @@
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 {
/** 数据库 MapperSolon 按类型注入) */
@Inject
protected lateinit var mapper: NxOrderMapper
override fun hmType(): String = "nx"
override fun healthCheck(): Result<Boolean> = 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<String, Any?>("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<String, Any?>("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<String, Any?>(
"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)
}
@@ -1,16 +0,0 @@
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 = "庆阳医院"
}
@@ -1,14 +0,0 @@
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 = "兴隆园医院"
}
@@ -1,88 +0,0 @@
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 {
/** 数据库 MapperSolon 按类型注入) */
@Inject
protected lateinit var mapper: YaOrderMapper
override fun hmType(): String = "ya"
override fun healthCheck(): Result<Boolean> = 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<String, Any?>("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<String, Any?>("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<String, Any?>(
"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)
}
@@ -1,14 +0,0 @@
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 = "延安医院"
}
@@ -1,212 +0,0 @@
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<Map<String, Any?>> {
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<Map<String, String>> {
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<Map<String, Any?>> {
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<String> {
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<Nothing?> {
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<List<Map<String, Any?>>> {
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);容错解码(历史日志可能混有 GBK 内容) */
@Mapping("/log/tail")
fun logTail(ctx: Context, @Param("file") file: String, @Param("lines") lines: Int?): Result<String> {
checkToken(ctx)
val target = safeLogFile(file)
val n = (lines ?: 200).coerceIn(1, 2000)
val decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPLACE)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPLACE)
val text = decoder.decode(java.nio.ByteBuffer.wrap(Files.readAllBytes(target.toPath()))).toString()
val all = text.split('\n')
return Result.success(all.takeLast(n).joinToString("\n"))
}
/** 日志全量下载 */
@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<Nothing?> {
checkToken(ctx)
val delay = (delayMs ?: 1000L).coerceIn(0, 10000)
Thread {
Thread.sleep(delay)
Solon.stopBlock() // 触发 Solon 停机事件(优雅退出)
}.apply { isDaemon = true }.start()
return Result.success()
}
}
@@ -1,183 +0,0 @@
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: String): Result<String> {
// Solon 对 Map 泛型 body 反序列化支持不佳,用 snack3 手动解析
@Suppress("UNCHECKED_CAST")
val map: Map<String, Any?> = org.noear.snack.ONode.load(body).toObject(Map::class.java) as Map<String, Any?>
return Result.success(service.saveOrder(map))
}
/** 5. 取消体检预约 */
@Mapping(value = "/cancelOrder/{peId}", method = [MethodType.POST])
fun cancelOrder(
@Path("peId") peId: String,
@Param("name") name: String,
@Param("idcard") idcard: String,
): Result<Nothing?> {
service.cancelOrder(peId, name, idcard)
return Result.success()
}
// ================= 2. 体检状态 =================
/** 3. 体检状态查询(多个 peId 逗号分隔) */
@Mapping("/getPeStatus/{peIds}")
fun getPeStatus(@Path("peIds") peIds: String): Result<List<Map<String, String>>> =
Result.success(service.getPhyexamStatus(peIds))
/** 13. 根据身份证号及年份获取当前体检状态及体检项状态 */
@Mapping("/getPeStatusAndItemList/{idNo}/{year}")
fun getPeStatusAndItemList(@Path("idNo") idNo: String, @Path("year") year: String): Result<Map<String, Any?>?> =
Result.success(service.getPeStatusByIdnoAndYear(idNo, year))
// ================= 3. 体检项目 =================
/** 1. 查询员工体检项目 */
@Mapping("/getEmpItem/{peId}")
fun getEmpItem(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getEmpItem(peId))
/** 2. 体检项目查询 */
@Mapping("/getItemList")
fun getItemList(): Result<List<Map<String, Any?>>> =
Result.success(service.getItemList())
/** 6. 体检指标项数据获取 */
@Mapping("/getPeItemList")
fun getPeItemList(): Result<List<Map<String, Any?>>> =
Result.success(service.getPeItemList())
/** 6.1 体检指标项及体检组合数据获取 */
@Mapping("/getAllItemList")
fun getAllItemList(): Result<List<Map<String, Any?>>> =
Result.success(service.getAllItemList())
/** 7. 根据体检指标项查询所属体检组合项 */
@Mapping("/getItemInfo/{peItemCode}")
fun getItemInfo(@Path("peItemCode") peItemCode: String): Result<List<Map<String, Any?>>> =
Result.success(service.getItemInfo(peItemCode))
// ================= 4. 体检结果 / 结论 / 建议 =================
/** 8. 根据体检编号获取员工体检结果 */
@Mapping("/getResult/{peId}")
fun getResult(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResult(peId))
/** 9. 体检结论(汇总) */
@Mapping("/getTotalConclusion/{peId}")
fun getTotalConclusion(@Path("peId") peId: String): Result<Map<String, String>> =
Result.success(service.getTotalConclusion(peId))
/** 10. 体检建议(汇总) */
@Mapping("/getTotalSuggest/{peId}")
fun getTotalSuggest(@Path("peId") peId: String): Result<Map<String, String>> =
Result.success(service.getTotalSuggest(peId))
/** 11. 体检结论(列表) */
@Mapping("/getConclusion/{peId}")
fun getConclusion(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getConclusion(peId))
/** 12. 体检建议(列表) */
@Mapping("/getSuggest/{peId}")
fun getSuggest(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getSuggest(peId))
// ================= 5. 按身份证 / 日期查询 =================
/** 14. 根据身份证号及年份获取体检结果 */
@Mapping("/getResultByIdcardAndYear/{idNo}/{year}")
fun getResultByIdcardAndYear(@Path("idNo") idNo: String, @Path("year") year: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByIdcardAndYear(idNo, year))
/** 14.1 根据身份证号及年份获取体检结果(全量) */
@Mapping("/getResultByIdcardAndYearAll/{idNo}/{year}")
fun getResultByIdcardAndYearAll(@Path("idNo") idNo: String, @Path("year") year: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByIdcardAndYearAll(idNo, year))
/** 15.1 根据体检编号获取体检结果 */
@Mapping("/getResultByPeId/{peId}")
fun getResultByPeId(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByPeId(peId))
/** 15.2 根据体检编号获取体检状态(简) */
@Mapping("/getResultStatus/{peId}")
fun getResultStatus(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByPeId(peId))
/** 15.3 根据 audit_date 查询当天的体检报告 */
@Mapping("/getResultByAuditDate/{auditDate}")
fun getResultByAuditDate(@Path("auditDate") auditDate: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByAuditDate(auditDate))
/** 16. 根据 id_code 或 pe_id 和审查年份查询体检报告 */
@Mapping(value = "/getResultByIdNoOrPeId", method = [MethodType.POST])
fun getResultByIdNoOrPeId(
@Param(value = "idNo", required = false) idNo: String?,
@Param(value = "peId", required = false) peId: String?,
@Param("auditYear") auditYear: String,
): Result<List<Map<String, Any?>>> {
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<Map<String, Any?>?> =
Result.success(service.getPeIdAndDateByIdcard(idNo))
/** 17.1 同上(数组) */
@Mapping("/getPeIdAndDateByIdcardList/{idNo}")
fun getPeIdAndDateByIdcardList(@Path("idNo") idNo: String): Result<List<Map<String, Any?>>> =
Result.success(service.getPeIdAndDateByIdcardList(idNo))
// ================= 6. 单位 / 人员列表 =================
/** 18. 添加修改单位信息 */
@Mapping(value = "/addUnit", method = [MethodType.POST])
fun addUnit(@Body body: String): Result<Nothing?> {
// Solon 对 List 泛型 body 反序列化支持不佳,此处用 snack3 手动解析
@Suppress("UNCHECKED_CAST")
val units: List<Map<String, Any?>> =
if (body.isBlank()) emptyList()
else org.noear.snack.ONode.load(body).toObject(List::class.java) as List<Map<String, Any?>>
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<List<Map<String, Any?>>> =
Result.success(service.getExamListByDate(auditDate))
}
@@ -1,17 +0,0 @@
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"
}
@@ -1,69 +0,0 @@
package com.hospital.front.dto
/**
* 统一 API 响应包装。
*
* 业务接口均使用此结构返回,便于前端统一处理。
*
* @param T 数据类型
* @property code 业务码(0=成功)
* @property msg 提示信息
* @property data 业务数据
*/
data class Result<T>(
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 <T> success(): Result<T?> = Result(SUCCESS_CODE, "success", null)
/**
* 成功响应。
*/
fun <T> success(data: T): Result<T> = Result(SUCCESS_CODE, "success", data)
/**
* 错误响应(默认错误码)。
*/
fun <T> error(msg: String): Result<T> = Result(ERROR_CODE, msg, null)
/**
* 错误响应(指定错误码)。
*/
fun <T> error(code: Int, msg: String): Result<T> = Result(code, msg, null)
}
}
@@ -1,23 +0,0 @@
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<String> = emptyList()
}
@@ -1,73 +0,0 @@
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<Any>(e.code, e.message ?: "业务异常"))
} catch (e: IllegalArgumentException) {
log.warn("参数异常:{}", e.message)
renderResult(ctx, Result.error<Any>(Result.CODE_PARAM_FORMAT, e.message ?: "参数错误"))
} catch (e: Exception) {
log.error("系统异常", e)
renderResult(ctx, Result.error<Any>(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)
}
}
@@ -1,42 +0,0 @@
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)
}
@@ -1,101 +0,0 @@
package com.hospital.front.mapper.bsm
import org.apache.ibatis.annotations.Mapper
import org.apache.ibatis.annotations.Param
/**
* BSM 医院组 MapperOracle 方言)。
*
* 适用医院: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<String, Any?>)
/** 取消预约(存储过程 P_DEL_PE_REGIST */
fun cancelOrder(map: MutableMap<String, Any?>)
/** 添加/修改单位 V1(存储过程 P_ADD_UNIT */
fun addUnit(map: MutableMap<String, Any?>)
/** 添加/修改单位 V2(存储过程 P_ADD_UNIT8 入参版) */
fun addUnitV2(map: MutableMap<String, Any?>)
/** 按体检预约 ID(逗号分隔)查询体检状态 */
fun getPhyexamStatus(@Param("peIds") peIds: String): List<Map<String, Any?>>
/** 医院所有体检项目(组合项) */
fun getItemList(): List<Map<String, Any?>>
/** 医院所有体检项目及指标项 */
fun getAllItemList(): List<Map<String, Any?>>
/** 医院所有体检指标项 */
fun getPeItemList(): List<Map<String, Any?>>
/** 员工体检项目 */
fun getEmpItem(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按指标项 CODE 查询体检组合 */
fun getItemInfo(@Param("peItemCode") peItemCode: String): List<Map<String, Any?>>
/** 员工体检结果 */
fun getResult(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(列表) */
fun getConclusion(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(全量) */
fun getConclusionAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(列表) */
fun getSuggest(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(全量) */
fun getSuggestAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检状态 */
fun getPeStatusByIdnoAndYear(@Param("idNo") idNo: String, @Param("year") year: String): Map<String, Any?>?
/** 按身份证号及年份查询体检结果 */
fun getResultByIdcardAndYear(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检结果(全量) */
fun getResultByIdcardAndYearAll(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按体检编号查询体检结果 */
fun getResultByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按审核日期查询体检报告(去重) */
fun getDisResultByAuditDate(@Param("auditDate") auditDate: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表 */
fun getItemByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表(全量) */
fun getItemByPeIdAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
fun getResultByIdNoOrPeId(
@Param("idNo") idNo: String?,
@Param("peId") peId: String?,
@Param("auditYear") auditYear: String,
): List<Map<String, Any?>>
/** 按身份证号查询当年 peId 与预约日期 */
fun getPeIdAndDateByIdcard(@Param("idNo") idNo: String): Map<String, Any?>?
/** 按身份证号查询当年 peId 与预约日期(数组) */
fun getPeIdAndDateByIdcardList(@Param("idNo") idNo: String): List<Map<String, Any?>>
/** 按审核日期查询当天体检人员列表(去重) */
fun getExamListByDate(@Param("auditDate") auditDate: String?): List<Map<String, Any?>>
}
@@ -1,101 +0,0 @@
package com.hospital.front.mapper.nx
import org.apache.ibatis.annotations.Mapper
import org.apache.ibatis.annotations.Param
/**
* BSM 医院组 MapperOracle 方言)。
*
* 适用医院: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<String, Any?>)
/** 取消预约(存储过程 P_DEL_PE_REGIST */
fun cancelOrder(map: MutableMap<String, Any?>)
/** 添加/修改单位 V1(存储过程 P_ADD_UNIT */
fun addUnit(map: MutableMap<String, Any?>)
/** 添加/修改单位 V2(存储过程 P_ADD_UNIT8 入参版) */
fun addUnitV2(map: MutableMap<String, Any?>)
/** 按体检预约 ID(逗号分隔)查询体检状态 */
fun getPhyexamStatus(@Param("peIds") peIds: String): List<Map<String, Any?>>
/** 医院所有体检项目(组合项) */
fun getItemList(): List<Map<String, Any?>>
/** 医院所有体检项目及指标项 */
fun getAllItemList(): List<Map<String, Any?>>
/** 医院所有体检指标项 */
fun getPeItemList(): List<Map<String, Any?>>
/** 员工体检项目 */
fun getEmpItem(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按指标项 CODE 查询体检组合 */
fun getItemInfo(@Param("peItemCode") peItemCode: String): List<Map<String, Any?>>
/** 员工体检结果 */
fun getResult(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(列表) */
fun getConclusion(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(全量) */
fun getConclusionAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(列表) */
fun getSuggest(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(全量) */
fun getSuggestAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检状态 */
fun getPeStatusByIdnoAndYear(@Param("idNo") idNo: String, @Param("year") year: String): Map<String, Any?>?
/** 按身份证号及年份查询体检结果 */
fun getResultByIdcardAndYear(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检结果(全量) */
fun getResultByIdcardAndYearAll(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按体检编号查询体检结果 */
fun getResultByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按审核日期查询体检报告(去重) */
fun getDisResultByAuditDate(@Param("auditDate") auditDate: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表 */
fun getItemByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表(全量) */
fun getItemByPeIdAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
fun getResultByIdNoOrPeId(
@Param("idNo") idNo: String?,
@Param("peId") peId: String?,
@Param("auditYear") auditYear: String,
): List<Map<String, Any?>>
/** 按身份证号查询当年 peId 与预约日期 */
fun getPeIdAndDateByIdcard(@Param("idNo") idNo: String): Map<String, Any?>?
/** 按身份证号查询当年 peId 与预约日期(数组) */
fun getPeIdAndDateByIdcardList(@Param("idNo") idNo: String): List<Map<String, Any?>>
/** 按审核日期查询当天体检人员列表(去重) */
fun getExamListByDate(@Param("auditDate") auditDate: String?): List<Map<String, Any?>>
}
@@ -1,101 +0,0 @@
package com.hospital.front.mapper.ya
import org.apache.ibatis.annotations.Mapper
import org.apache.ibatis.annotations.Param
/**
* BSM 医院组 MapperOracle 方言)。
*
* 适用医院: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<String, Any?>)
/** 取消预约(存储过程 P_DEL_PE_REGIST */
fun cancelOrder(map: MutableMap<String, Any?>)
/** 添加/修改单位 V1(存储过程 P_ADD_UNIT */
fun addUnit(map: MutableMap<String, Any?>)
/** 添加/修改单位 V2(存储过程 P_ADD_UNIT8 入参版) */
fun addUnitV2(map: MutableMap<String, Any?>)
/** 按体检预约 ID(逗号分隔)查询体检状态 */
fun getPhyexamStatus(@Param("peIds") peIds: String): List<Map<String, Any?>>
/** 医院所有体检项目(组合项) */
fun getItemList(): List<Map<String, Any?>>
/** 医院所有体检项目及指标项 */
fun getAllItemList(): List<Map<String, Any?>>
/** 医院所有体检指标项 */
fun getPeItemList(): List<Map<String, Any?>>
/** 员工体检项目 */
fun getEmpItem(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按指标项 CODE 查询体检组合 */
fun getItemInfo(@Param("peItemCode") peItemCode: String): List<Map<String, Any?>>
/** 员工体检结果 */
fun getResult(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(列表) */
fun getConclusion(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(全量) */
fun getConclusionAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(列表) */
fun getSuggest(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(全量) */
fun getSuggestAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检状态 */
fun getPeStatusByIdnoAndYear(@Param("idNo") idNo: String, @Param("year") year: String): Map<String, Any?>?
/** 按身份证号及年份查询体检结果 */
fun getResultByIdcardAndYear(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检结果(全量) */
fun getResultByIdcardAndYearAll(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按体检编号查询体检结果 */
fun getResultByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按审核日期查询体检报告(去重) */
fun getDisResultByAuditDate(@Param("auditDate") auditDate: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表 */
fun getItemByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表(全量) */
fun getItemByPeIdAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
fun getResultByIdNoOrPeId(
@Param("idNo") idNo: String?,
@Param("peId") peId: String?,
@Param("auditYear") auditYear: String,
): List<Map<String, Any?>>
/** 按身份证号查询当年 peId 与预约日期 */
fun getPeIdAndDateByIdcard(@Param("idNo") idNo: String): Map<String, Any?>?
/** 按身份证号查询当年 peId 与预约日期(数组) */
fun getPeIdAndDateByIdcardList(@Param("idNo") idNo: String): List<Map<String, Any?>>
/** 按审核日期查询当天体检人员列表(去重) */
fun getExamListByDate(@Param("auditDate") auditDate: String?): List<Map<String, Any?>>
}
@@ -1,260 +0,0 @@
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, Any?>): 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<Map<String, Any?>>): 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<Map<String, String>> =
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<String, String> =
mapOf("totalConclusion" to buildSummary(adapter().getConclusion(peId)))
/** 体检结论(汇总,全量来源) */
fun getTotalConclusionAll(peId: String): Map<String, String> =
mapOf("totalConclusion" to buildSummary(adapter().getConclusionAll(peId)))
/**
* 体检建议(汇总)。宁夏(nx)仅取首条 GUIDE_CONTENT。
*/
fun getTotalSuggest(peId: String): Map<String, String> =
mapOf("totalSuggest" to buildSuggest(adapter().getSuggest(peId), adapter().hmType() == "nx"))
/** 体检建议(汇总,全量来源) */
fun getTotalSuggestAll(peId: String): Map<String, String> =
mapOf("totalSuggest" to buildSuggest(adapter().getSuggestAll(peId), adapter().hmType() == "nx"))
/**
* 按身份证号 + 年份查询体检状态及项目(含宁夏体检报告兜底)。
*/
fun getPeStatusByIdnoAndYear(idNo: String, year: String): Map<String, Any?>? {
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.科室:结论;<br/> */
private fun buildSummary(rows: List<Map<String, Any?>>): String =
rows.mapIndexed { i, row ->
val dept = row["PE_DEPT_NAME"]?.toString()?.takeIf { it.isNotEmpty() }?.let { "$it:" } ?: ""
"${i + 1}.$dept${row["CONCLUSION_TEXT"] ?: ""};<br/>"
}.joinToString("")
/** 建议汇总:宁夏仅取首条 GUIDE_CONTENT;其余 1.建议项:内容;<br/> */
private fun buildSuggest(rows: List<Map<String, Any?>>, 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"] ?: ""};<br/>" }
.joinToString("")
}
/** Map → OrderV2Vo(数值/空值容错) */
private fun convertOrderV2(body: Map<String, Any?>): 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<String, Any?>) = 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(),
)
}
@@ -1,30 +0,0 @@
package com.hospital.front.vo
/**
* 体检结果传输 VOMapper 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<MedicalMiddleResultVo>? = null,
)
@@ -1,169 +0,0 @@
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<OrderItemVo>?): String =
items?.joinToString("&") { "${it.itemNo ?: ""}|${it.itemName ?: ""}" } ?: ""
}
}
/**
* V1 版预约订单 VOhm.ver=V1 时使用)。
*
* 字段名与字段顺序均与原 hospitalmiddle 的 OrderVoJava)保持一致,
* 字段顺序即存储过程 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<OrderItemVo>? = 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 版预约订单 VOhm.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-ddV2
val defendCancer: String? = null, // 34 防癌项
val itemList: List<OrderItemVo>? = 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, // 联系人1V2
val phone1: String? = null, // 电话1V2
val connecter2: String? = null, // 联系人2V2
val phone2: String? = null, // 电话2V2
)
@@ -12,7 +12,7 @@ solon.dataSources:
class: "com.zaxxer.hikari.HikariDataSource" class: "com.zaxxer.hikari.HikariDataSource"
jdbcUrl: jdbc:oracle:thin:@192.168.50.204:1521/FREEPDB1 jdbcUrl: jdbc:oracle:thin:@192.168.50.204:1521/FREEPDB1
username: SYSTEM username: SYSTEM
password: ${DB_PWD:jyf@2024} password: Msor6085008
minimumIdle: 2 minimumIdle: 2
maximumPoolSize: 10 maximumPoolSize: 10
connectionTimeout: 30000 connectionTimeout: 30000
+12 -3
View File
@@ -44,9 +44,9 @@ mybatis.db1:
- "com.hospital.front.mapper.bsm" - "com.hospital.front.mapper.bsm"
- "com.hospital.front.mapper.nx" - "com.hospital.front.mapper.nx"
- "com.hospital.front.mapper.ya" - "com.hospital.front.mapper.ya"
- "com/hospital/front/mapper/bsm/OrderMapper.xml" - "com/hospital/front/mapper/bsm/BsmOrderMapper.xml"
- "com/hospital/front/mapper/nx/OrderMapper.xml" - "com/hospital/front/mapper/nx/NxOrderMapper.xml"
- "com/hospital/front/mapper/ya/OrderMapper.xml" - "com/hospital/front/mapper/ya/YaOrderMapper.xml"
configuration: configuration:
mapUnderscoreToCamelCase: true mapUnderscoreToCamelCase: true
logImpl: "org.apache.ibatis.logging.slf4j.Slf4jImpl" logImpl: "org.apache.ibatis.logging.slf4j.Slf4jImpl"
@@ -61,3 +61,12 @@ logging:
root: INFO root: INFO
com.hospital.front: INFO com.hospital.front: INFO
org.noear: WARN org.noear: WARN
# ====================================
# Uptime-Kuma 监控(沿用原 hospitalmiddle 逻辑)
# kuma.code 在各医院配置中覆盖
# ====================================
kuma:
enable: false
cron: "0 */3 * * * *"
url: ""
@@ -276,55 +276,79 @@
<select id="getItemByPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo"> <select id="getItemByPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT SELECT
ITEM_ASSEM_CODE, ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME, ITEM_ASSEM_NAME,
PE_ITEM_CODE, PE_ITEM_CODE,
PE_ITEM_NAME, PE_ITEM_NAME,
PE_RESULT, PE_RESULT,
UNIT, UNIT,
PRINT_CONTEXT PRINT_CONTEXT
FROM FROM
V_PHYEXAM_RESULT (
WHERE SELECT
PE_ID = #{peId} b.pe_id,
b.pe_visit_id,
a.id_no,
a.NAME,
b.unit_code,
t.unit_name,
b.pe_queue_date,
b.audit_date,
c.pe_dept_code,
c.pe_dept_name,
c.item_assem_code,
c.item_assem_name,
c.pe_item_code,
c.pe_item_name,
c.pe_result,
c.unit,
c.print_context
FROM
pe_master_index a
JOIN pe_visit b ON b.pe_id = a.pe_id
JOIN pe_result_dict c ON c.pe_id = b.pe_id AND c.pe_visit_id = b.pe_visit_id
LEFT JOIN pe_unit_dict t ON t.unit_code = b.unit_code
WHERE
b.pe_id = #{peId}
) a
</select> </select>
<select id="getItemByPeIdAll" resultType="com.hospital.front.vo.MedicalMiddleResultVo"> <select id="getItemByPeIdAll" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT SELECT
ITEM_ASSEM_CODE, ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME, ITEM_ASSEM_NAME,
PE_ITEM_CODE, PE_ITEM_CODE,
PE_ITEM_NAME, PE_ITEM_NAME,
PE_RESULT, PE_RESULT,
UNIT, UNIT,
PRINT_CONTEXT PRINT_CONTEXT
FROM FROM
( (
SELECT SELECT
b.pe_id, b.pe_id,
b.pe_visit_id, b.pe_visit_id,
a.id_no, a.id_no,
a.NAME, a.NAME,
b.unit_code, b.unit_code,
t.unit_name, t.unit_name,
b.pe_queue_date, b.pe_queue_date,
b.audit_date, b.audit_date,
c.pe_dept_code, c.pe_dept_code,
c.pe_dept_name, c.pe_dept_name,
c.item_assem_code, c.item_assem_code,
c.item_assem_name, c.item_assem_name,
c.pe_item_code, c.pe_item_code,
c.pe_item_name, c.pe_item_name,
c.pe_result, c.pe_result,
c.unit, c.unit,
c.print_context c.print_context
FROM FROM
pe_master_index a pe_master_index a
JOIN pe_visit b ON b.pe_id = a.pe_id JOIN pe_visit b ON b.pe_id = a.pe_id
JOIN pe_result_dict c ON c.pe_id = b.pe_id AND c.pe_visit_id = b.pe_visit_id JOIN pe_result_dict c ON c.pe_id = b.pe_id AND c.pe_visit_id = b.pe_visit_id
LEFT JOIN pe_unit_dict t ON t.unit_code = b.unit_code LEFT JOIN pe_unit_dict t ON t.unit_code = b.unit_code
WHERE WHERE
b.pe_id = #{peId} b.pe_id = #{peId}
) a ) a
</select> </select>