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:
@@ -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]]。
|
||||||
@@ -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 401,body `"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、密文 Base64,1024位。中转 `RsaEncryptUtil` 已对齐。
|
||||||
@@ -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 无日志。
|
||||||
|
- **Redis(redisx)**:是独立库,无自动注入。手动 `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]]。
|
||||||
@@ -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`(jeecg,Spring3)
|
||||||
|
- 登录/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`
|
||||||
@@ -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
@@ -24,3 +24,13 @@
|
|||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
|
|
||||||
|
# ---> IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# ---> 敏感配置(.env 不入库,.env.example 作为模板入库)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# ---> 构建产物
|
||||||
|
target/
|
||||||
|
|
||||||
|
|||||||
@@ -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 文档「开发记录」章节。
|
||||||
@@ -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 内置的 snack4(org.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-Class,java -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=VALUE,KEY 不允许有空格</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 Bean(typed=true 设为默认,供 TokenCache @Inject 注入)。
|
||||||
|
*/
|
||||||
|
@Bean(typed = true)
|
||||||
|
public RedisClient redisClient() {
|
||||||
|
Properties props = new Properties();
|
||||||
|
// server/db/password:优先 Solon.cfg(yml 占位符),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.cfg(yml),为空则取 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("业务接口返回401,token失效,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/PKCS1Padding,PKCS#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 缓存:基于 Redis(redisx)存储当前有效 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 具体路径优先于通配 /**)。
|
||||||
Reference in New Issue
Block a user