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
+6
View File
@@ -0,0 +1,6 @@
# 项目记忆索引
- [架构变更-改用本地透传](architecture-local-passthrough.md) — 为何不用 Cloud Gateway 路由透传
- [Solon 技术栈坑点](solon-pitfalls.md) — redisx/定时任务/HTTP API 的正确用法
- [jeecg token 机制](jeecg-token-mechanism.md) — 源服务器 token 滑动续期原理
- [源服务器位置](source-server-location.md) — 源代码与部署地址
@@ -0,0 +1,12 @@
---
name: architecture-local-passthrough
description: 为何用本地通配Controller+HttpUtils手动透传,而非solon.cloud.gateway.routes路由透传
metadata:
type: project
---
本项目业务接口透传采用「本地通配 Controller(`@Mapping("/**")`+ HttpUtils 手动透传」,而非 `solon.cloud.gateway.routes` 路由透传。
**Why:** 调研发现 Solon Cloud Gateway 的路由匹配中,`Path=/**` 兜底路由会**覆盖本地 Controller**,导致 `/sys/watchUserLogin` 被透传到源服务器而非本地处理——这会使规避风控的核心需求失效。而用 CloudGatewayFilter 短路拦截 watchUserLogin 时,获取 reactive 请求体(password)的 API 复杂不确定。
**How to apply:** 保持 TokenController`@Mapping("/sys/watchUserLogin")` 具体路径)与 GatewayController`@Mapping("/**")` 通配)并存,依赖 Solon 本地路由器「具体路径优先于通配」的特性。若未来要换回流式透传,需先确认本地 Controller 能优先于路由匹配。相关坑点见 [[solon-pitfalls]]。
+17
View File
@@ -0,0 +1,17 @@
---
name: jeecg-token-mechanism
description: 源服务器jeecg的token机制(JWT+Redis双层、滑动续期),决定中转刷新策略
metadata:
type: reference
---
源服务器 jeecg 的 token 机制(调研 `data-center-boot-spring3` 代码得出):
- **JWT 带 exp**`JwtUtil.sign``withExpiresAt`,exp = 生成时间 + 3.5天),但 **exp 不是硬性过期**
- **真正失效条件**Redis key `prefix_user_token:<token>` 被删除(主动 logout 或 7天不活跃)。Redis TTL = 604800秒(7天)。
- **滑动续期**`ShiroRealm.jwtTokenRefresh()` 每次请求(Redis key 存在时)重置 TTL 为 7 天,且 JWT exp 到期时静默重签。
- **失效返回**HTTP 401body `"Token失效,请重新登录!"`token 从 `X-Access-Token` 请求头读取。
**对中转的影响**:中转每 10 秒探活会刷新源服务器 Redis TTL,token 实际不会自然过期(除非被主动 logout)。预过期刷新基于 JWT exp,因滑动续期很少触发,作为双保险保留。探活失效/被动401 后必须 `forceRefresh`(先清缓存再换新),否则命中失效 token 死循环。
**RSA**:源服务器 `RSAEncryptUtils.decrypt1``Cipher.getInstance("RSA")` = `RSA/ECB/PKCS1Padding`,公钥 X.509、私钥 PKCS#8、明文 UTF-8、密文 Base641024位。中转 `RsaEncryptUtil` 已对齐。
+22
View File
@@ -0,0 +1,22 @@
---
name: solon-pitfalls
description: Solon框架的正确API用法(redisx/定时任务/HTTP/注解/配置文件/打包),踩坑后实测确认
metadata:
type: reference
---
联调实测确认的 Solon 4.0.4 正确用法(多次踩坑后验证,务必牢记):
- **配置文件名**:Solon 默认读 `app.yml`**不是** `application.yml`。配置文件名错会导致所有配置读不到(null)。
- **YAML 插件**:默认只读 .properties,需加 `org.noear:solon-config-yaml` 依赖才能读 .yml,否则报 `profile is not supported`
- **打包插件**:用 `org.noear:solon-maven-plugin`repackage 目标),**不要用** maven-assembly-plugin(版本号易错,且非 Solon 标配)。插件版本用 3.10.5(与 solon 主框架 4.0.4 不同版本线)。
- **日志**:需加 `slf4j-simple` 实现,否则 SLF4J 退化为 NOP 无日志。
- **Redisredisx)**:是独立库,无自动注入。手动 `new RedisClient(Properties)`Properties key 为 `server`/`db`/`password`/`user`/`maxTotal`。**不要**用 `@Inject("${redis}") RedisClient`。API`getBucket().store/get``open(session -> session.key(k).delete())`
- **定时任务注解**`@Scheduled`/`@EnableScheduling``org.noear.solon.scheduling.annotation`(来自 `solon-scheduling` 依赖,非 `solon-scheduling-simple`)。
- **Context**:路径 `org.noear.solon.core.handle.Context`(非 `org.noear.solon.core.Context`)。方法:`path()``queryString()``method()``body()``headerNames()``header(name)``status(int)``headerSet(k,v)``output(String)`
- **HTTP 客户端**`solon-net-httputils``HttpUtils.http(url).exec(method)` 返回 HttpResponse。状态码 `resp.code()`(非 status)。body 用 `bodyOfJson(String)`(无 `body(String)` 单参重载,只有 `body(byte[])`/`body(content,contentType)`)。响应体 `resp.bodyAsString()`
- **占位符**`${KEY}` 能解析 `System.setProperty` 注入的值(联调验证)。
- **JSON 返回**Controller 返回 HashMap 会被 snack4 序列化为 `{key=value}` 非标准 JSON。返回标准 JSON 用 snack4 ONode`new ONode().asObject()` + `.set(k,v)`+ `@Produces("application/json")`。**不使用 fastjson**(规避 0day 漏洞),JSON 处理统一用 Solon 内置 snack4`org.noear.snack4.ONode`)。
- **snack4 API**:解析 `ONode.ofJson(str)`;取值 `node.getOrNull(key).getString()`/`.getLong()`;构造对象 `new ONode().asObject().set(k,v)`;序列化 `ONode.serialize(obj)``node.toJson()`
关联 [[architecture-local-passthrough]]、[[jeecg-token-mechanism]]。
+16
View File
@@ -0,0 +1,16 @@
---
name: source-server-location
description: 源服务器jeecg代码位置、部署地址、接口账号等环境信息
metadata:
type: reference
---
- **源服务器部署地址**(测试):`http://10.10.10.228:29999`(业务接口 + 换token + 探活目标)
- **源服务器代码**`E:\giteaspace\data-center-boot-spring3`jeecgSpring3
- 登录/token`jeecg-module-system/jeecg-system-biz/.../controller/LoginController.java``/sys/watchUserLogin``/sys/getToken``/sys/getThirdToken`
- RSA 工具:`jeecg-boot-base-core/.../util/RSAEncryptUtils.java`
- token 失效:`jeecg-boot-base-core/.../config/shiro/`JwtFilter/ShiroRealm
- **Redis**(复用 228 服务器):`10.10.10.228:6379`,密码 `Aa135790!123`,中转用 db11(与源服务器 db 隔离)
- **接口账号**(所有设备共用):username=`watchUser`password=`aECrgTES@@q32`
- **探活接口**`/watch/dcWatchEmeMobile/list`(带 `X-Access-Token` 头,200=有效/401=失效)
- **方案文档**`E:\data\ob_data\myob\11 健康长庆\数据中心转发网关优化\数据中心转发网关优化.md`
+23
View File
@@ -0,0 +1,23 @@
# .env 配置示例文件
# 用法:复制本文件为 .env,填入真实值。.env 文件不入 git 仓库(见 .gitignore)。
# application.yml 通过 ${ENV_VAR} 占位符引用此处变量。
#
# 注意:RSA 私钥含换行,需用双引号包裹并保留换行(或写成单行 Base64,推荐单行)。
# 接口账号(所有设备共用)
TOKEN_USERNAME=watchUser
TOKEN_PASSWORD=aECrgTES@@q32
# RSA 公钥(X.509 Base64,单行)
TOKEN_RSA_PUBLIC_KEY=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDbc+/BMkhCHZ5RDj6r4TXsFQJwP7HUd8qq+6wXo1KuvE6B1/9JmM7pNThmnkebSa+yP1ECa/H/QCSSKKHBb1JUeHKwUymvxVd78DPOanshqiAKSf+1bKx4g1OMT/5DPTn5Dg0lH5t4wvnUIpWM/7XH0Y1NWsgF5r8C90I3QcBkOwIDAQAB
# RSA 私钥(PKCS#8 Base64,单行)
TOKEN_RSA_PRIVATE_KEY=填入你的PKCS8私钥Base64字符串
# 源服务器地址(测试环境)
SOURCE_BASE_URL=http://10.10.10.228:29999
# Redis(复用源服务器,db11 隔离)
REDIS_SERVER=10.10.10.228:6379
REDIS_PASSWORD=Aa135790!123
REDIS_DB=11
+10
View File
@@ -24,3 +24,13 @@
hs_err_pid*
replay_pid*
# ---> IDE
.idea/
*.iml
# ---> 敏感配置(.env 不入库,.env.example 作为模板入库)
.env
# ---> 构建产物
target/
+72
View File
@@ -0,0 +1,72 @@
# CLAUDE.md — dc-solon-gateway 项目说明
> 本文件是 Claude Code 在本项目的工作指引。项目代码、约定、关键决策均记录于此。
## 项目概述
**dc-solon-gateway** 是数据中心转发网关,作为中转服务器收敛健康设备(手表)的 token 获取请求,规避 HTTP 爆破风控误报。
### 背景
健康设备众多且共用同一接口账号(watchUser),设备各自向源服务器(jeecg)请求 `/sys/watchUserLogin` 换 token。因 RSA 加密密码每次密文不同,短时间高频率访问被 WAF 误判为 HTTP 爆破。
### 解决方案
旧服务器域名指向本中转服务器:
- `/sys/watchUserLogin`**本地处理**:解密验证设备密码(防伪造)+ 单飞换 token + Redis 缓存
- 其余业务接口 → **手动 HttpUtils 透传**至源服务器 `http://10.10.10.228:29999`
- token 失效三重自愈:10秒主动探活 + 预过期刷新 + 业务接口 401 被动兜底
## 技术栈
| 组件 | 选择 |
|------|------|
| 框架内核 | Solon Cloud Gateway(提供 Vert.x HTTP Server + Solon 内核) |
| 透传 | 本地通配 Controller`@Mapping("/**")`+ HttpUtils 手动透传 |
| HTTP 客户端 | `solon-net-httputils``HttpUtils.http(url).exec(method)` |
| Redis | `redisx`(基于 Jedis,复用源服务器 228:6379 db11 |
| 定时任务 | `solon-scheduling-simple``@Scheduled` + `@EnableScheduling` |
| JSON | Solon 内置 `snack4``org.noear.snack4.ONode`,不使用 fastjson,规避其 0day 漏洞) |
## 关键约定(开发必须遵守)
### 1. 文件路径
- 所有文件操作必须用**完整绝对 Windows 路径**(如 `E:\yixiongspace\dc-solon-gateway\src\...`
### 2. 代码注释
- 所有注释用**简体中文**(类/方法/行内注释)
- 标识符(类名/方法名/变量名)用英文
### 3. Solon 注意事项(踩过的坑,务必牢记)
- **不要用 `solon.cloud.gateway.routes` 路由透传**:其 `Path=/**` 会覆盖本地 Controller,使 watchUserLogin 无法本地处理。本项目用本地通配 Controller 手动透传。
- **定时任务用 `@Scheduled`**(非 `@Async`),启动类加 `@EnableScheduling`,注解包 `org.noear.solon.scheduling.simple.annotation.Scheduled`
- **Redis 用 redisx**(非 solon-redis-jedis),API 为会话式 `getBucket().store/get` + `open(session -> session.key(k).delete())`
- **HTTP 响应状态码**用 `response.code()`(非 `.status()`),JSON body 用 `bodyOfJson()`
### 4. RSA 加密
- 中转的 `RsaEncryptUtil` 必须与源服务器 `RSAEncryptUtils` 对齐:`Cipher.getInstance("RSA")` = `RSA/ECB/PKCS1Padding`,公钥 X.509、私钥 PKCS#8、明文 UTF-8、密文 Base64。
- 源服务器代码位置:`E:\giteaspace\data-center-boot-spring3\jeecg-boot-base-core\...\RSAEncryptUtils.java`
### 5. 单飞机制
- 中转为**单实例**,单飞用本地 `synchronized` 双检锁(无需 Redis 分布式锁)。
- 探活失效/被动401 后必须**先清缓存再换 token**(`forceRefresh`),否则命中旧缓存返回失效 token 死循环。
## 配置
配置采用 **.env + 占位符** 机制(类似 dotenv):
- **`.env`**(项目根目录,**不入库**):存储真实敏感值(密码、私钥、Redis、源服务器地址),格式 `KEY=VALUE`
- **`.env.example`**(入库):模板,复制为 `.env` 后填真实值
- **`app.yml`**(注意:Solon 默认读 `app.yml`,非 `application.yml`):用 `${KEY}` 占位符引用 .env 变量
- **`EnvLoader`**`Solon.start` 前调用,读取 `.env``System.setProperty` 注入系统属性,使 yml 占位符解析
- **`GatewayConfig` / `RedisConfig`**:双保险读取(先 Solon.cfg,取不到则 fallback `System.getProperty`
> .env 不入 git(见 `.gitignore`)。修改敏感配置只改 `.env`,不改 yml。
> 运行:`java -jar dc-solon-gateway.jar`(从项目根/jar同目录启动,确保能读到 `.env`)。
## 相关文档
- 方案设计与开发进度:`E:\data\ob_data\myob\11 健康长庆\数据中心转发网关优化\数据中心转发网关优化.md`
- 项目记忆:`.claude/memory/`(索引见 `.claude/memory/MEMORY.md`
## 待联调验证(测试环境)
以下 API 细节在编码时基于文档/训练数据,需真实环境验证:本地路由优先级、redisx 注入与删除方法、Solon Context API、@Body 注解、redisx 版本兼容性。详见 OB 文档「开发记录」章节。
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.renkang</groupId>
<artifactId>dc-solon-gateway</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>dc-solon-gateway</name>
<description>数据中心转发网关:收敛设备token请求,规避HTTP爆破风控误报</description>
<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<solon.version>4.0.4</solon.version>
</properties>
<dependencies>
<!-- Solon Cloud Gateway:提供 Vert.x HTTP Server 与 Solon 内核 -->
<!-- 本项目用本地 Controller + HttpUtils 手动透传,不依赖其路由透传(见 GatewayController -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-cloud-gateway</artifactId>
<version>${solon.version}</version>
</dependency>
<!-- Solon YAML 配置支持(默认只读 .properties,需此插件才能读 app.yml -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-config-yaml</artifactId>
<version>${solon.version}</version>
</dependency>
<!-- HTTP 客户端:中转手动透传业务接口 + 换token + 探活 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-net-httputils</artifactId>
<version>${solon.version}</version>
</dependency>
<!-- Redis 客户端(redisx,基于 Jedis):token缓存 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>redisx</artifactId>
<version>1.8.2</version>
</dependency>
<!-- Solon 定时任务:注解 @Scheduled/@EnableScheduling(在 solon-scheduling+ simple 实现 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-scheduling</artifactId>
<version>${solon.version}</version>
</dependency>
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-scheduling-simple</artifactId>
<version>${solon.version}</version>
</dependency>
<!-- JSON 处理:使用 Solon 内置的 snack4org.noear:snack4,由 solon 传递依赖引入),
不再使用 fastjson(规避其历史 0day 漏洞风险) -->
<!-- SLF4J 日志实现:slf4j-simple,提供日志输出(否则 SLF4J 退化为 NOP 无日志) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.13</version>
</dependency>
</dependencies>
<build>
<finalName>dc-solon-gateway</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<!-- Solon 官方打包插件:打可执行 fat jar(含 Main-Classjava -jar 直接运行) -->
<plugin>
<groupId>org.noear</groupId>
<artifactId>solon-maven-plugin</artifactId>
<version>3.10.5</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>repackage</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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 具体路径优先于通配 /**)。