diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..b9ef92c --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -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) — 源代码与部署地址 diff --git a/.claude/memory/architecture-local-passthrough.md b/.claude/memory/architecture-local-passthrough.md new file mode 100644 index 0000000..c3ab1cf --- /dev/null +++ b/.claude/memory/architecture-local-passthrough.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]]。 diff --git a/.claude/memory/jeecg-token-mechanism.md b/.claude/memory/jeecg-token-mechanism.md new file mode 100644 index 0000000..c88da1a --- /dev/null +++ b/.claude/memory/jeecg-token-mechanism.md @@ -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:` 被删除(主动 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` 已对齐。 diff --git a/.claude/memory/solon-pitfalls.md b/.claude/memory/solon-pitfalls.md new file mode 100644 index 0000000..a9738d5 --- /dev/null +++ b/.claude/memory/solon-pitfalls.md @@ -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]]。 diff --git a/.claude/memory/source-server-location.md b/.claude/memory/source-server-location.md new file mode 100644 index 0000000..3cc7e4a --- /dev/null +++ b/.claude/memory/source-server-location.md @@ -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` diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bde5a08 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 9154f4c..24682e7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,13 @@ hs_err_pid* replay_pid* +# ---> IDE +.idea/ +*.iml + +# ---> 敏感配置(.env 不入库,.env.example 作为模板入库) +.env + +# ---> 构建产物 +target/ + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7f0585b --- /dev/null +++ b/CLAUDE.md @@ -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 文档「开发记录」章节。 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..316f178 --- /dev/null +++ b/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + com.renkang + dc-solon-gateway + 1.0.0 + jar + + dc-solon-gateway + 数据中心转发网关:收敛设备token请求,规避HTTP爆破风控误报 + + + 17 + 17 + 17 + UTF-8 + 4.0.4 + + + + + + + org.noear + solon-cloud-gateway + ${solon.version} + + + + + org.noear + solon-config-yaml + ${solon.version} + + + + + org.noear + solon-net-httputils + ${solon.version} + + + + + org.noear + redisx + 1.8.2 + + + + + org.noear + solon-scheduling + ${solon.version} + + + org.noear + solon-scheduling-simple + ${solon.version} + + + + + + + org.slf4j + slf4j-simple + 2.0.13 + + + + + dc-solon-gateway + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + UTF-8 + + + + + org.noear + solon-maven-plugin + 3.10.5 + + + package + repackage + + + + + + diff --git a/src/main/java/com/renkang/gateway/App.java b/src/main/java/com/renkang/gateway/App.java new file mode 100644 index 0000000..2dde1d3 --- /dev/null +++ b/src/main/java/com/renkang/gateway/App.java @@ -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; + +/** + * 数据中心转发网关启动类。 + *

+ * 职责:作为中转服务器,收敛健康设备的 token 获取请求,避免海量设备直连源服务器 + * 触发 HTTP 爆破风控误报(多设备共用同一接口账号,RSA 加密密码每次不同)。 + *

+ * 核心能力: + *

    + *
  1. /sys/watchUserLogin 本地处理:私钥解密设备密码 → 比对明文 → 单飞换 token → 缓存
  2. + *
  3. 其余请求经 GatewayController 手动 HttpUtils 透传至源服务器(http://10.10.10.228:29999)
  4. + *
  5. token 失效三重自愈:10秒主动探活 + 预过期刷新 + 业务接口401被动兜底
  6. + *
+ *

+ * 架构说明:不使用 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); + } +} diff --git a/src/main/java/com/renkang/gateway/config/EnvLoader.java b/src/main/java/com/renkang/gateway/config/EnvLoader.java new file mode 100644 index 0000000..50b9fc9 --- /dev/null +++ b/src/main/java/com/renkang/gateway/config/EnvLoader.java @@ -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)。 + *

+ * 在 Solon 启动前调用 {@link #load()},读取项目根目录的 .env 文件, + * 将其中的 KEY=VALUE 注入为系统属性({@link System#setProperty}), + * 使 application.yml 的 ${KEY} 占位符能正确解析。 + *

+ * 约定: + *

+ * + * @author renkang + */ +public class EnvLoader { + + /** .env 文件名(位于工作目录/项目根目录) */ + private static final String ENV_FILE = ".env"; + + /** + * 加载 .env 文件并注入为系统属性。 + *

+ * 必须在 {@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; + } +} diff --git a/src/main/java/com/renkang/gateway/config/GatewayConfig.java b/src/main/java/com/renkang/gateway/config/GatewayConfig.java new file mode 100644 index 0000000..9aa70dc --- /dev/null +++ b/src/main/java/com/renkang/gateway/config/GatewayConfig.java @@ -0,0 +1,100 @@ +package com.renkang.gateway.config; + +import org.noear.solon.Solon; + +/** + * 网关配置:从 application.yml 读取配置项(值经 ${ENV_VAR} 占位符引用 .env 变量)。 + *

+ * 采用静态方法读取而非注解注入,避免 bean 初始化时序问题(Solon.start 后 cfg 即就绪)。 + *

+ * 读取策略(双保险):先从 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; +} diff --git a/src/main/java/com/renkang/gateway/config/RedisConfig.java b/src/main/java/com/renkang/gateway/config/RedisConfig.java new file mode 100644 index 0000000..d511f9b --- /dev/null +++ b/src/main/java/com/renkang/gateway/config/RedisConfig.java @@ -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。 + *

+ * redisx 是独立库(非 Solon 插件),无自动注入机制,需根据配置手动 new RedisClient(Properties)。 + *

+ * 配置来源(双保险): + *

    + *
  1. 优先从 Solon.cfg 的 redis 段读取(yml 占位符解析后的值)
  2. + *
  3. 若为空,则从 System.getProperty 读取(由 EnvLoader 从 .env 注入)
  4. + *
+ * 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; + } +} diff --git a/src/main/java/com/renkang/gateway/controller/GatewayController.java b/src/main/java/com/renkang/gateway/controller/GatewayController.java new file mode 100644 index 0000000..31b10ed --- /dev/null +++ b/src/main/java/com/renkang/gateway/controller/GatewayController.java @@ -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 透传至源服务器。 + *

+ * Solon 本地路由器中,具体路径({@code /sys/watchUserLogin})优先于通配({@code /**}), + * 故 /sys/watchUserLogin 走本地 {@link TokenController},其余业务接口走本控制器透传。 + *

+ * 透传时检测源服务器响应:若 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; + + /** + * 通配透传:将请求原样转发至源服务器,并透传响应。 + *

+ * 透传内容包括:请求方法、路径+查询、请求头、请求体;响应状态码、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 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(); + } + } + } +} diff --git a/src/main/java/com/renkang/gateway/controller/TokenController.java b/src/main/java/com/renkang/gateway/controller/TokenController.java new file mode 100644 index 0000000..5d58e60 --- /dev/null +++ b/src/main/java/com/renkang/gateway/controller/TokenController.java @@ -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,本地处理而非透传源服务器。 + *

+ * 设备请求该接口时,中转: + *

    + *
  1. 解析设备请求体(username + RSA 加密 password)
  2. + *
  3. 交由 {@link TokenManager} 验证密码合法性 + 获取缓存/新换的 token
  4. + *
  5. 返回与源服务器一致的 jeecg Result 格式(标准 JSON)
  6. + *
+ * 伪造请求(密码验证失败)返回错误,不换 token、不访问源服务器。 + * + * @author renkang + */ +@Controller +public class TokenController { + + @Inject + TokenManager tokenManager; + + /** + * 手表用户登录/获取 token 接口(与源服务器路径一致,本地拦截)。 + *

+ * 请求体:{@code {"username":"watchUser", "password":""}}。 + * 响应体:{@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; + } +} + diff --git a/src/main/java/com/renkang/gateway/manager/TokenManager.java b/src/main/java/com/renkang/gateway/manager/TokenManager.java new file mode 100644 index 0000000..86027a3 --- /dev/null +++ b/src/main/java/com/renkang/gateway/manager/TokenManager.java @@ -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 + 缓存 + 失效检测。 + *

+ * 核心职责: + *

    + *
  1. 设备密码验证:私钥解密设备发来的加密密码,与配置明文比对,拦截伪造请求
  2. + *
  3. 单飞换 token:本地锁 + 双检缓存,同一时刻仅一个线程真正去源服务器换 token(防并发风暴)
  4. + *
  5. 探活:用当前 token 调探活接口,检测 token 是否仍有效
  6. + *
  7. 预过期检测:解析 JWT exp,距过期不足阈值则刷新
  8. + *
+ *

+ * 三条刷新触发线(探活失效、预过期、被动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)。 + *

+ * 流程:① 私钥解密设备密码 → 比对明文(防伪造)→ ② 缓存命中直接返回 → ③ 缓存 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(); + } + + /** + * 验证设备密码:私钥解密后与配置明文比对。 + *

+ * 解密失败或明文不匹配,均判定为伪造请求,拒绝换 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:本地锁 + 双检缓存。 + *

+ * 探活失效、预过期、被动 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。 + *

+ * 源服务器返回 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 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 调探活接口,检测是否仍有效。 + *

+ * 探活接口 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 不足刷新阈值)。 + *

+ * 解析 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:先清缓存,再单飞换新。 + *

+ * 用于探活失效、被动 401 等已确认 token 失效的场景。 + * 若不清缓存直接 refresh,会命中旧缓存返回失效 token(死循环),故必须先清。 + * + * @return 新 token;失败返回 null + */ + public String forceRefresh() { + log.info("强制刷新token(先清缓存再单飞换新)"); + tokenCache.clearToken(); + return refreshTokenSingleFlight(); + } +} diff --git a/src/main/java/com/renkang/gateway/task/TokenProbeTask.java b/src/main/java/com/renkang/gateway/task/TokenProbeTask.java new file mode 100644 index 0000000..0b23498 --- /dev/null +++ b/src/main/java/com/renkang/gateway/task/TokenProbeTask.java @@ -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 健康监测定时任务。 + *

+ * 两重机制保障 token 有效性: + *

    + *
  1. 探活任务:每 10 秒调用探活接口,失效则强制刷新(主)
  2. + *
  3. 预过期任务:每分钟检查 JWT exp,距过期不足阈值则强制刷新(双保险)
  4. + *
+ * 两任务均汇入 {@link TokenManager#forceRefresh()},共享单飞锁。 + * + * @author renkang + */ +@Component +public class TokenProbeTask { + + @Inject + TokenManager tokenManager; + + /** + * 探活:每 10 秒执行一次。 + *

+ * 用当前 token 调探活接口,返回 401 或异常即判定失效,强制刷新(先清缓存再单飞换新)。 + * 探活成功会顺带刷新源服务器 Redis TTL(滑动续期),令 token 保持有效。 + */ + @Scheduled(fixedRate = 10000) + public void probe() { + boolean valid = tokenManager.probeToken(); + if (!valid) { + tokenManager.forceRefresh(); + } + } + + /** + * 预过期检查:每分钟执行一次。 + *

+ * 解析缓存 token 的 JWT exp,距过期不足刷新阈值(默认 1 小时)则强制刷新。 + * 由于源服务器滑动续期,token 实际不易自然过期,此为双保险。 + */ + @Scheduled(fixedRate = 60000) + public void checkExpire() { + if (tokenManager.isTokenExpiringSoon()) { + tokenManager.forceRefresh(); + } + } +} diff --git a/src/main/java/com/renkang/gateway/util/RsaEncryptUtil.java b/src/main/java/com/renkang/gateway/util/RsaEncryptUtil.java new file mode 100644 index 0000000..eb1e9cd --- /dev/null +++ b/src/main/java/com/renkang/gateway/util/RsaEncryptUtil.java @@ -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 加解密工具。 + *

+ * 与源服务器 jeecg 的 {@code RSAEncryptUtils} 完全对齐,确保加解密互通: + *

+ *

+ * 用途: + *

+ * + * @author renkang + */ +public class RsaEncryptUtil { + + /** RSA 算法名,与源服务器 RSAEncryptUtils.KEY_ALGORITHM 一致 */ + private static final String KEY_ALGORITHM = "RSA"; + + /** + * 公钥加密明文(用于中转向源服务器换 token)。 + *

+ * 流程:明文密码 → 公钥加密 → 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; + } + } + + /** + * 私钥解密密文(用于中转解密设备发来的加密密码,验证设备合法性)。 + *

+ * 设备用公钥加密密码后发给中转,中调用本方法解密,再与配置的明文密码比对。 + * + * @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; + } + } +} diff --git a/src/main/java/com/renkang/gateway/util/TokenCache.java b/src/main/java/com/renkang/gateway/util/TokenCache.java new file mode 100644 index 0000000..e00f87c --- /dev/null +++ b/src/main/java/com/renkang/gateway/util/TokenCache.java @@ -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。 + *

+ * 缓存 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; + } +} diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml new file mode 100644 index 0000000..59904f5 --- /dev/null +++ b/src/main/resources/app.yml @@ -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 具体路径优先于通配 /**)。