Compare commits
6
Commits
b7283db0bd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97be68db61 | ||
|
|
8fff1db294 | ||
|
|
04e4d66a4c | ||
|
|
b871409361 | ||
|
|
a3566c1f0c | ||
|
|
ae2b585df9 |
@@ -2,6 +2,7 @@
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
dist/
|
||||
|
||||
# 运行时目录(本地联调用,不入 Git)
|
||||
bin/
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# Hospital Front — 医院前置机程序
|
||||
|
||||
> 基于 Kotlin + Solon 4.0.6 重构的医院前置机对接系统
|
||||
> 基于 Java 17 + Solon 4.0.6 的医院前置机对接系统
|
||||
>
|
||||
> 重构源:[hospitalmiddle](../hospitalmiddle)(SpringBoot 2.7 + MyBatis-Plus + Oracle/SqlServer)
|
||||
>
|
||||
> 完整方案:[E:\data\ob_data\myob\11 健康长庆\医院前置机重构\](../ob_data)
|
||||
> 重构源:hospitalmiddle(SpringBoot 2.7 + MyBatis-Plus + Oracle/SqlServer)
|
||||
|
||||
---
|
||||
|
||||
@@ -12,34 +10,35 @@
|
||||
|
||||
体检预约系统通过本程序与多家医院 HIS 系统对接,实现:
|
||||
- 体检预约的提交、取消
|
||||
- 体检项目查询、状态查询
|
||||
- 体检结果/结论/建议的获取
|
||||
- 体检项目 / 组合查询、体检状态查询
|
||||
- 体检结果 / 结论 / 建议的获取
|
||||
- 单位信息同步
|
||||
|
||||
**支持 5 家医院**(拼音编码):
|
||||
| 编码 | 医院名 | 数据库 |
|
||||
|---|---|---|
|
||||
| `xinglongyuan` | 兴隆园医院 | Oracle |
|
||||
| `qingyang` | 庆阳医院 | Oracle |
|
||||
| `ningxia` | 宁夏宝石花医院 | SqlServer |
|
||||
| `yanan` | 延安医院 | SqlServer |
|
||||
| `jinghe` | 泾河医院 | Oracle |
|
||||
**支持医院**(拼音编码,由 `hospital.active` 指定激活):
|
||||
|
||||
| 编码 | 医院名 | 数据库 | Adapter |
|
||||
|---|---|---|---|
|
||||
| `xinglongyuan` | 兴隆园医院 | Oracle | `XingLongYuanAdapter` |
|
||||
| `qingyang` | 庆阳医院 | Oracle | `QingyangAdapter` |
|
||||
| `ningxia` | 宁夏宝石花医院 | SqlServer | `NingxiaAdapter` |
|
||||
| `yanan` | 延安医院 | SqlServer | `YananAdapter` |
|
||||
| `jinghe` | 泾河医院 | Oracle | `JingheAdapter` |
|
||||
|
||||
医院通过 `HospitalAdapter` 接口 + `HospitalRouter` 路由(策略模式),方言基类:
|
||||
- `BsmAdapterBase`:Oracle 方言(bsm Mapper)
|
||||
- `NxAdapterBase`:SqlServer 方言(nx Mapper)
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
- **语言**:Kotlin 2.0.21(`jvmTarget=17`)
|
||||
- **框架**:Solon 4.0.6(国产轻量级 Java 框架)
|
||||
- **JDK**:17 LTS(推荐)或 21 LTS
|
||||
- **语言**:Java 17
|
||||
- **框架**:Solon 4.0.6
|
||||
- **ORM**:MyBatis-Plus 3.5.9
|
||||
- **数据库**:Oracle / SqlServer(直连医院库,不可修改)
|
||||
- **日志**:Logback 1.5.12
|
||||
- **HTTP/WS**:OkHttp 4.12.0
|
||||
- **数据库**:Oracle(ojdbc11 + orai18n,ZHS16GBK 字符集支持)/ SqlServer(直连医院库,只读对接,不可修改表结构)
|
||||
- **日志**:Logback(异步 appender,info.log / error.log 分文件滚动)
|
||||
- **构建**:Maven 3.9+
|
||||
|
||||
> ✅ **依赖瘦身**:不接入 Knife4j / Druid / Lombok / Jasypt(详见 [01-技术栈选型.md](../ob_data/01-技术栈选型.md))
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目结构
|
||||
@@ -48,31 +47,22 @@
|
||||
hospital-front/
|
||||
├── pom.xml
|
||||
├── README.md
|
||||
├── .gitignore
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ ├── kotlin/com/hospital/front/
|
||||
│ │ │ ├── HospitalFrontApp.kt # @SolonMain 启动类
|
||||
│ │ │ ├── adapter/ # 医院适配器(策略模式)
|
||||
│ │ │ ├── api/ # HTTP 接口层(按业务域)
|
||||
│ │ │ ├── service/ # 业务服务层
|
||||
│ │ │ ├── mapper/ # 数据访问层
|
||||
│ │ │ ├── vo/ # 值对象
|
||||
│ │ │ ├── dto/ # 数据传输对象
|
||||
│ │ │ ├── infra/ # 基础设施
|
||||
│ │ │ └── task/ # 定时任务
|
||||
│ │ └── resources/
|
||||
│ │ ├── app.yml # Solon 主配置
|
||||
│ │ ├── logback.xml # 日志配置
|
||||
│ │ └── mapper/ # MyBatis XML
|
||||
│ └── test/kotlin/ # 单元测试
|
||||
└── pac/ # 部署包(运行时外置)
|
||||
├── bin/ # 启动脚本
|
||||
├── conf/ # 外置配置
|
||||
├── backup/ # 历史版本
|
||||
├── log/ # 日志
|
||||
├── pid/ # PID 文件
|
||||
└── tmp/ # 临时文件
|
||||
├── conf/ # 各医院外置配置(含生产凭据,不入 Git)
|
||||
└── src/main/
|
||||
├── java/com/hospital/front/
|
||||
│ ├── HospitalFrontApp.java # 启动类
|
||||
│ ├── adapter/ # 医院适配器(策略模式 + 方言基类)
|
||||
│ ├── api/ # HTTP 接口(order / admin)
|
||||
│ ├── service/ # 业务服务层
|
||||
│ ├── mapper/bsm|nx/ # 数据访问层(按方言分包)
|
||||
│ ├── vo/ dto/ # 值对象 / 传输对象
|
||||
│ ├── infra/ # 基础设施(过滤器、异常处理、扫描器)
|
||||
│ └── task/ # 定时任务(Uptime-Kuma 上报)
|
||||
└── resources/
|
||||
├── app.yml # 主配置(框架级 + 公共配置)
|
||||
├── logback.xml # 日志配置
|
||||
├── tls10.security # 延安专用 JDK 安全策略(放开 TLSv1/弱算法)
|
||||
└── mapper/ # MyBatis XML
|
||||
```
|
||||
|
||||
---
|
||||
@@ -89,57 +79,61 @@ mvn clean package -DskipTests
|
||||
## 5. 启动(开发环境)
|
||||
|
||||
```bash
|
||||
# 拷贝外置配置(首次启动)
|
||||
cp -r pac/conf-example pac/conf
|
||||
# 编辑配置
|
||||
vim pac/conf/app-jinghe.yml
|
||||
|
||||
# 启动(默认加载 app.yml + conf/app-{HOSPITAL}.yml)
|
||||
HOSPITAL=jinghe java -jar target/hospital-front-1.0.0.jar
|
||||
# 方式一:默认加载 classpath 的 app.yml(hospital.active 默认 jinghe)
|
||||
java -jar target/hospital-front-1.0.0.jar
|
||||
```
|
||||
|
||||
数据源等医院差异配置在 `app-{医院}.yml` 中,开发期可用 `--solon.config.add` 外挂。
|
||||
|
||||
---
|
||||
|
||||
## 6. 部署
|
||||
## 6. 生产部署注意事项(重要)
|
||||
|
||||
详细部署、升级、回滚 SOP:[05-部署运维.md](../ob_data/05-部署运维.md)
|
||||
### 6.1 外挂医院配置
|
||||
|
||||
**Windows Server 简版**:
|
||||
```batch
|
||||
D:
|
||||
cd D:\hospital-front\bin
|
||||
call start.bat start
|
||||
生产环境配置文件**外置于 jar 同级或 `conf/` 目录**,通过 `--solon.config.add` 追加加载,
|
||||
修改数据库地址 / 鉴权等无需重新打包:
|
||||
|
||||
```bash
|
||||
java -jar hospital-front.jar --solon.config.add=conf/app-yanan.yml --server.port=8003
|
||||
```
|
||||
|
||||
- 配置模板见仓库 `conf/` 目录(app-jinghe / app-ningxia / app-yanan)
|
||||
- `--server.port` 覆盖默认端口 8082
|
||||
- 外挂文件中必须指定 `hospital.active` 与 `hm.type`(bsm / nx / ya),并定义 `solon.dataSources.db1`
|
||||
|
||||
### 6.2 延安医院:TLS 1.0 特殊处理
|
||||
|
||||
延安 SqlServer 为老版本(仅支持 TLS 1.0 / 弱加密算法),JDK 17 默认禁用,
|
||||
必须外挂 JDK 安全属性文件启动,否则连接报 `handshake failure` / `No appropriate protocol`:
|
||||
|
||||
```bash
|
||||
java -Dfile.encoding=UTF-8 -Djava.security.properties=tls10.security -jar hospital-front.jar --solon.config.add=conf/app-yanan.yml --server.port=8003
|
||||
```
|
||||
|
||||
- `tls10.security` 已打包在 jar 内(`src/main/resources/`),上述命令按 classpath 加载,无需单独分发文件;也可用文件系统绝对路径覆盖
|
||||
- `-Dfile.encoding=UTF-8`:Windows 服务器默认 GBK,防止日志与接口中文乱码
|
||||
- 其余医院**不要**带 `tls10.security`(会全局放开弱算法,降低安全基线)
|
||||
|
||||
### 6.3 安全配置(生产必读)
|
||||
|
||||
`app.yml` 中以下配置生产环境必须核对(可在外挂配置中覆盖):
|
||||
|
||||
| 配置 | 说明 |
|
||||
|---|---|
|
||||
| `order.basic` | `/order/**` 的 HTTP Basic 认证(`user:password`),生产必须配置 |
|
||||
| `admin.token` | `/api/admin/**` 的 `X-Admin-Token`,Agent 环回调用 |
|
||||
| `security.ipWhitelistEnabled` / `ipWhitelist` | IP 白名单(支持精确 IP / 通配段 / CIDR),端口暴露公网时务必开启 |
|
||||
| `security.trustProxy` | nginx 反代部署时开启,从 X-Forwarded-For 取真实客户端 IP |
|
||||
|
||||
### 6.4 日志
|
||||
|
||||
- 日志目录默认 `./log`(`-Dlog.path=xxx` 可覆盖)
|
||||
- `info.log`(INFO 及以上)/ `error.log`(仅 ERROR)分文件,50MB 按天滚动,保留 30 天
|
||||
- pattern 不含 `%file:%line`(AsyncAppender 下取不到调用者信息,会打成 `?:?`)
|
||||
|
||||
---
|
||||
|
||||
## 7. 文档
|
||||
|
||||
完整设计文档位于:`E:\data\ob_data\myob\11 健康长庆\医院前置机重构\`
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [00-总体方案.md](../ob_data/00-总体方案.md) | 整体方案 |
|
||||
| [01-技术栈选型.md](../ob_data/01-技术栈选型.md) | Solon 选型、版本、注解对照 |
|
||||
| [02-医院差异化矩阵.md](../ob_data/02-医院差异化矩阵.md) | 5 家医院差异、Adapter 拆分 |
|
||||
| [03-架构设计.md](../ob_data/03-架构设计.md) | 系统架构、模块、时序 |
|
||||
| [04-数据流与接口契约.md](../ob_data/04-数据流与接口契约.md) | 接口清单、入参出参 |
|
||||
| [05-部署运维.md](../ob_data/05-部署运维.md) | 部署、升级、回滚 SOP |
|
||||
| [06-变更记录.md](../ob_data/06-变更记录.md) | 所有变更登记 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 任务进度
|
||||
|
||||
| Step | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| 1 | 工程骨架 | ✅ |
|
||||
| 2 | 5 医院配置中心 | ⏳ |
|
||||
| 3 | 业务接口 Kotlin 重写 | ⏳ |
|
||||
| 4 | 5 套 Mapper 适配 | ⏳ |
|
||||
| 5 | 主程序被控端 API | ⏳ |
|
||||
| 6 | Agent 工程 | ⏳ |
|
||||
| 7 | 升级流程 | ⏳ |
|
||||
| 8 | 日志推送 | ⏳ |
|
||||
| 9 | B 端管理后台 | ⏳ |
|
||||
| 10 | 联调 + 文档 | ⏳ |
|
||||
@@ -107,6 +107,12 @@
|
||||
<artifactId>ojdbc11</artifactId>
|
||||
<version>${ojdbc.version}</version>
|
||||
</dependency>
|
||||
<!-- Oracle NLS 字符集支持:ZHS16GBK 等中文/多字节字符集转换表,缺失报 ORA-17056 -->
|
||||
<dependency>
|
||||
<groupId>com.oracle.database.nls</groupId>
|
||||
<artifactId>orai18n</artifactId>
|
||||
<version>${ojdbc.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- SqlServer -->
|
||||
<dependency>
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.hospital.front.api.admin;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.net.URI;
|
||||
import java.util.Base64;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
@@ -23,6 +25,7 @@ import org.noear.solon.core.handle.MethodType;
|
||||
|
||||
import com.hospital.front.adapter.HospitalRouter;
|
||||
import com.hospital.front.dto.Result;
|
||||
import com.hospital.front.infra.DbHealthMonitor;
|
||||
import com.hospital.front.infra.exception.BusinessException;
|
||||
|
||||
/**
|
||||
@@ -43,6 +46,10 @@ public class AdminApi {
|
||||
@Inject
|
||||
private HospitalRouter router;
|
||||
|
||||
/** DB 健康缓存监测(health 接口读缓存秒回,避免 DB 慢时阻塞被 Agent 误判宕机) */
|
||||
@Inject
|
||||
private DbHealthMonitor dbHealthMonitor;
|
||||
|
||||
/** 日志目录(与 logback.xml 的 log.path 保持一致) */
|
||||
private File getLogDir() {
|
||||
return new File(Solon.cfg().get("log.path", "./log"));
|
||||
@@ -59,20 +66,21 @@ public class AdminApi {
|
||||
|
||||
// ================= 1. 健康与指标 =================
|
||||
|
||||
/** 健康检查(含数据库探测) */
|
||||
/** 健康检查(进程健康即时返回;DB 状态读后台探测缓存,不阻塞) */
|
||||
@Mapping("/health")
|
||||
public Result<Map<String, Object>> health(Context ctx) {
|
||||
checkToken(ctx);
|
||||
Runtime mem = Runtime.getRuntime();
|
||||
Result<Boolean> db = router.active().healthCheck();
|
||||
Result<Boolean> db = dbHealthMonitor.last();
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("status", db.isSuccess() ? "UP" : "DEGRADED");
|
||||
// db=null 表示后台首轮探测未完成(启动/唤醒初期),进程本身健康,报 STARTING 而非 DEGRADED
|
||||
out.put("status", db == null ? "STARTING" : (db.isSuccess() ? "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.isSuccess());
|
||||
database.put("msg", db.getMsg());
|
||||
database.put("ok", db != null && db.isSuccess());
|
||||
database.put("msg", db != null ? db.getMsg() : "DB 探测进行中");
|
||||
out.put("database", database);
|
||||
Map<String, Object> jvm = new HashMap<>();
|
||||
jvm.put("totalMemoryMb", mem.totalMemory() / 1048576);
|
||||
@@ -274,7 +282,123 @@ public class AdminApi {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// ================= 5. 接口清单(供 B 端接口测试工具加载) =================
|
||||
// ================= 5. 接口代理(供 B 端接口测试工具直连调用) =================
|
||||
|
||||
/** httpProxy 请求体(Solon 自动绑定 JSON 请求体) */
|
||||
public static class HttpProxyReq {
|
||||
/** HTTP 方法:GET/POST/PUT/DELETE */
|
||||
public String method = "GET";
|
||||
/** 接口路径,如 /order/getItemList */
|
||||
public String path;
|
||||
/** query 参数(拼到 URL 上) */
|
||||
public Map<String, String> query;
|
||||
/** 请求体字符串(POST/PUT 时使用) */
|
||||
public String body;
|
||||
/** 额外请求头 */
|
||||
public Map<String, String> headers;
|
||||
}
|
||||
|
||||
/** 代理专用 HttpClient:环回调用本机接口,读超时 120s 适配大接口 */
|
||||
private final java.net.http.HttpClient proxyHttp = java.net.http.HttpClient.newBuilder()
|
||||
.connectTimeout(java.time.Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* HTTP 代理:在 main 进程内环回调用本机接口,原样透传响应。
|
||||
*
|
||||
* B 端接口测试工具经此代理访问业务接口,免去 Agent WS 通道倒手
|
||||
* (WS 通道有 2MB 消息上限与 60s 等待超时,大接口会超时/掉线)。
|
||||
* 自动按 path 附加鉴权头:/order/** → Basic(order.basic 配置),
|
||||
* /api/admin/** → X-Admin-Token。
|
||||
*/
|
||||
@Mapping(value = "/httpProxy", method = {MethodType.POST})
|
||||
public Result<Map<String, Object>> httpProxy(Context ctx, HttpProxyReq req) {
|
||||
checkToken(ctx);
|
||||
// 参数校验与路径白名单:只允许代理本机 /order/** 与 /api/admin/**
|
||||
if (req == null || req.path == null || req.path.isEmpty()) {
|
||||
throw BusinessException.paramMissing("path 不能为空");
|
||||
}
|
||||
String path = req.path.trim();
|
||||
if (!path.startsWith("/order/") && !path.startsWith("/api/admin/")) {
|
||||
throw BusinessException.businessFailed("仅允许代理 /order/** 与 /api/admin/** 路径:" + path);
|
||||
}
|
||||
// 拼接环回 URL:server.port + path + query
|
||||
String port = Solon.cfg().get("server.port", "8080");
|
||||
StringBuilder url = new StringBuilder("http://127.0.0.1:").append(port).append(path);
|
||||
if (req.query != null && !req.query.isEmpty()) {
|
||||
StringBuilder qs = new StringBuilder();
|
||||
for (Map.Entry<String, String> e : req.query.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
if (qs.length() > 0) {
|
||||
qs.append('&');
|
||||
}
|
||||
qs.append(java.net.URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8))
|
||||
.append('=')
|
||||
.append(java.net.URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
|
||||
}
|
||||
if (qs.length() > 0) {
|
||||
url.append('?').append(qs);
|
||||
}
|
||||
}
|
||||
// 构建请求:按 path 附加鉴权头
|
||||
java.net.http.HttpRequest.Builder rb = java.net.http.HttpRequest.newBuilder(URI_COMPAT.apply(url.toString()));
|
||||
String m = req.method == null ? "GET" : req.method.toUpperCase();
|
||||
java.net.http.HttpRequest.BodyPublisher pub = req.body == null || req.body.isEmpty()
|
||||
? java.net.http.HttpRequest.BodyPublishers.noBody()
|
||||
: java.net.http.HttpRequest.BodyPublishers.ofString(req.body, StandardCharsets.UTF_8);
|
||||
switch (m) {
|
||||
case "POST" -> rb.POST(pub);
|
||||
case "PUT" -> rb.PUT(pub);
|
||||
case "DELETE" -> rb.DELETE();
|
||||
default -> rb.GET();
|
||||
}
|
||||
if (path.startsWith("/order/")) {
|
||||
String basic = Solon.cfg().get("order.basic", "");
|
||||
boolean authFlag = !"false".equals(Solon.cfg().get("order.authFlag", "true"));
|
||||
if (authFlag && basic != null && !basic.isEmpty()) {
|
||||
// 对齐 OrderApi checkBasic 的校验格式:Basic + Base64(user:password)
|
||||
rb.header("Authorization", "Basic "
|
||||
+ Base64.getEncoder().encodeToString(basic.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
} else {
|
||||
rb.header("X-Admin-Token", Solon.cfg().get("admin.token", ""));
|
||||
}
|
||||
if (req.headers != null) {
|
||||
for (Map.Entry<String, String> e : req.headers.entrySet()) {
|
||||
if (e.getKey() != null && e.getValue() != null) {
|
||||
rb.header(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
rb.timeout(java.time.Duration.ofSeconds(120));
|
||||
// 执行并透传
|
||||
try {
|
||||
java.net.http.HttpResponse<String> resp = proxyHttp.send(rb.build(),
|
||||
java.net.http.HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("status", resp.statusCode());
|
||||
out.put("body", resp.body());
|
||||
return Result.success(out);
|
||||
} catch (java.io.IOException e) {
|
||||
throw BusinessException.businessFailed("环回调用失败(本机 " + path + "):" + e.getMessage());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw BusinessException.businessFailed("环回调用被中断:" + path);
|
||||
}
|
||||
}
|
||||
|
||||
/** URI 构建容错(URL 已校验,异常时给出可读错误) */
|
||||
private static final java.util.function.Function<String, URI> URI_COMPAT = s -> {
|
||||
try {
|
||||
return URI.create(s);
|
||||
} catch (Exception e) {
|
||||
throw BusinessException.paramMissing("非法 URL:" + s);
|
||||
}
|
||||
};
|
||||
|
||||
// ================= 6. 接口清单(供 B 端接口测试工具加载) =================
|
||||
|
||||
/**
|
||||
* 返回 /order 业务接口元数据清单。
|
||||
|
||||
@@ -234,7 +234,7 @@ public class OrderApi {
|
||||
@Mapping("/getResultStatus/{peId}")
|
||||
public Result<List<MedicalMiddleResultVo>> getResultStatus(Context ctx, @Path("peId") String peId) {
|
||||
checkBasic(ctx);
|
||||
return Result.success(service.getResultByPeId(peId));
|
||||
return Result.success(service.getResultStatus(peId));
|
||||
}
|
||||
|
||||
/** 17. 根据身份证号查询当前年的 peId 和预约日期 */
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.hospital.front.infra;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
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 lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* 全局接口访问日志过滤器。
|
||||
*
|
||||
* 统一打印每个请求的调用日志:HTTP 方法、路径、来源 IP、请求参数、耗时、响应状态码。
|
||||
* 覆盖所有 Controller(/order、/api/admin),新增接口自动生效,无需逐个手写日志。
|
||||
*
|
||||
* 参数覆盖说明:
|
||||
* - @Param(query/form)与 @Path(路径变量)统一通过 ctx.paramMap() 取;@Path 的值同时也在 ctx.path() 里可见。
|
||||
* - @Body 参数(saveOrder/addUnit)不在此读取,避免消费 body 流破坏 @Body 反序列化;
|
||||
* 由 service 层日志覆盖(saveOrder 打印 peInfoStr/peItemStr,addUnit 打印 units)。
|
||||
*
|
||||
* 顺序:index = -1 保证本过滤器在 GlobalExceptionHandler / IpWhitelistFilter 之前执行(最外层),
|
||||
* 从而能记录到异常被处理后写入 ctx 的最终状态码(401/500 等)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component(index = -1)
|
||||
public class ApiAccessLogFilter implements Filter {
|
||||
|
||||
/** 无需打印访问日志的路径(高频健康探测等,避免刷屏) */
|
||||
private static final Set<String> SKIP_LOG_PATHS = Set.of("/api/admin/health");
|
||||
|
||||
@Override
|
||||
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
chain.doFilter(ctx);
|
||||
} finally {
|
||||
long cost = System.currentTimeMillis() - start;
|
||||
if (!SKIP_LOG_PATHS.contains(ctx.path())) {
|
||||
log.info("[接口调用] {} {} ip={} 参数=[{}] cost={}ms status={}",
|
||||
ctx.method(), ctx.path(), ctx.remoteIp(), paramsOf(ctx), cost, ctx.status());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 汇总请求参数(query/form/path,@Body 除外),形如 key1=v1, key2=v2 */
|
||||
private String paramsOf(Context ctx) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (var kv : ctx.paramMap()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(", ");
|
||||
}
|
||||
sb.append(kv.getKey()).append('=').append(kv.getFirstValue());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.hospital.front.infra;
|
||||
|
||||
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;
|
||||
import com.hospital.front.dto.Result;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* 数据库健康后台监测(缓存式)。
|
||||
*
|
||||
* 后台周期探测 DB(默认 30 秒),/api/admin/health 直接读缓存秒回——
|
||||
* 避免 DB 建连慢(如机器休眠唤醒后网络未就绪,实测可达 4 分钟)时
|
||||
* 健康接口长时间阻塞,导致 Agent 看门狗 30s 超时误判主程序宕机而反复重启。
|
||||
*
|
||||
* 启动初期缓存为空视为 STARTING(探测线程尚未跑完第一轮),不算故障。
|
||||
*/
|
||||
@Component
|
||||
public class DbHealthMonitor {
|
||||
|
||||
@Inject
|
||||
private HospitalRouter router;
|
||||
|
||||
/** 最近一次探测结果(null = 尚未完成首轮探测) */
|
||||
private final AtomicReference<Result<Boolean>> last = new AtomicReference<>();
|
||||
|
||||
/** 周期探测(首轮延迟由调度器决定,失败不影响后续轮次) */
|
||||
@Scheduled(cron = "*/30 * * * * *")
|
||||
public void probe() {
|
||||
try {
|
||||
last.set(router.active().healthCheck());
|
||||
} catch (Exception e) {
|
||||
// healthCheck 内部已捕获,此处兜底防止调度线程中断
|
||||
last.set(Result.error("探测异常:" + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/** 最近一次探测结果;未完成首轮探测时返回 null(调用方按 STARTING 处理) */
|
||||
public Result<Boolean> last() {
|
||||
return last.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.hospital.front.infra;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.noear.solon.Solon;
|
||||
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 lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IP 白名单过滤器(应对前置机端口被公网扫描器探测)。
|
||||
*
|
||||
* 配置(app.yml,支持医院配置覆盖):
|
||||
* security:
|
||||
* ipWhitelistEnabled: false # 白名单开关(默认关,行为不变)
|
||||
* ipWhitelist: "10.10.10.228,10.0.0.0/24" # IP 池,逗号分隔
|
||||
*
|
||||
* 规则:
|
||||
* - 开关关闭时全放行(默认)
|
||||
* - 127.0.0.1 / ::1(Agent 环回访问 /api/admin)永远放行,无需配置
|
||||
* - 直连部署:取连接对端 IP(remoteIp)判定
|
||||
* - nginx 反代部署(security.trustProxy=true):连接对端必须是环回或 nginx 所在 IP,
|
||||
* 然后取 X-Forwarded-For 的最右侧 IP 判定(最右侧由可信 nginx 追加,无法伪造;
|
||||
* 不能取最左侧——客户端可自带 XFF 冒充白名单)
|
||||
* - 支持三种写法:精确 IP(10.0.0.5)、通配段(112.113.*.*,每段可单独 *)、
|
||||
* CIDR 网段(10.0.0.0/24)
|
||||
* - 非白名单一律 403 打回,并记 WARN 一行(含来源 IP 与路径,便于追踪扫描源)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class IpWhitelistFilter implements Filter {
|
||||
|
||||
/** 环回地址(永远放行) */
|
||||
private static final Set<String> LOOPBACK = new HashSet<>(Arrays.asList("127.0.0.1", "0:0:0:0:0:0:0:1", "::1"));
|
||||
|
||||
@Override
|
||||
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
|
||||
if (!enabled()) {
|
||||
chain.doFilter(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
String peer = ctx.remoteIp() != null ? ctx.remoteIp() : "";
|
||||
String ip = peer;
|
||||
|
||||
if (trustProxy()) {
|
||||
// 反代模式:对端必须是环回(同机 nginx)或白名单中的代理 IP,
|
||||
// 否则视为伪造直连,直接打回
|
||||
if (!LOOPBACK.contains(peer)) {
|
||||
log.warn("[IP白名单] 代理模式下非可信代理直连:ip={} {} {}", peer, ctx.method(), ctx.path());
|
||||
reject(ctx, peer);
|
||||
return;
|
||||
}
|
||||
// X-Forwarded-For 最右侧为本机 nginx 追加的真实客户端 IP(不可伪造)
|
||||
String xff = ctx.header("X-Forwarded-For");
|
||||
if (xff != null && !xff.trim().isEmpty()) {
|
||||
String[] hops = xff.split(",");
|
||||
ip = hops[hops.length - 1].trim();
|
||||
}
|
||||
// 无 XFF 的环回请求 = Agent 本机访问,保持环回 IP(放行)
|
||||
}
|
||||
|
||||
if (isAllowed(ip)) {
|
||||
chain.doFilter(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(ctx, ip);
|
||||
}
|
||||
|
||||
/** 非白名单:403 打回(一行日志,避免扫描器刷爆日志) */
|
||||
private void reject(Context ctx, String ip) {
|
||||
log.warn("[IP白名单] 拒绝访问:ip={} {} {}", ip, ctx.method(), ctx.path());
|
||||
ctx.status(403);
|
||||
ctx.contentType("application/json;charset=UTF-8");
|
||||
ctx.output("{\"success\":false,\"msg\":\"禁止访问\",\"data\":null}");
|
||||
}
|
||||
|
||||
/** 白名单开关(惰性读取,支持热更新) */
|
||||
private boolean enabled() {
|
||||
return "true".equals(Solon.cfg().get("security.ipWhitelistEnabled", "false"));
|
||||
}
|
||||
|
||||
/** 代理模式开关:nginx 反代部署时置 true,从 X-Forwarded-For 取真实客户端 IP */
|
||||
private boolean trustProxy() {
|
||||
return "true".equals(Solon.cfg().get("security.trustProxy", "false"));
|
||||
}
|
||||
|
||||
/** IP 是否放行:环回 / 精确匹配 / 通配段匹配 / CIDR 网段匹配 */
|
||||
private boolean isAllowed(String ip) {
|
||||
if (LOOPBACK.contains(ip)) {
|
||||
return true;
|
||||
}
|
||||
for (String entry : pool()) {
|
||||
if (entry.contains("/")) {
|
||||
if (cidrMatch(ip, entry)) {
|
||||
return true;
|
||||
}
|
||||
} else if (entry.contains("*")) {
|
||||
if (wildcardMatch(ip, entry)) {
|
||||
return true;
|
||||
}
|
||||
} else if (entry.equals(ip)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通配段匹配(仅 IPv4):entry 每段可为 * 或具体数字,如 112.113.*.*。
|
||||
*/
|
||||
private static boolean wildcardMatch(String ip, String entry) {
|
||||
String[] ipParts = ip.split("\\.");
|
||||
String[] entryParts = entry.split("\\.");
|
||||
if (ipParts.length != 4 || entryParts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (!"*".equals(entryParts[i]) && !entryParts[i].equals(ipParts[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 白名单 IP 池(逗号分隔,惰性读取) */
|
||||
private Set<String> pool() {
|
||||
String raw = Solon.cfg().get("security.ipWhitelist", "");
|
||||
Set<String> set = new HashSet<>();
|
||||
if (raw != null && !raw.trim().isEmpty()) {
|
||||
for (String s : raw.split(",")) {
|
||||
if (!s.trim().isEmpty()) {
|
||||
set.add(s.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/** IPv4 CIDR 网段匹配(如 10.0.0.0/24) */
|
||||
private static boolean cidrMatch(String ip, String cidr) {
|
||||
try {
|
||||
String[] parts = cidr.split("/");
|
||||
byte[] net = java.net.InetAddress.getByName(parts[0]).getAddress();
|
||||
int prefix = Integer.parseInt(parts[1]);
|
||||
byte[] addr = java.net.InetAddress.getByName(ip).getAddress();
|
||||
if (net.length != addr.length || net.length != 4) {
|
||||
return false; // 仅支持 IPv4 网段
|
||||
}
|
||||
int fullBytes = prefix / 8;
|
||||
int restBits = prefix % 8;
|
||||
for (int i = 0; i < fullBytes; i++) {
|
||||
if (net[i] != addr[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (restBits > 0 && fullBytes < 4) {
|
||||
int mask = 0xFF << (8 - restBits);
|
||||
if ((net[fullBytes] & mask) != (addr[fullBytes] & mask)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false; // 网段配置非法视为不匹配
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,22 @@ public class GlobalExceptionHandler implements Filter {
|
||||
public void doFilter(Context ctx, FilterChain chain) {
|
||||
try {
|
||||
chain.doFilter(ctx);
|
||||
} catch (org.noear.solon.core.exception.StatusException e) {
|
||||
int code = e.getCode();
|
||||
if (code >= 500) {
|
||||
// 5xx 服务端状态异常:真实故障,ERROR 记堆栈
|
||||
log.error("服务端状态异常:{} {}(HTTP {})", ctx.method(), ctx.path(), code, e);
|
||||
} else if (code == 400) {
|
||||
// 400 请求解析失败(多为 multipart/Content-Type 错误):真实调用失败,WARN 并带 Content-Type 便于定位
|
||||
log.warn("请求解析失败(HTTP 400):{} {},Content-Type={}", ctx.method(), ctx.path(), ctx.header("Content-Type"));
|
||||
} else if (code == 404) {
|
||||
// 404 路由未匹配:多为扫描器探测,DEBUG 静默(INFO 级别下不再输出)
|
||||
log.debug("路由未匹配:{} {}(HTTP 404)", ctx.method(), ctx.path());
|
||||
} else {
|
||||
// 其它 4xx(401/403/405/415 等)
|
||||
log.info("客户端状态异常:{} {}(HTTP {})", ctx.method(), ctx.path(), code);
|
||||
}
|
||||
renderStatus(ctx, code);
|
||||
} catch (BusinessException e) {
|
||||
String msg = e.getMessage() != null ? e.getMessage() : "业务异常";
|
||||
log.warn("业务异常:{}", e.getMessage());
|
||||
@@ -42,11 +58,24 @@ public class GlobalExceptionHandler implements Filter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出路由状态异常(404/405 等)响应。
|
||||
*/
|
||||
private void renderStatus(Context ctx, int code) {
|
||||
ctx.status(code);
|
||||
ctx.contentType("application/json;charset=UTF-8");
|
||||
ctx.output("{\"success\":false,\"msg\":\"Not Found\",\"data\":null}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出 Result JSON 响应(与原 hospitalmiddle 契约一致:{"success":false,"msg":...})。
|
||||
*/
|
||||
private void renderResult(Context ctx, Result<?> result) {
|
||||
ctx.status(200);
|
||||
// 保留上游预设的 4xx/5xx 状态码(如 checkBasic 认证失败已设 401),否则默认 200。
|
||||
// 若强行刷成 200,Basic 认证失败将不再返回 401,浏览器不会弹出账号密码框。
|
||||
if (ctx.status() < 400) {
|
||||
ctx.status(200);
|
||||
}
|
||||
ctx.contentType("application/json;charset=UTF-8");
|
||||
ctx.output("{\"success\":" + result.isSuccess()
|
||||
+ ",\"msg\":" + toJsonValue(result.getMsg())
|
||||
|
||||
@@ -100,8 +100,10 @@ public class OrderService {
|
||||
if (result.getPeId() == null) {
|
||||
throw BusinessException.procFailed("医院未返回体检编号");
|
||||
}
|
||||
log.info("提交预约成功:peId={}", result.getPeId());
|
||||
return result.getPeId();
|
||||
} else {
|
||||
log.warn("提交预约失败:errorMsg={}", result.getErrorMsg());
|
||||
throwHospitalFailed(result.getErrorMsg());
|
||||
// 不可达,仅为满足 Java 返回类型要求
|
||||
throw new IllegalStateException("unreachable");
|
||||
@@ -119,6 +121,7 @@ public class OrderService {
|
||||
|
||||
/** 批量添加/修改单位;返回失败信息(全部成功返回 null) */
|
||||
public String addUnits(List<Map<String, Object>> units) {
|
||||
log.info("添加单位:units={}", units);
|
||||
StringBuilder errors = new StringBuilder();
|
||||
boolean useV2 = "V2".equals(getHmVer());
|
||||
for (int index = 0; index < units.size(); index++) {
|
||||
@@ -176,6 +179,14 @@ public class OrderService {
|
||||
return enrichResultAll(adapter().getResultByPeId(peId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 15.2 体检状态(简):返回原始基础字段,不做报告组装(对齐原 hospitalmiddle 的 getResultStatus)。
|
||||
* 与 getResultByPeId 的区别在于不调用 enrichResultAll,故不含 conclusion/suggest/hospitalId/resultVoList。
|
||||
*/
|
||||
public List<MedicalMiddleResultVo> getResultStatus(String peId) {
|
||||
return adapter().getResultByPeId(peId);
|
||||
}
|
||||
|
||||
public Map<String, Object> getPeIdAndDateByIdcard(String idNo) {
|
||||
return adapter().getPeIdAndDateByIdcard(idNo);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ solon:
|
||||
app:
|
||||
name: "hospital-front"
|
||||
group: "hospital"
|
||||
# JSON 序列化对齐原 hospitalmiddle(Jackson 默认保留 null 字段)
|
||||
# JSON 序列化对齐原 hospitalmiddle(Jackson 默认保留 null 字段 + 日期格式)
|
||||
serialization:
|
||||
json:
|
||||
nullAsWriteable: true
|
||||
# 对齐原 spring.jackson.date-format / time-zone:Date/Timestamp 序列化为字符串而非毫秒时间戳
|
||||
dateAsFormat: "yyyy-MM-dd HH:mm:ss"
|
||||
dateAsTimeZone: "GMT+8"
|
||||
|
||||
# 应用配置
|
||||
# 医院注册清单由代码决定(各 Adapter 的 @Component),此处仅指定激活哪家
|
||||
@@ -44,6 +47,20 @@ order:
|
||||
admin:
|
||||
token: ""
|
||||
|
||||
# IP 白名单(应对端口暴露公网被扫描器探测;环回地址 Agent 永远放行)
|
||||
# 开启后非白名单 IP 一律 403。
|
||||
# ipWhitelist 三种写法可混用,逗号分隔:
|
||||
# 精确 IP:10.0.0.5
|
||||
# 通配段:112.113.*.*(每段可单独 *)
|
||||
# CIDR 网段:10.0.0.0/24
|
||||
security:
|
||||
ipWhitelistEnabled: false
|
||||
ipWhitelist: ""
|
||||
# ipWhitelist: "112.113.*.*,10.20.30.40,10.0.0.0/24"
|
||||
# nginx 反代部署时开启:从 X-Forwarded-For 最右侧取真实客户端 IP 判定
|
||||
# (对端必须是本机环回的 nginx,防止客户端伪造 XFF 绕过白名单)
|
||||
# trustProxy: false
|
||||
|
||||
# ====================================
|
||||
# MyBatis-Plus 公共配置(db1 为唯一数据源,随医院配置注入)
|
||||
# mappers 条目两种形式(Solon mybatis 约定):
|
||||
|
||||
@@ -338,17 +338,7 @@
|
||||
ID_NO AS "idNo",
|
||||
NAME AS "name",
|
||||
TO_CHAR(audit_date, 'YYYY-MM-DD') AS "auditDate"
|
||||
FROM (
|
||||
SELECT b.pe_id, b.pe_visit_id, a.id_no, a.name, b.unit_code,
|
||||
(SELECT t.unit_name FROM pe_unit_dict t WHERE t.unit_code = b.unit_code) 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, pe_visit b, pe_result_dict c
|
||||
WHERE a.pe_id = b.pe_id
|
||||
AND b.pe_id = c.pe_id
|
||||
AND b.pe_visit_id = c.pe_visit_id
|
||||
)
|
||||
FROM V_PHYEXAM_RESULT
|
||||
<where>
|
||||
<choose>
|
||||
<when test="auditDate != null and auditDate != ''">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<totalSizeCap>5GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%date [%thread] %-5level [%logger{50}] %file:%line - %msg%n</pattern>
|
||||
<pattern>%date [%thread] %-5level [%logger{50}] - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<totalSizeCap>2GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%date [%thread] %-5level [%logger{50}] %file:%line - %msg%n</pattern>
|
||||
<pattern>%date [%thread] %-5level [%logger{50}] - %msg%n</pattern>
|
||||
</encoder>
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>ERROR</level>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# 重新允许 TLSv1/TLSv1.1(仅本进程生效,为兼容老版 SQL Server 的登录加密)
|
||||
jdk.tls.disabledAlgorithms=SSLv3, RC4, DES, MD5withRSA, \
|
||||
DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL
|
||||
Reference in New Issue
Block a user