feat: 完成数据中心转发网关核心功能(真实联调通过)

核心功能(已连接真实源服务器验证):
- /sys/watchUserLogin 本地处理:RSA解密验证设备密码(防伪造) + 单飞换token + Redis缓存
- 业务接口经本地通配Controller手动HttpUtils透传至源服务器 + 401检测清缓存
- token失效三重自愈:10秒探活 + 预过期刷新 + 被动401兜底

配置隔离(dotenv风格)+ 安全:
- .env 存敏感信息不入库;app.yml 用 ${KEY} 占位符引用;EnvLoader启动前加载
- 移除fastjson(规避0day),JSON改用Solon内置snack4

联调修复多个Solon实际API问题(配置文件名/YAML插件/打包/redisx/注解路径等)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-05 15:32:17 +08:00
co-authored by Claude
parent 159d6362af
commit 020f112124
20 changed files with 1196 additions and 0 deletions
@@ -0,0 +1,35 @@
package com.renkang.gateway;
import com.renkang.gateway.config.EnvLoader;
import org.noear.solon.Solon;
import org.noear.solon.annotation.SolonMain;
import org.noear.solon.scheduling.annotation.EnableScheduling;
/**
* 数据中心转发网关启动类。
* <p>
* 职责:作为中转服务器,收敛健康设备的 token 获取请求,避免海量设备直连源服务器
* 触发 HTTP 爆破风控误报(多设备共用同一接口账号,RSA 加密密码每次不同)。
* <p>
* 核心能力:
* <ol>
* <li>/sys/watchUserLogin 本地处理:私钥解密设备密码 → 比对明文 → 单飞换 token → 缓存</li>
* <li>其余请求经 GatewayController 手动 HttpUtils 透传至源服务器(http://10.10.10.228:29999</li>
* <li>token 失效三重自愈:10秒主动探活 + 预过期刷新 + 业务接口401被动兜底</li>
* </ol>
* <p>
* 架构说明:不使用 solon.cloud.gateway.routes 路由透传(其 Path=/** 会覆盖本地 watchUserLogin 处理),
* 改用本地通配 Controller@Mapping("/**")+ HttpUtils 手动透传,Solon 具体路径优先于通配,
* 确保 /sys/watchUserLogin 走本地 TokenController,其余业务接口透传源服务器。
*
* @author renkang
*/
@SolonMain
@EnableScheduling
public class App {
public static void main(String[] args) {
// 启动前加载 .env,将敏感配置注入系统属性,供 app.yml 的 ${KEY} 占位符解析
EnvLoader.load();
Solon.start(App.class, args);
}
}
@@ -0,0 +1,93 @@
package com.renkang.gateway.config;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* .env 文件加载器(类似 dotenv)。
* <p>
* 在 Solon 启动前调用 {@link #load()},读取项目根目录的 .env 文件,
* 将其中的 KEY=VALUE 注入为系统属性({@link System#setProperty}),
* 使 application.yml 的 ${KEY} 占位符能正确解析。
* <p>
* 约定:
* <ul>
* <li>.env 位于项目根目录(即 jar 运行的工作目录,或开发时项目根)</li>
* <li>每行格式 KEY=VALUEKEY 不允许有空格</li>
* <li>VALUE 可用双引号包裹(支持含空格、特殊字符、换行的值);未引号则取等号后到行尾(去除首尾空格)</li>
* <li>以 # 开头的行视为注释,跳过;空行跳过</li>
* <li>不覆盖已存在的环境变量/系统属性(优先尊重运行环境注入的值)</li>
* </ul>
*
* @author renkang
*/
public class EnvLoader {
/** .env 文件名(位于工作目录/项目根目录) */
private static final String ENV_FILE = ".env";
/**
* 加载 .env 文件并注入为系统属性。
* <p>
* 必须在 {@code Solon.start} 之前调用,确保 yml 占位符解析时属性已就绪。
* 文件不存在时静默跳过(开发环境可能依赖系统真实环境变量)。
*/
public static void load() {
File file = new File(ENV_FILE);
if (!file.exists()) {
// 尝试 classpath 下的 .env(打包后从工作目录读取,开发时从项目根读取;此处兜底)
return;
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
parseAndSet(line);
}
} catch (Exception e) {
// .env 加载失败不应阻断启动,打印警告即可
System.err.println("[EnvLoader] 加载 .env 失败:" + e.getMessage());
}
}
/**
* 解析单行并设置系统属性。
*/
private static void parseAndSet(String line) {
if (line == null) {
return;
}
String trimmed = line.trim();
// 空行或注释跳过
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
return;
}
int eq = trimmed.indexOf('=');
if (eq <= 0) {
return;
}
String key = trimmed.substring(0, eq).trim();
if (key.isEmpty()) {
return;
}
String rawValue = trimmed.substring(eq + 1).trim();
String value = unquote(rawValue);
// 不覆盖已存在的系统属性/环境变量(运行环境优先)
if (System.getProperty(key) == null && System.getenv(key) == null) {
System.setProperty(key, value);
}
}
/**
* 去除首尾双引号(若有)。支持单行值;多行值应在 .env 中合并为单行。
*/
private static String unquote(String value) {
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return value.substring(1, value.length() - 1);
}
return value;
}
}
@@ -0,0 +1,100 @@
package com.renkang.gateway.config;
import org.noear.solon.Solon;
/**
* 网关配置:从 application.yml 读取配置项(值经 ${ENV_VAR} 占位符引用 .env 变量)。
* <p>
* 采用静态方法读取而非注解注入,避免 bean 初始化时序问题(Solon.start 后 cfg 即就绪)。
* <p>
* 读取策略(双保险):先从 Solon.cfg 读 yml 解析后的值;若为空,则 fallback 到
* {@link System#getProperty}(由 {@link EnvLoader} 从 .env 注入),
* 确保 yml 占位符即使未解析也能从系统属性取到 .env 值。
*
* @author renkang
*/
public class GatewayConfig {
/** 敏感配置对应的 .env 环境变量名映射 */
private static final String ENV_SOURCE_BASE_URL = "SOURCE_BASE_URL";
private static final String ENV_USERNAME = "TOKEN_USERNAME";
private static final String ENV_PASSWORD = "TOKEN_PASSWORD";
private static final String ENV_RSA_PUBLIC = "TOKEN_RSA_PUBLIC_KEY";
private static final String ENV_RSA_PRIVATE = "TOKEN_RSA_PRIVATE_KEY";
private GatewayConfig() {
}
/**
* 读取配置:先 Solon.cfg,再 fallback System.getProperty.env 注入值)。
*/
private static String get(String cfgKey, String envKey) {
String val = Solon.cfg().get(cfgKey);
if (val == null || val.isEmpty()) {
val = System.getProperty(envKey);
}
return val;
}
// ==================== 源服务器 ====================
/** 源服务器网关地址(透传目标 + 换token/探活目标) */
public static String sourceBaseUrl() {
return get("gateway.source.base-url", ENV_SOURCE_BASE_URL);
}
/** 换 token 接口路径 */
public static String tokenPath() {
return Solon.cfg().get("gateway.source.token-path");
}
/** 探活接口路径 */
public static String probePath() {
return Solon.cfg().get("gateway.source.probe-path");
}
// ==================== 凭证与密钥 ====================
/** 统一接口账号(所有设备共用) */
public static String username() {
return get("gateway.token.username", ENV_USERNAME);
}
/** 明文密码:解密设备密码后比对;公钥加密后发源服务器 */
public static String password() {
return get("gateway.token.password", ENV_PASSWORD);
}
/** RSA 公钥(X.509 Base64,加密密码发源服务器) */
public static String rsaPublicKey() {
return get("gateway.token.rsa-public-key", ENV_RSA_PUBLIC);
}
/** RSA 私钥(PKCS#8 Base64,解密设备发来的密码) */
public static String rsaPrivateKey() {
return get("gateway.token.rsa-private-key", ENV_RSA_PRIVATE);
}
// ==================== 定时与刷新策略 ====================
/** 主动探活间隔(秒) */
public static int probeInterval() {
return Solon.cfg().getInt("gateway.token.probe-interval", 10);
}
/** 预过期刷新阈值(秒):token 距 JWT exp 过期不足此值时主动刷新 */
public static int refreshThreshold() {
return Solon.cfg().getInt("gateway.token.refresh-threshold", 3600);
}
// ==================== 缓存 key 常量(与源服务器 prefix_user_token: 严格隔离)====================
/** 当前缓存的 token */
public static final String CACHE_TOKEN_KEY = "gateway:token:current";
/** 单飞分布式锁 key(单实例下用本地锁,此 key 备用) */
public static final String LOCK_KEY = "gateway:token:lock";
/** token 上锁的过期时间(秒),防止持锁进程崩溃导致死锁 */
public static final int LOCK_EXPIRE_SECONDS = 30;
}
@@ -0,0 +1,51 @@
package com.renkang.gateway.config;
import org.noear.redisx.RedisClient;
import org.noear.solon.Solon;
import org.noear.solon.annotation.Bean;
import org.noear.solon.annotation.Configuration;
import java.util.Properties;
/**
* Redis 配置:手动构建 redisx RedisClient Bean。
* <p>
* redisx 是独立库(非 Solon 插件),无自动注入机制,需根据配置手动 new RedisClient(Properties)。
* <p>
* 配置来源(双保险):
* <ol>
* <li>优先从 Solon.cfg 的 redis 段读取(yml 占位符解析后的值)</li>
* <li>若为空,则从 System.getProperty 读取(由 EnvLoader 从 .env 注入)</li>
* </ol>
* Properties 的 key 遵循 redisx 约定:server / db / password / user / maxTotal。
*
* @author renkang
*/
@Configuration
public class RedisConfig {
/**
* 构建 RedisClient Beantyped=true 设为默认,供 TokenCache @Inject 注入)。
*/
@Bean(typed = true)
public RedisClient redisClient() {
Properties props = new Properties();
// server/db/password:优先 Solon.cfgyml 占位符),fallback System.property.env
props.setProperty("server", resolve("redis.server", "REDIS_SERVER"));
props.setProperty("db", resolve("redis.db", "REDIS_DB"));
props.setProperty("password", resolve("redis.password", "REDIS_PASSWORD"));
return new RedisClient(props);
}
/**
* 解析配置值:先取 Solon.cfgyml),为空则取 System.property.env)。
*/
private String resolve(String cfgKey, String envKey) {
String val = Solon.cfg().get(cfgKey);
if (val == null || val.isEmpty() || val.startsWith("${")) {
// yml 占位符未解析(仍为 ${...})时,回退到系统属性(.env)
val = System.getProperty(envKey);
}
return val;
}
}
@@ -0,0 +1,88 @@
package com.renkang.gateway.controller;
import com.renkang.gateway.config.GatewayConfig;
import com.renkang.gateway.manager.TokenManager;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Inject;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.core.handle.Context;
import org.noear.solon.net.http.HttpResponse;
import org.noear.solon.net.http.HttpUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collection;
/**
* 业务接口透传控制器:通配 /** 拦截所有未匹配具体路径的请求,手动 HttpUtils 透传至源服务器。
* <p>
* Solon 本地路由器中,具体路径({@code /sys/watchUserLogin})优先于通配({@code /**}),
* 故 /sys/watchUserLogin 走本地 {@link TokenController},其余业务接口走本控制器透传。
* <p>
* 透传时检测源服务器响应:若 401(token 失效),调用 {@link TokenManager#markTokenInvalid()} 清缓存,
* 设备收到 401 后自行重新调 watchUserLogin(策略B,设备已具备重试逻辑)。
*
* @author renkang
*/
@Controller
public class GatewayController {
private static final Logger log = LoggerFactory.getLogger(GatewayController.class);
@Inject
TokenManager tokenManager;
/**
* 通配透传:将请求原样转发至源服务器,并透传响应。
* <p>
* 透传内容包括:请求方法、路径+查询、请求头、请求体;响应状态码、Content-Type、响应体。
*
* @param ctx Solon 请求上下文
*/
@Mapping("/**")
public void passthrough(Context ctx) throws Throwable {
// 构造目标 URL:源服务器 + path + query
String path = ctx.path();
String query = ctx.queryString();
String pathQuery = (query != null && !query.isEmpty()) ? (path + "?" + query) : path;
String targetUrl = GatewayConfig.sourceBaseUrl() + pathQuery;
String method = ctx.method();
HttpUtils http = HttpUtils.http(targetUrl).timeout(30);
// 透传请求头(含 X-Access-Token 等鉴权头)
Collection<String> headerNames = ctx.headerNames();
if (headerNames != null) {
for (String name : headerNames) {
http.header(name, ctx.header(name));
}
}
// 透传请求体
String body = ctx.body();
if (body != null && !body.isEmpty()) {
http.bodyOfJson(body);
}
// 转发并透传响应
try (HttpResponse resp = http.exec(method)) {
int code = resp.code();
ctx.status(code);
// 透传关键响应头
String contentType = resp.header("Content-Type");
if (contentType != null) {
ctx.headerSet("Content-Type", contentType);
}
String bodyStr = resp.bodyAsString();
if (bodyStr != null) {
ctx.output(bodyStr);
}
// 401 检测:token 失效,清缓存,设备将自行重新获取 token
if (code == 401) {
log.warn("业务接口返回401token失效,path={}", pathQuery);
tokenManager.markTokenInvalid();
}
}
}
}
@@ -0,0 +1,65 @@
package com.renkang.gateway.controller;
import com.renkang.gateway.manager.TokenManager;
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.Produces;
import org.noear.snack4.ONode;
/**
* 本地 Token 控制器:拦截 /sys/watchUserLogin,本地处理而非透传源服务器。
* <p>
* 设备请求该接口时,中转:
* <ol>
* <li>解析设备请求体(username + RSA 加密 password</li>
* <li>交由 {@link TokenManager} 验证密码合法性 + 获取缓存/新换的 token</li>
* <li>返回与源服务器一致的 jeecg Result 格式(标准 JSON</li>
* </ol>
* 伪造请求(密码验证失败)返回错误,不换 token、不访问源服务器。
*
* @author renkang
*/
@Controller
public class TokenController {
@Inject
TokenManager tokenManager;
/**
* 手表用户登录/获取 token 接口(与源服务器路径一致,本地拦截)。
* <p>
* 请求体:{@code {"username":"watchUser", "password":"<RSA加密密码>"}}。
* 响应体:{@code {"success":true,"code":200,"result":{"token":"xxx"}}}。
*
* @param body 原始请求体 JSON
* @return jeecg Result 格式的 token 响应(JSONObject 确保标准 JSON 序列化)
*/
@Mapping("/sys/watchUserLogin")
@Produces("application/json")
public Object watchUserLogin(@Body String body) {
ONode req = ONode.ofJson(body);
String encPassword = req.getOrNull("password").getString();
String token = tokenManager.getTokenForDevice(encPassword);
if (token == null) {
// 密码错误(伪造请求)或换 token 失败
ONode err = new ONode().asObject();
err.set("success", false);
err.set("code", 500);
err.set("message", "密码错误或获取token失败");
return err;
}
// 返回与源服务器一致的格式(ONode 由 Solon 序列化为标准 JSON
ONode result = new ONode().asObject();
result.set("success", true);
result.set("code", 200);
ONode data = new ONode().asObject();
data.set("token", token);
result.set("result", data);
return result;
}
}
@@ -0,0 +1,237 @@
package com.renkang.gateway.manager;
import com.renkang.gateway.config.GatewayConfig;
import com.renkang.gateway.util.RsaEncryptUtil;
import com.renkang.gateway.util.TokenCache;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.net.http.HttpResponse;
import org.noear.solon.net.http.HttpUtils;
import org.noear.snack4.ONode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Map;
/**
* Token 管理器:设备密码验证 + 单飞换 token + 缓存 + 失效检测。
* <p>
* 核心职责:
* <ol>
* <li>设备密码验证:私钥解密设备发来的加密密码,与配置明文比对,拦截伪造请求</li>
* <li>单飞换 token:本地锁 + 双检缓存,同一时刻仅一个线程真正去源服务器换 token(防并发风暴)</li>
* <li>探活:用当前 token 调探活接口,检测 token 是否仍有效</li>
* <li>预过期检测:解析 JWT exp,距过期不足阈值则刷新</li>
* </ol>
* <p>
* 三条刷新触发线(探活失效、预过期、被动401)均汇入 {@link #refreshTokenSingleFlight},共享同一本地锁。
*
* @author renkang
*/
@Component
public class TokenManager {
private static final Logger log = LoggerFactory.getLogger(TokenManager.class);
@Inject
TokenCache tokenCache;
/** 单飞锁:单实例下用本地锁,同一时刻仅一个线程真正去源服务器换 token */
private final Object refreshLock = new Object();
/**
* 设备获取 token 入口(对应 /sys/watchUserLogin)。
* <p>
* 流程:① 私钥解密设备密码 → 比对明文(防伪造)→ ② 缓存命中直接返回 → ③ 缓存 miss 则单飞换 token。
*
* @param deviceEncPassword 设备用公钥加密后的密码(Base64)
* @return token 字符串;密码验证失败(伪造请求)或换 token 失败返回 null
*/
public String getTokenForDevice(String deviceEncPassword) {
// ① 验证设备密码(防伪造)
if (!verifyDevicePassword(deviceEncPassword)) {
log.warn("设备密码验证失败,疑似伪造请求,拒绝换token");
return null;
}
// ② 缓存命中直接返回
String cached = tokenCache.getToken();
if (cached != null) {
return cached;
}
// ③ 单飞换 token
return refreshTokenSingleFlight();
}
/**
* 验证设备密码:私钥解密后与配置明文比对。
* <p>
* 解密失败或明文不匹配,均判定为伪造请求,拒绝换 token。
*
* @param deviceEncPassword 设备发来的加密密码
* @return true 表示密码正确,是合法设备
*/
private boolean verifyDevicePassword(String deviceEncPassword) {
if (deviceEncPassword == null || deviceEncPassword.isEmpty()) {
return false;
}
String decrypted = RsaEncryptUtil.decrypt(deviceEncPassword, GatewayConfig.rsaPrivateKey());
if (decrypted == null) {
return false;
}
return decrypted.equals(GatewayConfig.password());
}
/**
* 单飞刷新 token:本地锁 + 双检缓存。
* <p>
* 探活失效、预过期、被动 401、设备请求缓存 miss 均调用此方法。
* 同一时刻仅一个线程真正去源服务器换 token,其余线程等锁后双检命中缓存复用,
* 从而把源服务器收到的换 token 请求收敛为 1 次,规避风控。
*
* @return 新 token;失败返回 null
*/
public String refreshTokenSingleFlight() {
// 快速路径:缓存已有
String cached = tokenCache.getToken();
if (cached != null) {
return cached;
}
synchronized (refreshLock) {
// 双检:可能在等锁期间已被其他线程刷新
cached = tokenCache.getToken();
if (cached != null) {
return cached;
}
String newToken = doRefreshFromSource();
if (newToken != null) {
tokenCache.setToken(newToken);
log.info("token刷新成功并已缓存");
}
return newToken;
}
}
/**
* 向源服务器换 token:公钥加密明文密码 → POST /sys/watchUserLogin。
* <p>
* 源服务器返回 jeecg Result 格式:{@code {success:true, code:200, result:{token:"xxx"}}}。
*
* @return 新 token;失败返回 null
*/
private String doRefreshFromSource() {
String encPassword = RsaEncryptUtil.encrypt(GatewayConfig.password(), GatewayConfig.rsaPublicKey());
if (encPassword == null) {
log.error("公钥加密密码失败,无法换token");
return null;
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("username", GatewayConfig.username());
body.put("password", encPassword);
String url = GatewayConfig.sourceBaseUrl() + GatewayConfig.tokenPath();
try {
HttpResponse response = HttpUtils.http(url)
.header("Content-Type", "application/json")
.bodyOfJson(ONode.serialize(body))
.exec("POST");
if (response.code() == 200) {
ONode result = ONode.ofJson(response.bodyAsString());
ONode data = result.getOrNull("result");
if (data != null) {
return data.getOrNull("token").getString();
}
}
log.warn("换token失败,源服务器响应码:{}", response.code());
} catch (Exception e) {
log.error("换token异常,url={}", url, e);
}
return null;
}
/**
* 探活:用当前 token 调探活接口,检测是否仍有效。
* <p>
* 探活接口 watch/dcWatchEmeMobile/list 数据量少、查询快。
* 返回 200 表示有效;401 或异常表示失效。
*
* @return true 表示 token 有效
*/
public boolean probeToken() {
String token = tokenCache.getToken();
if (token == null) {
return false;
}
String url = GatewayConfig.sourceBaseUrl() + GatewayConfig.probePath();
try {
HttpResponse response = HttpUtils.http(url)
.header("X-Access-Token", token)
.exec("GET");
int code = response.code();
if (code == 200) {
return true;
}
log.warn("探活失效,响应码:{}", code);
return false;
} catch (Exception e) {
log.error("探活异常,url={}", url, e);
return false;
}
}
/**
* 检查 token 是否将过期(距 JWT exp 不足刷新阈值)。
* <p>
* 解析 JWT payload 的 exp 字段,与当前时间比较。
* 由于源服务器滑动续期,token 实际不易自然过期,此为双保险。
*
* @return true 表示需要刷新(距过期不足阈值或无 token)
*/
public boolean isTokenExpiringSoon() {
String token = tokenCache.getToken();
if (token == null) {
return true;
}
try {
String[] parts = token.split("\\.");
if (parts.length < 2) {
return false;
}
// JWT payload 是 Base64URL 编码(- 和 _ 代替 + /,无 padding
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
ONode payload = ONode.ofJson(new String(payloadBytes));
Long exp = payload.getOrNull("exp").getLong();
if (exp == null) {
return false;
}
long remain = exp - System.currentTimeMillis() / 1000;
return remain < GatewayConfig.refreshThreshold();
} catch (Exception e) {
log.warn("解析JWT exp失败,跳过预过期判断", e);
return false;
}
}
/**
* 标记 token 失效(被动 401 时调用):清缓存,下次请求触发单飞刷新。
*/
public void markTokenInvalid() {
tokenCache.clearToken();
}
/**
* 强制刷新 token:先清缓存,再单飞换新。
* <p>
* 用于探活失效、被动 401 等已确认 token 失效的场景。
* 若不清缓存直接 refresh,会命中旧缓存返回失效 token(死循环),故必须先清。
*
* @return 新 token;失败返回 null
*/
public String forceRefresh() {
log.info("强制刷新token(先清缓存再单飞换新)");
tokenCache.clearToken();
return refreshTokenSingleFlight();
}
}
@@ -0,0 +1,52 @@
package com.renkang.gateway.task;
import com.renkang.gateway.manager.TokenManager;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
import org.noear.solon.scheduling.annotation.Scheduled;
/**
* Token 健康监测定时任务。
* <p>
* 两重机制保障 token 有效性:
* <ol>
* <li>探活任务:每 10 秒调用探活接口,失效则强制刷新(主)</li>
* <li>预过期任务:每分钟检查 JWT exp,距过期不足阈值则强制刷新(双保险)</li>
* </ol>
* 两任务均汇入 {@link TokenManager#forceRefresh()},共享单飞锁。
*
* @author renkang
*/
@Component
public class TokenProbeTask {
@Inject
TokenManager tokenManager;
/**
* 探活:每 10 秒执行一次。
* <p>
* 用当前 token 调探活接口,返回 401 或异常即判定失效,强制刷新(先清缓存再单飞换新)。
* 探活成功会顺带刷新源服务器 Redis TTL(滑动续期),令 token 保持有效。
*/
@Scheduled(fixedRate = 10000)
public void probe() {
boolean valid = tokenManager.probeToken();
if (!valid) {
tokenManager.forceRefresh();
}
}
/**
* 预过期检查:每分钟执行一次。
* <p>
* 解析缓存 token 的 JWT exp,距过期不足刷新阈值(默认 1 小时)则强制刷新。
* 由于源服务器滑动续期,token 实际不易自然过期,此为双保险。
*/
@Scheduled(fixedRate = 60000)
public void checkExpire() {
if (tokenManager.isTokenExpiringSoon()) {
tokenManager.forceRefresh();
}
}
}
@@ -0,0 +1,94 @@
package com.renkang.gateway.util;
import javax.crypto.Cipher;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* RSA 加解密工具。
* <p>
* 与源服务器 jeecg 的 {@code RSAEncryptUtils} 完全对齐,确保加解密互通:
* <ul>
* <li>transformation = "RSA"(等价 RSA/ECB/PKCS1PaddingPKCS#1 v1.5 填充)</li>
* <li>公钥格式:Base64 编码的 X.509 SubjectPublicKeyInfo{@link X509EncodedKeySpec}</li>
* <li>私钥格式:Base64 编码的 PKCS#8{@link PKCS8EncodedKeySpec}</li>
* <li>明文 UTF-8 编码,密文 Base64 标准编码输出</li>
* <li>1024 位密钥,单次 doFinal,明文不超过 117 字节</li>
* <li>Java 原生 Cipher,无第三方库依赖</li>
* </ul>
* <p>
* 用途:
* <ul>
* <li>{@link #encrypt}:中转向源服务器换 token 前,用公钥加密明文密码(源服务器用对应私钥 decrypt1 解密)</li>
* <li>{@link #decrypt}:中转解密设备发来的加密密码,验证设备合法性(防伪造)</li>
* </ul>
*
* @author renkang
*/
public class RsaEncryptUtil {
/** RSA 算法名,与源服务器 RSAEncryptUtils.KEY_ALGORITHM 一致 */
private static final String KEY_ALGORITHM = "RSA";
/**
* 公钥加密明文(用于中转向源服务器换 token)。
* <p>
* 流程:明文密码 → 公钥加密 → Base64 密文 → 发送源服务器 → 源服务器用私钥 decrypt1 解密。
*
* @param plaintext 明文(如密码 aECrgTES@@q32
* @param publicKeyBase64 Base64 编码的 X.509 公钥
* @return Base64 编码的密文;失败返回 null
*/
public static String encrypt(String plaintext, String publicKeyBase64) {
try {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PublicKey publicKey = keyFactory.generatePublic(keySpec);
Cipher cipher = Cipher.getInstance(KEY_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encrypted = cipher.doFinal(plaintext.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 私钥解密密文(用于中转解密设备发来的加密密码,验证设备合法性)。
* <p>
* 设备用公钥加密密码后发给中转,中调用本方法解密,再与配置的明文密码比对。
*
* @param ciphertextBase64 Base64 编码的密文(设备用公钥加密的密码)
* @param privateKeyBase64 Base64 编码的 PKCS#8 私钥
* @return 解密后的明文;失败返回 null
*/
public static String decrypt(String ciphertextBase64, String privateKeyBase64) {
if (ciphertextBase64 == null || ciphertextBase64.isEmpty()
|| privateKeyBase64 == null || privateKeyBase64.isEmpty()) {
return null;
}
try {
byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
Cipher cipher = Cipher.getInstance(KEY_ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] decoded = Base64.getDecoder().decode(ciphertextBase64.getBytes("UTF-8"));
byte[] decrypted = cipher.doFinal(decoded);
// 源服务器用 new String(tempBytes)(默认编码),这里显式 UTF-8 更稳妥,纯 ASCII 密码不受影响
return new String(decrypted, "UTF-8");
} catch (Exception e) {
// 解密失败通常是密文格式错误或私钥不匹配,可能是伪造请求
return null;
}
}
}
@@ -0,0 +1,55 @@
package com.renkang.gateway.util;
import com.renkang.gateway.config.GatewayConfig;
import org.noear.redisx.RedisClient;
import org.noear.solon.annotation.Component;
import org.noear.solon.annotation.Inject;
/**
* Token 缓存:基于 Redisredisx)存储当前有效 token。
* <p>
* 缓存 key 为 {@value GatewayConfig#CACHE_TOKEN_KEY},与源服务器 {@code prefix_user_token:} 严格隔离。
* 中转为单实例,token 缓存跨请求线程共享;单飞控制使用本地 JVM 锁(见 {@link com.renkang.gateway.manager.TokenManager})。
*
* @author renkang
*/
@Component
public class TokenCache {
/** token 缓存过期时间(秒),与源服务器 Redis TTL 一致 7 天 */
private static final int TOKEN_TTL = 604800;
/** 注入 redisx Redis 客户端 */
@Inject
RedisClient redisClient;
/**
* 获取当前缓存的 token。
*
* @return token 字符串;无缓存或已过期返回 null
*/
public String getToken() {
return redisClient.getBucket().get(GatewayConfig.CACHE_TOKEN_KEY);
}
/**
* 缓存 token(带过期时间)。
*/
public void setToken(String token) {
redisClient.getBucket().store(GatewayConfig.CACHE_TOKEN_KEY, token, TOKEN_TTL);
}
/**
* 清除缓存的 token(失效后立即清除,触发下次请求重新换取)。
*/
public void clearToken() {
redisClient.open(session -> session.key(GatewayConfig.CACHE_TOKEN_KEY).delete());
}
/**
* 判断当前是否有缓存的 token。
*/
public boolean hasToken() {
return getToken() != null;
}
}
+45
View File
@@ -0,0 +1,45 @@
server:
port: 8080
solon:
app:
name: dc-solon-gateway
group: gateway
# ==================== 源服务器与凭证配置 ====================
# 敏感值通过 ${ENV_VAR} 占位符引用 .env 文件中的变量(见项目根目录 .env / .env.example
gateway:
source:
# 源服务器网关地址(透传目标 + 换token/探活目标)
base-url: "${SOURCE_BASE_URL}"
# 换 token 接口路径(设备登录接口)
token-path: "/sys/watchUserLogin"
# 探活接口路径(轻量查询,用于主动检测 token 有效性)
probe-path: "/watch/dcWatchEmeMobile/list"
token:
# 统一接口账号(所有健康设备共用)
username: "${TOKEN_USERNAME}"
# 明文密码:① 解密设备发来的密码后与此比对(防伪造)② 公钥加密后发源服务器换token
password: "${TOKEN_PASSWORD}"
# RSA 公钥(X.509 Base64,1024位):加密明文密码后发源服务器
rsa-public-key: "${TOKEN_RSA_PUBLIC_KEY}"
# RSA 私钥(PKCS#8 Base64,1024位):解密设备发来的加密密码,验证设备合法性
rsa-private-key: "${TOKEN_RSA_PRIVATE_KEY}"
# 主动探活间隔(秒):定时调用探活接口检测 token 有效性
probe-interval: 10
# 预过期刷新阈值(秒):token 距 JWT exp 过期不足此值时主动刷新
refresh-threshold: 3600
# token 总有效期(秒,用于参考,实际由 JWT exp 与源服务器 Redis TTL 共同控制)
expire-seconds: 604800
# ==================== Redis 配置(redisx,复用228服务器,db11与源服务器隔离)====================
# key 前缀统一 gateway:,与源服务器 prefix_user_token: 严格隔离
redis:
server: "${REDIS_SERVER}"
db: "${REDIS_DB}"
password: "${REDIS_PASSWORD}"
# ==================== 透传说明 ====================
# 本项目用本地 GatewayController@Mapping("/**"))手动 HttpUtils 透传业务接口至源服务器,
# 不使用 solon.cloud.gateway.routes 路由透传(因其 Path=/** 会覆盖本地 watchUserLogin 处理)。
# /sys/watchUserLogin 由本地 TokenController 处理(Solon 具体路径优先于通配 /**)。