Compare commits
2
Commits
b7283db0bd
...
a3566c1f0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3566c1f0c | ||
|
|
ae2b585df9 |
@@ -2,6 +2,7 @@
|
|||||||
target/
|
target/
|
||||||
*.class
|
*.class
|
||||||
*.jar
|
*.jar
|
||||||
|
dist/
|
||||||
|
|
||||||
# 运行时目录(本地联调用,不入 Git)
|
# 运行时目录(本地联调用,不入 Git)
|
||||||
bin/
|
bin/
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import org.noear.solon.core.handle.MethodType;
|
|||||||
|
|
||||||
import com.hospital.front.adapter.HospitalRouter;
|
import com.hospital.front.adapter.HospitalRouter;
|
||||||
import com.hospital.front.dto.Result;
|
import com.hospital.front.dto.Result;
|
||||||
|
import com.hospital.front.infra.DbHealthMonitor;
|
||||||
import com.hospital.front.infra.exception.BusinessException;
|
import com.hospital.front.infra.exception.BusinessException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +44,10 @@ public class AdminApi {
|
|||||||
@Inject
|
@Inject
|
||||||
private HospitalRouter router;
|
private HospitalRouter router;
|
||||||
|
|
||||||
|
/** DB 健康缓存监测(health 接口读缓存秒回,避免 DB 慢时阻塞被 Agent 误判宕机) */
|
||||||
|
@Inject
|
||||||
|
private DbHealthMonitor dbHealthMonitor;
|
||||||
|
|
||||||
/** 日志目录(与 logback.xml 的 log.path 保持一致) */
|
/** 日志目录(与 logback.xml 的 log.path 保持一致) */
|
||||||
private File getLogDir() {
|
private File getLogDir() {
|
||||||
return new File(Solon.cfg().get("log.path", "./log"));
|
return new File(Solon.cfg().get("log.path", "./log"));
|
||||||
@@ -59,20 +64,21 @@ public class AdminApi {
|
|||||||
|
|
||||||
// ================= 1. 健康与指标 =================
|
// ================= 1. 健康与指标 =================
|
||||||
|
|
||||||
/** 健康检查(含数据库探测) */
|
/** 健康检查(进程健康即时返回;DB 状态读后台探测缓存,不阻塞) */
|
||||||
@Mapping("/health")
|
@Mapping("/health")
|
||||||
public Result<Map<String, Object>> health(Context ctx) {
|
public Result<Map<String, Object>> health(Context ctx) {
|
||||||
checkToken(ctx);
|
checkToken(ctx);
|
||||||
Runtime mem = Runtime.getRuntime();
|
Runtime mem = Runtime.getRuntime();
|
||||||
Result<Boolean> db = router.active().healthCheck();
|
Result<Boolean> db = dbHealthMonitor.last();
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
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("hospital", router.activeCode());
|
||||||
out.put("hospitalName", router.active().displayName());
|
out.put("hospitalName", router.active().displayName());
|
||||||
out.put("registeredHospitals", router.registeredCodes());
|
out.put("registeredHospitals", router.registeredCodes());
|
||||||
Map<String, Object> database = new HashMap<>();
|
Map<String, Object> database = new HashMap<>();
|
||||||
database.put("ok", db.isSuccess());
|
database.put("ok", db != null && db.isSuccess());
|
||||||
database.put("msg", db.getMsg());
|
database.put("msg", db != null ? db.getMsg() : "DB 探测进行中");
|
||||||
out.put("database", database);
|
out.put("database", database);
|
||||||
Map<String, Object> jvm = new HashMap<>();
|
Map<String, Object> jvm = new HashMap<>();
|
||||||
jvm.put("totalMemoryMb", mem.totalMemory() / 1048576);
|
jvm.put("totalMemoryMb", mem.totalMemory() / 1048576);
|
||||||
|
|||||||
@@ -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,10 @@ public class GlobalExceptionHandler implements Filter {
|
|||||||
public void doFilter(Context ctx, FilterChain chain) {
|
public void doFilter(Context ctx, FilterChain chain) {
|
||||||
try {
|
try {
|
||||||
chain.doFilter(ctx);
|
chain.doFilter(ctx);
|
||||||
|
} catch (org.noear.solon.core.exception.StatusException e) {
|
||||||
|
// 404/405 等路由状态异常:多为扫描器探测,INFO 一行即可(不打堆栈,避免刷爆日志)
|
||||||
|
log.info("路由未匹配:{} {}(HTTP {})", ctx.method(), ctx.path(), e.getCode());
|
||||||
|
renderStatus(ctx, e.getCode());
|
||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
String msg = e.getMessage() != null ? e.getMessage() : "业务异常";
|
String msg = e.getMessage() != null ? e.getMessage() : "业务异常";
|
||||||
log.warn("业务异常:{}", e.getMessage());
|
log.warn("业务异常:{}", e.getMessage());
|
||||||
@@ -42,6 +46,15 @@ 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":...})。
|
* 输出 Result JSON 响应(与原 hospitalmiddle 契约一致:{"success":false,"msg":...})。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ order:
|
|||||||
admin:
|
admin:
|
||||||
token: ""
|
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 为唯一数据源,随医院配置注入)
|
# MyBatis-Plus 公共配置(db1 为唯一数据源,随医院配置注入)
|
||||||
# mappers 条目两种形式(Solon mybatis 约定):
|
# mappers 条目两种形式(Solon mybatis 约定):
|
||||||
|
|||||||
Reference in New Issue
Block a user