feat: 新增网关模块

This commit is contained in:
x6g2
2026-11-01 14:14:26 +08:00
parent c7ef37b6c0
commit b1fcd66450
49 changed files with 4440 additions and 2 deletions
+12
View File
@@ -0,0 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="starapi-gateway" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="starapi/starapi-gateway:6.0.0" />
<option name="buildOnly" value="true" />
<option name="sourceFilePath" value="starapi-gateway/Dockerfile" />
</settings>
</deployment>
<method v="2" />
</configuration>
</component>
+9
View File
@@ -487,6 +487,13 @@
<version>${revision}</version>
</dependency>
<!-- 开放接口网关模块 -->
<dependency>
<groupId>com.starsee.starapi</groupId>
<artifactId>starapi-gateway</artifactId>
<version>${revision}</version>
</dependency>
</dependencies>
</dependencyManagement>
@@ -501,6 +508,8 @@
<module>starapi-modules</module>
<!-- api模块 -->
<module>starapi-api</module>
<!-- 开放接口网关 -->
<module>starapi-gateway</module>
</modules>
<packaging>pom</packaging>
+13
View File
@@ -130,6 +130,19 @@ services:
privileged: true
network_mode: "host"
starapi-gateway:
image: starapi/starapi-gateway:6.0.0
container_name: starapi-gateway
environment:
# 时区上海
TZ: Asia/Shanghai
SERVER_PORT: 8082
volumes:
# 网关日志目录
- /docker/gateway/logs/:/starapi/gateway/logs/
privileged: true
network_mode: "host"
starapi-monitor-admin:
image: starapi/starapi-monitor-admin:6.0.0
container_name: starapi-monitor-admin
+23
View File
@@ -45,6 +45,11 @@ http {
server 127.0.0.1:9090;
}
# 开放 API 网关(签名鉴权、限流、上游转发)
upstream gateway {
server 127.0.0.1:8082;
}
upstream snailjob-server {
server 127.0.0.1:8800;
}
@@ -90,6 +95,24 @@ http {
index index.html index.htm;
}
# 开放 API 调用入口:原样转发到网关,不丢弃 /api/v1 前缀
# 注意:不要在 location 内改写或丢弃 X-App-Key / X-Timestamp / X-Nonce / X-Signature / X-Request-Id,
# nginx 默认原样透传客户端请求头,网关签名校验依赖这些头
location /api/v1/ {
# 设置客户端请求头中的 Host 信息(保持原始 Host)
proxy_set_header Host $http_host;
# 获取客户端真实 IP
proxy_set_header X-Real-IP $remote_addr;
# 获取完整的客户端 IP 链(经过多级代理时)
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 网关转发上游可能耗时较长,放宽读超时
proxy_read_timeout 300s;
# 禁用代理缓冲,数据直接传给客户端
proxy_buffering off;
# 不带 URI 的 proxy_pass,保留 /api/v1 前缀
proxy_pass http://gateway;
}
location /prod-api/ {
# 设置客户端请求头中的 Host 信息(保持原始 Host)
proxy_set_header Host $http_host;
@@ -110,6 +110,8 @@ security:
- /warm-flow-ui/config
- /snail-chat/**
- /api/snail/chat/**
# 内网接口放行 Sa-Token 登录校验,改由内网令牌单独校验
- /internal/**
# MyBatisPlus配置
# https://baomidou.com/config/
@@ -191,6 +193,16 @@ springdoc:
- group: 8.调用日志模块
packages-to-scan: com.starsee.starapi.calllog
# 内网接口鉴权配置(网关等内部服务调用 admin 时使用)
internal:
api:
# 是否开启内网接口鉴权
enabled: true
# 网关与 admin 共享的内网调用令牌,需与网关 gateway.internal.token 保持一致
token: ${INTERNAL_API_TOKEN:starapi-internal-token}
# 允许调用内网接口的来源 IP,空表示不限制
ip-white-list: []
# 防止XSS攻击
xss:
# 过滤开关
@@ -199,6 +211,8 @@ xss:
excludeUrls:
- /system/notice
- /warm-flow/save-json
# 内网接口为服务端内部 JSON 调用,不做 XSS 清洗
- /internal/**
--- # 分布式锁 lock4j 全局配置
lock4j:
@@ -0,0 +1,38 @@
package com.starsee.starapi.internal.api.constant;
/**
* 内网接口契约常量
* <p>
* 内网接口只允许服务端之间调用(网关调用 admin 的扣费与调用日志接口),统一走
* {@link #INTERNAL_TOKEN_HEADER} 令牌鉴权,并在安全配置中放行 Sa-Token 登录校验。
*
* @author x6g2
*/
public interface InternalApiConstants {
/**
* 内网调用令牌请求头
*/
String INTERNAL_TOKEN_HEADER = "X-Internal-Token";
/**
* 内网接口路径前缀
*/
String INTERNAL_PATH_PREFIX = "/internal/";
/**
* 内网接口路径匹配模式
*/
String INTERNAL_PATH_PATTERN = INTERNAL_PATH_PREFIX + "**";
/**
* 计费扣费内网接口路径
*/
String BILLING_DEDUCT_PATH = "/internal/billing/deduct";
/**
* 调用日志写入内网接口路径
*/
String CALL_LOG_RECORD_PATH = "/internal/calllog/record";
}
@@ -19,8 +19,11 @@ import com.starsee.starapi.common.core.utils.ServletUtils;
import com.starsee.starapi.common.core.utils.SpringUtils;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.common.satoken.utils.LoginHelper;
import com.starsee.starapi.common.security.config.properties.InternalApiProperties;
import com.starsee.starapi.common.security.config.properties.SecurityProperties;
import com.starsee.starapi.common.security.handler.AllUrlHandler;
import com.starsee.starapi.common.security.interceptor.InternalApiInterceptor;
import com.starsee.starapi.internal.api.constant.InternalApiConstants;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -37,7 +40,7 @@ import java.util.List;
@Slf4j
@AutoConfiguration
@EnableConfigurationProperties(SecurityProperties.class)
@EnableConfigurationProperties({SecurityProperties.class, InternalApiProperties.class})
@RequiredArgsConstructor
public class SecurityConfig implements WebMvcConfigurer {
@@ -46,7 +49,7 @@ public class SecurityConfig implements WebMvcConfigurer {
private final SecurityProperties securityProperties;
/**
* 注册 Sa-Token 路由拦截器并配置鉴权规则。
* 注册 Sa-Token 路由拦截器与内网接口鉴权拦截器,并配置鉴权规则。
*
* @param registry 拦截器注册器
*/
@@ -89,6 +92,10 @@ public class SecurityConfig implements WebMvcConfigurer {
})).addPathPatterns("/**")
// 排除不需要拦截的路径
.excludePathPatterns(securityProperties.getExcludes());
// 内网接口令牌鉴权,独立于后台登录鉴权,仅拦截 /internal/** 路径
registry.addInterceptor(SpringUtils.getBean(InternalApiInterceptor.class))
.addPathPatterns(InternalApiConstants.INTERNAL_PATH_PATTERN);
}
/**
@@ -112,6 +119,17 @@ public class SecurityConfig implements WebMvcConfigurer {
});
}
/**
* 创建内网接口鉴权拦截器,仅拦截内网接口路径。
*
* @param internalApiProperties 内网接口鉴权配置
* @return 内网接口鉴权拦截器
*/
@Bean
public InternalApiInterceptor internalApiInterceptor(InternalApiProperties internalApiProperties) {
return new InternalApiInterceptor(internalApiProperties);
}
/**
* 按客户端配置校验接口访问路径与来源 IP。
*
@@ -0,0 +1,33 @@
package com.starsee.starapi.common.security.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 内网接口鉴权配置属性
* <p>
* 内网接口只允许网关等服务端内部调用,调用方需在请求头中携带内网令牌;
* 网关侧的令牌配置为 {@code gateway.internal.token},两侧需保持一致。
*
* @author x6g2
*/
@Data
@ConfigurationProperties(prefix = "internal.api")
public class InternalApiProperties {
/**
* 是否开启内网接口鉴权,关闭后所有内网接口不再校验令牌
*/
private boolean enabled = true;
/**
* 内网调用令牌,为空时拒绝所有内网请求(fail-closed)
*/
private String token;
/**
* 允许调用内网接口的来源 IP 白名单,支持单个 IP 与 CIDR 网段,为空表示不限制
*/
private String[] ipWhiteList = new String[0];
}
@@ -0,0 +1,96 @@
package com.starsee.starapi.common.security.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.starsee.starapi.common.core.constant.HttpStatus;
import com.starsee.starapi.common.core.domain.R;
import com.starsee.starapi.common.core.utils.NetUtils;
import com.starsee.starapi.common.core.utils.ServletUtils;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.common.json.utils.JsonUtils;
import com.starsee.starapi.common.security.config.properties.InternalApiProperties;
import com.starsee.starapi.internal.api.constant.InternalApiConstants;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 内网接口鉴权拦截器
* <p>
* 仅拦截 {@code /internal/**} 路径,与后台登录鉴权相互独立:先校验请求头中的内网令牌,
* 再按配置的来源 IP 白名单校验调用方;令牌未配置时按 fail-closed 拒绝全部请求。
*
* @author x6g2
*/
@Slf4j
@RequiredArgsConstructor
public class InternalApiInterceptor implements HandlerInterceptor {
private final InternalApiProperties internalApiProperties;
/**
* 校验内网调用令牌与来源 IP,校验通过后放行请求。
*
* @param request 当前请求
* @param response 当前响应
* @param handler 目标处理器
* @return true=放行,false=拒绝
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!internalApiProperties.isEnabled()) {
log.warn("内网接口鉴权已关闭,跳过令牌校验: {} {}", request.getMethod(), request.getRequestURI());
return true;
}
String token = internalApiProperties.getToken();
if (StringUtils.isBlank(token)) {
log.warn("内网接口令牌未配置,拒绝访问,来源IP: {}, 请求路径: {}", getClientIp(request), request.getRequestURI());
return reject(response);
}
String requestToken = request.getHeader(InternalApiConstants.INTERNAL_TOKEN_HEADER);
if (!StringUtils.equals(token, requestToken)) {
log.warn("内网接口令牌校验失败,来源IP: {}, 请求路径: {}", getClientIp(request), request.getRequestURI());
return reject(response);
}
String[] ipWhiteList = internalApiProperties.getIpWhiteList();
if (ipWhiteList != null && ipWhiteList.length > 0) {
String clientIp = getClientIp(request);
boolean matched = false;
for (String rule : ipWhiteList) {
if (StringUtils.isNotBlank(rule) && NetUtils.isMatchIpRule(rule, clientIp)) {
matched = true;
break;
}
}
if (!matched) {
log.warn("内网接口来源IP不在白名单内,来源IP: {}, 请求路径: {}", clientIp, request.getRequestURI());
return reject(response);
}
}
return true;
}
/**
* 输出统一的未授权响应,不向前端暴露失败细节。
*
* @param response 当前响应
* @return 始终返回 false,表示中断请求
*/
private boolean reject(HttpServletResponse response) {
ServletUtils.renderString(response, JsonUtils.toJsonString(R.fail(HttpStatus.UNAUTHORIZED, "内网接口鉴权失败")));
// renderString 固定写入 200,这里显式回写 401,保持与响应体 code 一致的未授权语义
response.setStatus(HttpStatus.UNAUTHORIZED);
return false;
}
/**
* 获取调用方来源 IP,兼容反向代理场景。
*
* @param request 当前请求
* @return 来源 IP
*/
private String getClientIp(HttpServletRequest request) {
return ServletUtils.getClientIP(request);
}
}
@@ -0,0 +1,157 @@
package com.starsee.starapi.common.security;
import com.starsee.starapi.common.core.utils.SpringUtils;
import com.starsee.starapi.common.security.config.properties.InternalApiProperties;
import com.starsee.starapi.common.security.interceptor.InternalApiInterceptor;
import com.starsee.starapi.internal.api.constant.InternalApiConstants;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import tools.jackson.databind.json.JsonMapper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 内网接口鉴权拦截器单元测试,覆盖令牌校验、fail-closed、开关关闭与 IP 白名单场景
*
* @author x6g2
*/
@DisplayName("common-security 内网接口鉴权单元测试")
class InternalApiInterceptorTest {
/**
* 网关调用 admin 扣费接口的请求路径
*/
private static final String REQUEST_PATH = "/internal/billing/deduct";
/**
* 测试用内网令牌
*/
private static final String INTERNAL_TOKEN = "starapi-internal-token";
/**
* 在调用拦截器前初始化 JsonUtils 依赖的全局 JsonMapper,避免启动完整 Spring 容器。
*/
@BeforeAll
static void initializeJsonMapper() {
GenericApplicationContext context = new GenericApplicationContext();
context.registerBean(JsonMapper.class, () -> JsonMapper.builder().build());
context.refresh();
new SpringUtils().setApplicationContext(context);
}
/**
* 验证令牌正确且未配置 IP 白名单时放行内网请求。
*/
@Test
@DisplayName("令牌正确时放行内网请求")
void shouldPassWithCorrectToken() {
InternalApiInterceptor interceptor = new InternalApiInterceptor(properties(INTERNAL_TOKEN));
MockHttpServletRequest request = request(INTERNAL_TOKEN);
assertTrue(interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
}
/**
* 验证令牌错误时返回 401,且响应体业务码同为 401。
*
* @throws Exception 读取响应内容时可能抛出编码异常
*/
@Test
@DisplayName("令牌错误时返回未授权响应")
void shouldRejectWithWrongToken() throws Exception {
InternalApiInterceptor interceptor = new InternalApiInterceptor(properties(INTERNAL_TOKEN));
MockHttpServletResponse response = new MockHttpServletResponse();
assertFalse(interceptor.preHandle(request("wrong-token"), response, new Object()));
assertEquals(401, response.getStatus());
assertTrue(response.getContentAsString().contains("\"code\":401"));
}
/**
* 验证令牌未配置时按 fail-closed 策略拒绝所有内网请求。
*/
@Test
@DisplayName("令牌未配置时拒绝内网请求")
void shouldRejectWhenTokenNotConfigured() {
InternalApiInterceptor interceptor = new InternalApiInterceptor(properties(null));
MockHttpServletResponse response = new MockHttpServletResponse();
assertFalse(interceptor.preHandle(request(INTERNAL_TOKEN), response, new Object()));
assertEquals(401, response.getStatus());
}
/**
* 验证关闭内网接口鉴权后直接放行请求。
*/
@Test
@DisplayName("关闭鉴权后放行内网请求")
void shouldPassWhenDisabled() {
InternalApiProperties properties = properties(INTERNAL_TOKEN);
properties.setEnabled(false);
InternalApiInterceptor interceptor = new InternalApiInterceptor(properties);
assertTrue(interceptor.preHandle(request("wrong-token"), new MockHttpServletResponse(), new Object()));
}
/**
* 验证来源 IP 不在白名单内时拒绝内网请求。
*/
@Test
@DisplayName("来源IP不在白名单时拒绝内网请求")
void shouldRejectWhenIpNotInWhiteList() {
InternalApiInterceptor interceptor = new InternalApiInterceptor(properties(INTERNAL_TOKEN, "10.0.0.0/24"));
MockHttpServletResponse response = new MockHttpServletResponse();
MockHttpServletRequest request = request(INTERNAL_TOKEN);
request.addHeader("X-Forwarded-For", "192.168.1.10");
assertFalse(interceptor.preHandle(request, response, new Object()));
assertEquals(401, response.getStatus());
}
/**
* 验证来源 IP 命中白名单时放行内网请求。
*/
@Test
@DisplayName("来源IP命中白名单时放行内网请求")
void shouldPassWhenIpInWhiteList() {
InternalApiInterceptor interceptor = new InternalApiInterceptor(properties(INTERNAL_TOKEN, "10.0.0.0/24"));
MockHttpServletRequest request = request(INTERNAL_TOKEN);
request.addHeader("X-Forwarded-For", "10.0.0.8");
assertTrue(interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
}
/**
* 构建内网接口鉴权配置。
*
* @param token 内网令牌
* @param ipWhiteList 来源 IP 白名单
* @return 内网接口鉴权配置
*/
private static InternalApiProperties properties(String token, String... ipWhiteList) {
InternalApiProperties properties = new InternalApiProperties();
properties.setToken(token);
properties.setIpWhiteList(ipWhiteList);
return properties;
}
/**
* 构建带内网令牌请求头的扣费接口请求。
*
* @param token 内网令牌
* @return 扣费接口请求
*/
private static MockHttpServletRequest request(String token) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", REQUEST_PATH);
request.setRequestURI(REQUEST_PATH);
request.addHeader(InternalApiConstants.INTERNAL_TOKEN_HEADER, token);
return request;
}
}
+27
View File
@@ -0,0 +1,27 @@
# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/
FROM bellsoft/liberica-openjdk-rocky:21.0.12-cds
# FROM bellsoft/liberica-openjdk-rocky:25.0.4-cds
# FROM findepi/graalvm:java21-native
LABEL maintainer="Lion Li"
RUN mkdir -p /starapi/gateway/logs \
/starapi/gateway/temp
# 如需接入 skywalking 探针:mkdir -p /starapi/skywalking/agent 并挂载探针目录
WORKDIR /starapi/gateway
ENV SERVER_PORT=8082 LANG=C.UTF-8 LC_ALL=C.UTF-8 JAVA_OPTS=""
EXPOSE ${SERVER_PORT}
ADD ./target/starapi-gateway.jar ./app.jar
SHELL ["/bin/bash", "-c"]
ENTRYPOINT java -Djava.security.egd=file:/dev/./urandom -Dserver.port=${SERVER_PORT} \
# 应用名称 如果想区分集群节点监控 改成不同的名称即可
#-Dskywalking.agent.service_name=starapi-gateway \
#-javaagent:/starapi/skywalking/agent/skywalking-agent.jar \
-XX:+HeapDumpOnOutOfMemoryError -XX:+UseZGC ${JAVA_OPTS} \
-jar app.jar
+339
View File
@@ -0,0 +1,339 @@
# starapi-gateway 开放接口网关
开放 API 的独立调用入口:外部调用方只访问网关,网关完成鉴权、限流、幂等、转发、扣费与调用日志采集。
网关是独立 Spring Boot 应用(Jetty 容器),**不连接业务库**,路由/凭证/授权全部来自 Redis 缓存,
扣费与调用日志通过内网 HTTP 回传给 `starapi-admin`。
## 一、模块定位与调用链
```
外部调用方
│ /api/v1/**(X-App-Key / X-Timestamp / X-Nonce / X-Signature)
▼
┌──────────────────────────────────────────────┐
│ starapi-gateway (8082) │
│ 1. route : openapi:route:path 匹配 → openapi:route
│ 2. 接口维护中拦截 │
│ 3. 应用凭证 → 应用状态 → 开发者账号状态 │
│ 4. IP 白名单 │
│ 5. 时间戳偏移 → nonce 防重放 → 签名校验 │
│ 6. 接口授权(openapi:app:auth:{appId}) │
│ 7. 请求ID幂等(回放 or 拒绝) │
│ 8. QPS 令牌桶 + 应用日调用限额 │
│ 9. 转发上游(api_info.upstream_url) │
│ 10. 上游成功(2xx)后扣费 → 回写响应 │
│ 11. 异步写调用日志 │
└───────┬──────────────────────────┬────────────┘
│ Redis 只读缓存 │ 内网 HTTP(X-Internal-Token)
▼ ▼
openapi 发布的缓存 starapi-admin (8080)
/internal/billing/deduct
/internal/calllog/record
```
- 入口路径:`gateway.include-paths`(默认仅 `/api/v1/**`)匹配的请求才进入网关流程,其余请求直接交给后续过滤器与 MVC。
- 过滤器链:链路按阶段拆成 8 个 `OncePerRequestFilter`,由类上的 `@Order` 注解排序(顺序常量见 `GatewayConstants.FILTER_ORDER_*`,彼此间隔 10),任一阶段失败即写出统一错误响应并结束链路:
| 顺序 | 过滤器 | 职责 |
|---|---|---|
| 100 | `OpenApiContextFilter` | 建立请求上下文(requestId/方法/规范化路径/IP/请求体/开始时间);位于最外层,结束后统一记录调用日志 |
| 110 | `OpenApiRouteFilter` | 路由缓存匹配、接口维护中拦截 |
| 120 | `OpenApiCredentialFilter` | 应用凭证、应用状态、开发者账号状态、IP 白名单 |
| 130 | `OpenApiSignFilter` | 时间戳偏移、nonce 防重放、HMAC-SHA256 签名校验 |
| 140 | `OpenApiAuthFilter` | 应用对接口的授权校验 |
| 150 | `OpenApiIdempotencyFilter` | 请求ID幂等:命中缓存回放、并发请求抢占处理锁 |
| 160 | `OpenApiRateLimitFilter` | QPS 令牌桶、应用日调用限额 |
| 170 | `OpenApiForwardFilter` | 转发上游、2xx 后扣费、回写响应、缓存成功响应 |
- 公共逻辑(路径判定、上下文读取、统一错误响应、异常信息整理)收敛在 `AbstractOpenApiFilter`,各阶段过滤器只实现自身校验。
- 网关错误统一返回 JSON:`{"code":业务码,"msg":"提示","requestId":"链路ID","timestamp":毫秒时间戳}`。
- 请求ID(`X-Request-Id`)同时是幂等键、扣费幂等键与调用日志主键;调用方不传或格式非法(超过 64 字符、含字母数字下划线中划线以外的字符)时由网关生成 32 位无横线 UUID。
## 二、请求头约定
| 请求头 | 必填 | 说明 |
|---|---|---|
| `X-App-Key` | 是 | 应用 AppKey,用于读取应用凭证缓存;缺失或未命中返回 `40101` |
| `X-Timestamp` | 是 | 毫秒级时间戳字符串,默认允许与服务器时间相差 ±300 秒,超出返回 `40102` |
| `X-Nonce` | 是 | 请求随机串(建议 16~32 位),同一 AppKey 下只允许使用一次,重复返回 `40103` |
| `X-Signature` | 是 | 签名,64 位**小写**十六进制 HMAC-SHA256,见第三节;缺失或校验失败返回 `40104` |
| `X-Request-Id` | 否 | 链路追踪ID;缺省由网关生成,用于幂等、扣费与日志关联 |
| `Content-Type` | 按接口 | 转发时原样传给上游;请求体按原始字节参与签名,GET 等空请求体按空数组处理 |
网关在**签名校验通过、开始转发**时还会向上游追加以下请求头,调用方无需也不能伪造:
| 转发头 | 说明 |
|---|---|
| `X-Request-Id` | 本次调用的链路追踪ID(调用方未传时为网关生成值) |
| `X-Gateway-App-Id` | 调用方应用ID |
| `X-Gateway-User-Id` | 调用方所属开发者用户ID |
| `X-Gateway-Client-Ip` | 网关解析出的调用方真实IP |
鉴权头与 HTTP 逐跳头(`X-App-Key`、`X-Timestamp`、`X-Nonce`、`X-Signature`、`Host`、`Content-Length`、`Connection` 等)**不会**透传给上游。
## 三、签名算法
签名实现见 `com.starsee.starapi.gateway.util.OpenApiSignUtils`,算法固定、区分大小写,调用方必须完全一致。
### 3.1 签名串
```
signContent = appKey + "\n"
+ timestamp + "\n"
+ nonce + "\n"
+ METHOD + "\n"
+ path + "\n"
+ md5Hex(body)
signature = hex( HMAC-SHA256( key = appSecret, data = signContent ) ) // 小写十六进制
```
| 段 | 规则 |
|---|---|
| `appKey` | 请求头 `X-App-Key` 原值 |
| `timestamp` | 请求头 `X-Timestamp` 原值(毫秒时间戳字符串,不重新格式化) |
| `nonce` | 请求头 `X-Nonce` 原值 |
| `METHOD` | HTTP 方法,**统一大写**(`get` → `GET`) |
| `path` | 请求路径,**不含查询参数**;以 `/` 开头,末尾多余的 `/` 会被去掉(根路径 `/` 保留);即 `/api/v1/hello?x=1` 参与签名的是 `/api/v1/hello`。为兼容按原始 URI 签名的调用方,网关会先用规范化路径校验,失败后再用保留尾斜杠的原始路径校验一次 |
| `body` | 原始请求体字节,空请求体按空数组计算;空数组的 MD5 为 `d41d8cd98f00b204e9800998ecf8427e` |
补充规则:
- `appSecret` 作为 HMAC 密钥,按 UTF-8 编码;签名结果按 **小写十六进制** 传输,大小写不匹配视为失败。
- 各段之间只用一个 `\n` 分隔,末段(MD5)之后**没有**换行。
- 网关先校验时间戳、再占用 nonce、最后校验签名;**签名失败时 nonce 已被占用**,用同一 nonce 重试会得到 `40103`,重试请更换 nonce。
- 签名开关 `gateway.sign.enabled=false` 时整段校验(时间戳/nonce/签名)全部跳过,仅用于临时排查。
### 3.2 curl + openssl 示例
```bash
export APP_KEY='your_app_key'
export APP_SECRET='your_app_secret'
export METHOD='POST'
export API_PATH='/api/v1/example/hello'
export BODY='{"name":"starapi"}'
TS=$(date +%s%3N) # 毫秒时间戳
NONCE=$(openssl rand -hex 16)
BODY_MD5=$(printf '%s' "$BODY" | openssl dgst -md5 -r | cut -d' ' -f1)
SIGN_CONTENT="${APP_KEY}
${TS}
${NONCE}
${METHOD}
${API_PATH}
${BODY_MD5}"
SIGNATURE=$(printf '%s' "$SIGN_CONTENT" | openssl dgst -sha256 -hmac "$APP_SECRET" -r | cut -d' ' -f1)
curl -sS -X "$METHOD" "http://127.0.0.1:8082${API_PATH}" \
-H 'Content-Type: application/json' \
-H "X-App-Key: ${APP_KEY}" \
-H "X-Timestamp: ${TS}" \
-H "X-Nonce: ${NONCE}" \
-H "X-Signature: ${SIGNATURE}" \
-H "X-Request-Id: $(openssl rand -hex 16)" \
--data-binary "$BODY"
```
> `printf '%s' "$SIGN_CONTENT"` 会完整保留换行;签名串必须严格是 6 段、5 个 `\n`。
### 3.3 Java(Hutool HMac)示例
网关与调用方使用同一套 Hutool API,可直接对照 `OpenApiSignUtils#sign`:
```java
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.UUID;
String appKey = "your_app_key";
String appSecret = "your_app_secret";
String method = "POST";
String path = "/api/v1/example/hello";
byte[] body = "{\"name\":\"starapi\"}".getBytes(StandardCharsets.UTF_8);
String timestamp = String.valueOf(System.currentTimeMillis()); // 毫秒
String nonce = UUID.randomUUID().toString().replace("-", "");
String signContent = appKey + "\n"
+ timestamp + "\n"
+ nonce + "\n"
+ method.toUpperCase(Locale.ROOT) + "\n"
+ path + "\n"
+ DigestUtil.md5Hex(body);
HMac hmac = new HMac(HmacAlgorithm.HmacSHA256, appSecret.getBytes(StandardCharsets.UTF_8));
String signature = hmac.digestHex(signContent); // 小写十六进制
```
## 四、错误码
业务码与 HTTP 状态码同时返回,来源 `GatewayErrorCode`。
| 业务码 | HTTP | 枚举 | 含义 |
|---|---|---|---|
| 40101 | 401 | `APP_CREDENTIAL_INVALID` | 应用凭证无效,请检查AppKey |
| 40102 | 401 | `TIMESTAMP_INVALID` | 请求时间戳无效或已超出允许的时间偏移 |
| 40103 | 401 | `NONCE_REPLAYED` | 请求nonce已被使用,请勿重复提交 |
| 40104 | 401 | `SIGNATURE_INVALID` | 请求签名校验未通过(缺参数时提示“请求缺少签名参数”) |
| 40301 | 403 | `APP_DISABLED` | 应用已被禁用 |
| 40302 | 403 | `USER_DISABLED` | 开发者账号已被停用或冻结 |
| 40303 | 403 | `IP_NOT_ALLOWED` | 调用来源IP不在应用白名单内 |
| 40304 | 403 | `API_NOT_AUTHORIZED` | 应用未开通该接口 |
| 40401 | 404 | `ROUTE_NOT_FOUND` | 开放接口不存在或已下线 |
| 40901 | 409 | `REQUEST_DUPLICATE` | 相同的请求ID正在处理或已完成,请勿重复提交 |
| 42901 | 429 | `RATE_LIMITED` | 调用频率超过接口QPS限制 |
| 42902 | 429 | `DAILY_LIMIT_EXCEEDED` | 调用次数已超过应用日调用限额 |
| 50001 | 500 | `GATEWAY_ERROR` | 网关内部错误 |
| 50201 | 502 | `UPSTREAM_ERROR` | 上游服务调用失败 |
| 50301 | 503 | `API_MAINTAIN` | 开放接口维护中,请稍后重试 |
| 50401 | 504 | `UPSTREAM_TIMEOUT` | 上游服务响应超时 |
共 16 个业务码,全部来自 `GatewayErrorCode`。按 HTTP 状态可做通用重试判断,按业务码可精确定位原因。
## 五、配置说明
配置前缀 `gateway`,对应 `GatewayProperties`;`application.yml` 已给出默认值,`application-dev.yml` / `application-prod.yml` 只覆盖 Redis 与 admin 地址。
| 配置项 | 默认值 | 说明 |
|---|---|---|
| `gateway.enabled` | `true` | 网关过滤器总开关;关闭后 `/api/v1/**` 不再进入网关流程 |
| `gateway.include-paths` | `/api/v1/**` | 进入网关鉴权与转发的路径匹配规则(Spring `PathPattern`) |
| `gateway.sign.enabled` | `true` | 时间戳/nonce/签名校验开关 |
| `gateway.sign.timestamp-skew-seconds` | `300` | 时间戳允许偏移(秒) |
| `gateway.sign.nonce-ttl-seconds` | `300` | nonce 防重放缓存时长(秒),应不小于时间戳偏移 |
| `gateway.rate-limit.enabled` | `true` | QPS 限流开关 |
| `gateway.rate-limit.qps-interval-seconds` | `1` | 令牌桶统计窗口(秒),窗口内允许请求数即 QPS 上限 |
| `gateway.rate-limit.daily-limit-enabled` | `true` | 应用日调用限额开关 |
| `gateway.idempotent.enabled` | `true` | 请求ID幂等开关 |
| `gateway.idempotent.lock-ttl-seconds` | `60` | “处理中”锁时长(秒) |
| `gateway.idempotent.response-ttl-seconds` | `600` | 成功响应回放缓存时长(秒) |
| `gateway.idempotent.max-response-cache-bytes` | `262144` | 可回放响应体上限(256KB),超出只去重不回放 |
| `gateway.internal.admin-base-url` | `http://127.0.0.1:8080` | admin 内网访问地址 |
| `gateway.internal.token` | 空(`application.yml` 取 `${INTERNAL_API_TOKEN:starapi-internal-token}`) | 内网令牌,必须与 admin 的 `internal.api.token` 一致 |
| `gateway.internal.connect-timeout-ms` | `2000` | 内网接口连接超时(毫秒) |
| `gateway.internal.read-timeout-ms` | `5000` | 内网接口读超时(毫秒) |
| `gateway.internal.deduct-path` | `/internal/billing/deduct` | 扣费接口路径(取 `InternalApiConstants`) |
| `gateway.internal.call-log-path` | `/internal/calllog/record` | 调用日志接口路径(取 `InternalApiConstants`) |
| `gateway.internal.call-log-pool-size` | `2` | 调用日志异步线程数(最大线程数不小于 4) |
| `gateway.internal.call-log-queue-capacity` | `1024` | 调用日志异步队列容量,队列满由调用线程兜底执行 |
| `gateway.forward.connect-timeout-ms` | `2000` | 上游连接超时(毫秒);读超时按接口 `api_info.timeout_ms`,未配置时默认 5000ms |
限流阈值不在网关配置里:QPS 取「应用 `api_app.qps_limit`(默认 50)」与「接口 `api_info.qps_limit`(默认 100)」的较小值;日限额取应用 `api_app.daily_call_limit`,未配置(≤0)表示不限。
## 六、缓存与数据来源
网关**只读** Redis 缓存、不连业务库。缓存由 openapi 模块的 `ApiRouteCacheServiceImpl` 在接口/应用/授权变更时发布,必要时可触发全量刷新。
### 6.1 业务缓存(openapi 模块发布,网关只读)
| 缓存键 | 结构 | 内容 | 发布方 |
|---|---|---|---|
| `openapi:route` | Hash | `apiId(字符串) → ApiRouteDTO`(上游地址、超时、计费方式、价格、免费次数、QPS、状态等) | `ApiRouteCacheServiceImpl#publishRoute` |
| `openapi:route:path` | Hash | `METHOD:path → apiId(字符串)`;`METHOD` 为 `GET/POST/...` 或 `ALL` | 同上 |
| `openapi:app:credential` | Hash | `appKey → AppCredentialDTO`(appId、userId、appSecret、IP白名单、QPS/日限额、应用状态、开发者账号状态) | `ApiRouteCacheServiceImpl#doRefreshAppCredential` |
| `openapi:app:auth:{appId}` | Hash | `apiId(字符串) → AppAuthDTO`(授权状态、剩余免费次数) | `ApiRouteCacheServiceImpl#writeAppAuthCache` |
路由匹配顺序:先按请求方法精确匹配 `openapi:route:path`,未命中再回退 `ALL:path`;路径先归一化(去掉查询参数、去掉末尾 `/`)。
接口状态为「维护中(2)」也会进入路径索引,由网关在命中路由后返回 `50301`;已下线接口不在索引中,表现为 `40401`。
状态取值(与生产端一致):应用状态 `0` 正常、开发者账号状态 `0` 正常、接口状态 `2` 维护中、授权状态 `0` 已开通。
### 6.2 网关自身运行缓存(网关读写的限流/幂等键)
| 缓存键 | 类型 | 用途 | 过期 |
|---|---|---|---|
| `gateway:nonce:{appKey}:{nonce}` | String(setIfAbsent) | nonce 防重放占用 | `gateway.sign.nonce-ttl-seconds` |
| `gateway:qps:{appId}:{apiId}:{qps}` | Redisson 令牌桶(OVERALL) | 应用+接口维度 QPS 限流 | 3600 秒 |
| `gateway:daily:{appId}:{yyyyMMdd}` | AtomicLong | 应用日调用计数 | 首次自增起 26 小时 |
| `gateway:idempotent:lock:{requestId}` | String `1`(setIfAbsent) | 相同请求ID的处理中锁 | `gateway.idempotent.lock-ttl-seconds` |
| `gateway:idempotent:response:{requestId}` | 对象 | 首次成功响应,用于幂等回放 | `gateway.idempotent.response-ttl-seconds` |
## 七、计费与调用日志
- **扣费时机**:上游返回 **2xx** 时,在回写调用方响应之前调用 admin `POST /internal/billing/deduct`,携带 `appId`、`userId`、`apiId`、`chargeType`、`pricePerCall`,并以 `requestId` 作为幂等键;admin 侧同一 `requestId` 不会重复扣费。
- **扣费失败不影响响应**:内网调用异常只记录 error 日志、写入 `error_msg`,并在响应头追加 `X-Gateway-Warning: charge-failed`,已拿到的上游结果照常返回;上游非 2xx 时不做扣费。
- **调用日志**:请求结束后异步调用 `POST /internal/calllog/record`(线程池 `gatewayCallLogExecutor`,`@Async`),记录 `requestId`、`appId`、`userId`、`apiId`、请求/响应时间、耗时、HTTP 状态、上游业务码 `code`、客户端 IP、是否计费、扣费方式/次数/金额、余额、错误信息;缺路由或凭证信息时跳过(admin 侧主键三元组校验要求齐全)。
- **失败也记日志**:所有拒绝分支(鉴权、限流、上游失败等)都会写调用日志,`error_msg` 按 `api_call_log.error_msg` 长度(512)截断。
- **幂等回放响应头**:命中首次成功响应回放时返回 `X-Gateway-Replay: true`;此时不会再扣费、不会再调用上游。
- **计数时机**:QPS/日限额在转发上游**之前**占用,因此上游失败(502/504)也会计入调用量与 QPS。
## 八、部署与本地启动
### 8.1 关键参数
| 项 | 值 |
|---|---|
| 进程端口 | `8082`(`server.port`,Docker 中由 `SERVER_PORT` 覆盖) |
| 应用名 | `StarAPI-Gateway` |
| 上下文路径 | `/` |
| 构建产物 | `starapi-gateway/target/starapi-gateway.jar` |
| profile | 由 Maven profile `@profiles.active@` 注入,默认 `dev`(`local` 无对应网关配置文件,需显式指定 `dev`/`prod`) |
| 内网令牌环境变量 | `INTERNAL_API_TOKEN`,默认 `starapi-internal-token` |
### 8.2 admin 侧配套要求
网关依赖 admin 的内网接口,admin 的 `application.yml` 必须满足(当前仓库已配置):
| 配置 | 值 | 说明 |
|---|---|---|
| `security.excludes` | 包含 `/internal/**` | 内网接口放行 Sa-Token 登录校验,改由内网令牌校验 |
| `internal.api.enabled` | `true` | 内网接口鉴权开关 |
| `internal.api.token` | `${INTERNAL_API_TOKEN:starapi-internal-token}` | **必须与网关 `gateway.internal.token` 完全一致**,否则扣费/日志全部失败 |
| `internal.api.ip-white-list` | `[]` | 允许来源 IP,空表示不限制 |
| `xss.excludeUrls` | 包含 `/internal/**` | 内网 JSON 调用不做 XSS 清洗 |
内网接口:`POST /internal/billing/deduct`、`POST /internal/calllog/record`,请求头 `X-Internal-Token`。
### 8.3 本地启动
```bash
# 1. 打包(网关依赖 starapi-api 与 common 模块,首次需一起构建)
mvn -pl starapi-gateway -am clean package -Pdev
# 2. 启动(默认 profile=dev,需能连到 dev 环境 Redis)
INTERNAL_API_TOKEN=starapi-internal-token \
java -jar starapi-gateway/target/starapi-gateway.jar --server.port=8082
# 3. 健康检查
curl -s http://127.0.0.1:8082/actuator/health
```
本地没有 admin 时网关仍可转发上游,但扣费与调用日志会失败(不影响响应,仅 `X-Gateway-Warning: charge-failed` 与 error 日志)。
启动前可用 openapi 管理端触发一次“全量刷新网关缓存”,否则路由/凭证缓存为空,请求会直接返回 `40101` / `40401`。
### 8.4 Docker 与 nginx
- 镜像:`starapi/starapi-gateway:6.0.0`;`Dockerfile` 构建上下文为 `starapi-gateway/`(`ADD ./target/starapi-gateway.jar ./app.jar`),工作目录 `/starapi/gateway`,日志写 `/starapi/gateway/logs`(logback `./logs`),`EXPOSE 8082`。
- IDEA 已提供 `.run/starapi-gateway.run.xml`(dockerfile 构建,仅构建镜像)。
- `script/docker/docker-compose.yml` 新增 `starapi-gateway` 服务:`network_mode: "host"`、`TZ=Asia/Shanghai`、`SERVER_PORT=8082`、日志卷 `/docker/gateway/logs/:/starapi/gateway/logs/`、`privileged: true`,位于 `starapi-server2` 之后。
```bash
# 构建镜像(在仓库根目录执行)
docker build -t starapi/starapi-gateway:6.0.0 starapi-gateway
# 只启动/更新网关
docker compose -f script/docker/docker-compose.yml up -d starapi-gateway
```
- `script/docker/nginx/conf/nginx.conf` 已增加 `upstream gateway { server 127.0.0.1:8082; }` 与 `location /api/v1/` 反代:
- `proxy_pass http://gateway;` 不带 URI,**保留 `/api/v1` 前缀**;
- `proxy_read_timeout 300s`、`proxy_buffering off`;
- location 内不改写、不丢弃 `X-App-Key`、`X-Timestamp`、`X-Nonce`、`X-Signature`、`X-Request-Id`,nginx 默认原样透传客户端请求头,签名依赖这些头;
- 已设置 `Host`、`X-Real-IP`、`X-Forwarded-For`,网关据此解析真实客户端 IP。
## 九、已知限制与后续
- **未引入 Spring Cloud Gateway**:仓库无 spring-cloud 依赖栈,网关基于 Spring Boot Web + Jetty + `RestClient` 自行实现转发,保持与既有技术栈一致;上游响应体当前整体读入内存再回写(非流式),大文件下载/SSE 场景需要后续改造。
- **调用日志暂落 MySQL 单表**(`api_call_log` 不分表、不入 Elasticsearch),日志量上来后再做归档/分表/入 ES。
- **限流基于 Redis 令牌桶**,按「应用 + 接口 + 有效阈值」维度;键中包含有效 QPS,调整配置后自动切换新桶,多实例共享同一份计数。
- **网关不直连业务库**,路由/凭证/授权强依赖 openapi 侧缓存刷新;缓存未刷新或发布失败时只能返回 `40101`/`40401`,需在 openapi 管理端重新触发刷新。
- **幂等只缓存 2xx 成功响应**,失败响应不缓存;同一 `requestId` 并发时只有一个请求执行,其余返回 `40901`。
- **幂等回放在验签与授权之后命中**:相同 `requestId` 的重复请求会先完成凭证、IP、签名与接口授权校验,命中首次成功响应后直接回放(返回 `X-Gateway-Replay: true`),不再占用 QPS、不再扣费、不再调用上游;处理中的并发重复请求返回 `40901`。
- **QPS/日限额先占后转发**,上游失败也计入调用量;如需“仅成功调用计数”,需要把限流挪到转发成功之后并调整令牌桶语义。
- **网关不承载管理页面与后台接口**,运营侧能力全部在 admin(openapi / billing / calllog)。
+96
View File
@@ -0,0 +1,96 @@
<?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">
<parent>
<artifactId>starapi</artifactId>
<groupId>com.starsee.starapi</groupId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<packaging>jar</packaging>
<artifactId>starapi-gateway</artifactId>
<description>
开放接口网关:路由匹配、签名鉴权、限流、幂等、转发与调用日志采集,独立应用(默认 8082)
</description>
<dependencies>
<!-- 核心模块 -->
<dependency>
<groupId>com.starsee.starapi</groupId>
<artifactId>starapi-common-core</artifactId>
</dependency>
<!-- 序列化模块 -->
<dependency>
<groupId>com.starsee.starapi</groupId>
<artifactId>starapi-common-json</artifactId>
</dependency>
<!-- 缓存服务:网关路由与应用授权的唯一数据来源 -->
<dependency>
<groupId>com.starsee.starapi</groupId>
<artifactId>starapi-common-redis</artifactId>
</dependency>
<!-- api模块:路由/凭证/授权/计费/日志契约DTO -->
<dependency>
<groupId>com.starsee.starapi</groupId>
<artifactId>starapi-api</artifactId>
</dependency>
<!-- SpringBoot Web容器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-tomcat</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- web 容器使用 jetty -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
<!-- 监控端点 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Hutool 加密工具:签名与摘要 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-crypto</artifactId>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>${maven-jar-plugin.version}</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,37 @@
package com.starsee.starapi.gateway;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* 开放接口网关启动类
* <p>
* 网关是独立部署的开放调用入口,不直连业务库:路由、应用凭证与授权信息全部从 Redis 读取,
* 扣费与调用日志通过内网接口回传 admin。
*
* @author x6g2
*/
@Slf4j
@EnableAsync
@ConfigurationPropertiesScan
@SpringBootApplication
public class StarApiGatewayApplication {
/**
* 网关启动入口
*
* @param args 启动参数
*/
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(StarApiGatewayApplication.class, args);
Environment environment = context.getEnvironment();
log.info("StarAPI 开放接口网关启动成功,端口={},环境={}",
environment.getProperty("server.port"), environment.getProperty("spring.profiles.active"));
}
}
@@ -0,0 +1,56 @@
package com.starsee.starapi.gateway.client;
import com.starsee.starapi.billing.api.domain.DeductRequestDTO;
import com.starsee.starapi.billing.api.domain.DeductResultDTO;
import com.starsee.starapi.common.core.domain.R;
import com.starsee.starapi.gateway.config.GatewayProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import tools.jackson.core.type.TypeReference;
/**
* 计费扣费客户端
* <p>
* 网关转发上游成功后按次调用 admin 内网扣费接口,扣费成功后由网关异步写入调用日志。
* 扣费请求以 {@code requestId} 作为幂等键,同一链路重复调用不会重复扣费;
* 扣费结果只用于日志与告警,不影响已经返回给调用方的上游响应。
*
* @author x6g2
*/
@Component
@RequiredArgsConstructor
public class BillingClient {
/**
* 内网接口客户端
*/
private final InternalApiClient internalApiClient;
/**
* 网关配置属性
*/
private final GatewayProperties properties;
/**
* 按次扣费
* <p>
* 扣费请求中的 {@code requestId} 即网关链路追踪ID,admin 侧据此幂等;
* 免费接口或无需计费时 admin 返回未计费结果,此处不额外判断。
*
* @param request 扣费请求
* @return 扣费结果,接口未返回数据时返回 {@code null}
* @throws InternalApiException 内网扣费接口调用失败时抛出
*/
public DeductResultDTO deduct(DeductRequestDTO request) {
R<DeductResultDTO> result = internalApiClient.post(
properties.getInternal().getDeductPath(),
request,
new TypeReference<R<DeductResultDTO>>() {
});
if (result == null) {
return null;
}
return result.getData();
}
}
@@ -0,0 +1,51 @@
package com.starsee.starapi.gateway.client;
import com.starsee.starapi.calllog.api.domain.CallLogDTO;
import com.starsee.starapi.gateway.config.GatewayProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
/**
* 调用日志客户端
* <p>
* 请求结束后由网关异步回传调用日志到 admin。写日志属于旁路操作,失败不能影响调用方
* 已经拿到的上游响应,因此这里捕获全部异常只记录错误日志,不向调用方抛出。
* <p>
* {@link Async} 依赖 Spring 代理生效,调用方必须注入本 bean(而非自行 new),网关过滤器直接注入满足该条件。
*
* @author x6g2
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CallLogClient {
/**
* 内网接口客户端
*/
private final InternalApiClient internalApiClient;
/**
* 网关配置属性
*/
private final GatewayProperties properties;
/**
* 异步写入调用日志
* <p>
* 由 {@code gatewayCallLogExecutor} 线程池执行,异常一律吞掉并记录日志,保证不影响调用方响应。
*
* @param callLog 调用日志
*/
@Async("gatewayCallLogExecutor")
public void record(CallLogDTO callLog) {
try {
internalApiClient.postVoid(properties.getInternal().getCallLogPath(), callLog);
} catch (Exception e) {
log.error("异步写入调用日志失败 requestId={}", callLog.getRequestId(), e);
}
}
}
@@ -0,0 +1,111 @@
package com.starsee.starapi.gateway.client;
import com.starsee.starapi.common.core.constant.HttpStatus;
import com.starsee.starapi.common.core.domain.R;
import com.starsee.starapi.common.json.utils.JsonUtils;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.internal.api.constant.InternalApiConstants;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import tools.jackson.core.type.TypeReference;
/**
* admin 内网接口客户端
* <p>
* 网关独立部署、不连接业务库,扣费与调用日志都通过 admin 的内网接口完成。所有内网接口统一
* 返回 {@link R} 结构,由本类负责发起 JSON 请求、校验 {@code code} 并解包 {@code data},
* 任何失败都转换成 {@link InternalApiException},避免上层过滤器处理 Spring 的 HTTP 异常细节。
*
* @author x6g2
*/
@Component
@RequiredArgsConstructor
public class InternalApiClient {
/**
* 内网接口专用 RestClient,bean 名为 internalRestClient(由网关自动配置提供)
*/
@Qualifier("internalRestClient")
private final RestClient internalRestClient;
/**
* 网关配置属性
*/
private final GatewayProperties properties;
/**
* 调用内网接口并解包 {@code R.data}
*
* @param path 内网接口路径,例如 {@code /internal/billing/deduct}
* @param request 请求体对象,为 {@code null} 时发送空 JSON 对象
* @param type 响应体的泛型类型引用,需包含 {@code R<T>} 完整泛型
* @param <T> 业务数据类型
* @return 内网接口返回体,{@code code} 一定为成功码
* @throws InternalApiException 内网接口调用失败、返回体为空或业务码非成功时抛出
*/
public <T> R<T> post(String path, Object request, TypeReference<R<T>> type) {
return execute(path, request, type);
}
/**
* 调用内网接口,仅校验业务码不关心返回数据
*
* @param path 内网接口路径,例如 {@code /internal/calllog/record}
* @param request 请求体对象,为 {@code null} 时发送空 JSON 对象
* @throws InternalApiException 内网接口调用失败、返回体为空或业务码非成功时抛出
*/
public void postVoid(String path, Object request) {
execute(path, request, new TypeReference<R<Void>>() {
});
}
/**
* 执行内网接口调用并校验返回体
* <p>
* 统一处理请求序列化、JSON 反序列化与异常转换;{@link InternalApiException} 已经是最外层语义,
* 因此直接向上抛出,不再重复包装。
*
* @param path 内网接口路径
* @param request 请求体对象
* @param type 响应体泛型类型引用
* @param <T> 业务数据类型
* @return 内网接口返回体
*/
private <T> R<T> execute(String path, Object request, TypeReference<R<T>> type) {
try {
String requestBody = JsonUtils.toJsonString(request);
RestClient.RequestBodySpec bodySpec = internalRestClient.post()
.uri(path)
.contentType(MediaType.APPLICATION_JSON);
String token = properties.getInternal().getToken();
// 令牌未配置时不发送空请求头,由 admin 侧内网拦截器按 fail-closed 拒绝,避免写出非法 HTTP 头
if (StringUtils.hasText(token)) {
bodySpec = bodySpec.header(InternalApiConstants.INTERNAL_TOKEN_HEADER, token);
}
String responseText = bodySpec.body(requestBody == null ? "{}" : requestBody)
.retrieve()
.body(String.class);
R<T> result = JsonUtils.parseObject(responseText, type);
if (result == null) {
throw new InternalApiException(path, "内网接口返回为空: " + path, null);
}
if (result.getCode() != HttpStatus.SUCCESS) {
throw new InternalApiException(path,
"内网接口返回失败, path=" + path + ", code=" + result.getCode() + ", msg=" + result.getMsg(), null);
}
return result;
} catch (InternalApiException e) {
throw e;
} catch (RestClientException e) {
throw new InternalApiException(path, "内网接口请求失败, path=" + path + ", msg=" + e.getMessage(), e);
} catch (RuntimeException e) {
throw new InternalApiException(path, "内网接口调用异常, path=" + path + ", msg=" + e.getMessage(), e);
}
}
}
@@ -0,0 +1,55 @@
package com.starsee.starapi.gateway.client;
import java.io.Serial;
/**
* 内网接口调用异常
* <p>
* 网关调用 admin 内网接口(扣费、调用日志)失败时抛出,携带失败的内网接口路径,
* 便于调用方按失败接口做差异化处理:扣费失败只记录告警,调用日志失败直接忽略。
*
* @author x6g2
*/
public class InternalApiException extends RuntimeException {
@Serial
private static final long serialVersionUID = 1L;
/**
* 失败的内网接口路径
*/
private final String path;
/**
* 构造内网接口调用异常(不含接口路径)
*
* @param message 异常信息
* @param cause 原始异常
*/
public InternalApiException(String message, Throwable cause) {
super(message, cause);
this.path = null;
}
/**
* 构造内网接口调用异常(含接口路径)
*
* @param path 失败的内网接口路径
* @param message 异常信息
* @param cause 原始异常
*/
public InternalApiException(String path, String message, Throwable cause) {
super(message, cause);
this.path = path;
}
/**
* 获取失败的内网接口路径
*
* @return 内网接口路径,未指定时为 {@code null}
*/
public String getPath() {
return path;
}
}
@@ -0,0 +1,71 @@
package com.starsee.starapi.gateway.client;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import lombok.RequiredArgsConstructor;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 上游转发客户端工厂
* <p>
* 上游接口的读超时按 {@code api_info.timeout_ms} 逐接口配置,而 Spring 的 {@link RestClient}
* 在构建后无法再修改超时,因此这里按超时时间缓存并复用客户端实例,避免每次转发都重新创建
* JDK HttpClient 与线程池。连接超时统一取 {@code gateway.forward.connect-timeout-ms}。
*
* @author x6g2
*/
@Component
@RequiredArgsConstructor
public class UpstreamClientFactory {
/**
* 上游客户端缓存,键为读超时时间(毫秒)
*/
private final Map<Integer, RestClient> clientCache = new ConcurrentHashMap<>();
/**
* 网关配置属性
*/
private final GatewayProperties properties;
/**
* 获取指定读超时的上游转发客户端
* <p>
* 相同超时时间复用同一个客户端实例;超时时间小于等于 0 时回退到
* {@link GatewayConstants#DEFAULT_TIMEOUT_MS},与接口未配置超时时的默认语义一致。
*
* @param timeoutMs 读超时时间(毫秒)
* @return 上游转发客户端
*/
public RestClient getClient(int timeoutMs) {
int effectiveTimeoutMs = timeoutMs <= 0 ? GatewayConstants.DEFAULT_TIMEOUT_MS : timeoutMs;
return clientCache.computeIfAbsent(effectiveTimeoutMs, this::createClient);
}
/**
* 按读超时创建上游转发客户端
* <p>
* 上游重定向一律不跟随({@link HttpClient.Redirect#NEVER}),避免上游的 3xx 响应被网关静默消费,
* 保证调用方拿到上游的原始状态码。
*
* @param timeoutMs 读超时时间(毫秒)
* @return 新建的上游转发客户端
*/
private RestClient createClient(int timeoutMs) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(properties.getForward().getConnectTimeoutMs()))
.followRedirects(HttpClient.Redirect.NEVER)
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofMillis(timeoutMs));
return RestClient.builder().requestFactory(requestFactory).build();
}
}
@@ -0,0 +1,73 @@
package com.starsee.starapi.gateway.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 网关基础 Bean 配置
* <p>
* 负责内网调用客户端与调用日志异步线程池等与业务无关的基础设施装配。
*
* @author x6g2
*/
@Slf4j
@Configuration
public class GatewayBeansConfig {
/**
* admin 内网接口调用客户端
* <p>
* 扣费与调用日志走内网 HTTP,连接与读超时取 {@code gateway.internal} 配置,避免上游抖动占满转发线程。
*
* @param properties 网关配置
* @return 内网接口调用客户端
*/
@Bean("internalRestClient")
public RestClient internalRestClient(GatewayProperties properties) {
GatewayProperties.Internal internal = properties.getInternal();
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(internal.getConnectTimeoutMs()))
.followRedirects(HttpClient.Redirect.NEVER)
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofMillis(internal.getReadTimeoutMs()));
return RestClient.builder()
.baseUrl(internal.getAdminBaseUrl())
.requestFactory(requestFactory)
.build();
}
/**
* 调用日志异步线程池
* <p>
* 队列满时由调用线程兜底执行,宁可略微增加请求耗时也不能丢失日志。
*
* @param properties 网关配置
* @return 调用日志线程池
*/
@Bean("gatewayCallLogExecutor")
public ThreadPoolTaskExecutor gatewayCallLogExecutor(GatewayProperties properties) {
GatewayProperties.Internal internal = properties.getInternal();
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(internal.getCallLogPoolSize());
executor.setMaxPoolSize(Math.max(internal.getCallLogPoolSize(), 4));
executor.setQueueCapacity(internal.getCallLogQueueCapacity());
executor.setThreadNamePrefix("gateway-calllog-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(10);
executor.initialize();
log.info("调用日志异步线程池初始化完成,core={} max={} queue={}",
executor.getCorePoolSize(), executor.getMaxPoolSize(), internal.getCallLogQueueCapacity());
return executor;
}
}
@@ -0,0 +1,186 @@
package com.starsee.starapi.gateway.config;
import com.starsee.starapi.internal.api.constant.InternalApiConstants;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 网关配置属性
*
* @author x6g2
*/
@Data
@ConfigurationProperties(prefix = "gateway")
public class GatewayProperties {
/**
* 是否启用网关过滤器
*/
private boolean enabled = true;
/**
* 开放接口路径匹配规则,仅这些路径进入网关鉴权与转发流程
*/
private String[] includePaths = {"/api/v1/**"};
/**
* 签名校验配置
*/
private Sign sign = new Sign();
/**
* 限流配置
*/
private RateLimit rateLimit = new RateLimit();
/**
* 幂等配置
*/
private Idempotent idempotent = new Idempotent();
/**
* 调用 admin 内网接口的配置
*/
private Internal internal = new Internal();
/**
* 上游转发配置
*/
private Forward forward = new Forward();
/**
* 签名校验配置
*/
@Data
public static class Sign {
/**
* 是否校验签名
*/
private boolean enabled = true;
/**
* 请求时间戳允许的偏移时间(秒)
*/
private long timestampSkewSeconds = 300L;
/**
* nonce 防重放缓存时长(秒),应不小于时间戳偏移时间
*/
private long nonceTtlSeconds = 300L;
}
/**
* 限流配置
*/
@Data
public static class RateLimit {
/**
* 是否启用限流
*/
private boolean enabled = true;
/**
* QPS 令牌桶统计窗口(秒),固定窗口内允许的请求数即为 QPS 上限
*/
private int qpsIntervalSeconds = 1;
/**
* 是否启用应用日调用限额
*/
private boolean dailyLimitEnabled = true;
}
/**
* 幂等配置
*/
@Data
public static class Idempotent {
/**
* 是否启用请求ID幂等
*/
private boolean enabled = true;
/**
* 处理中请求的锁时长(秒)
*/
private long lockTtlSeconds = 60L;
/**
* 成功响应缓存时长(秒),用于相同请求ID的幂等回放
*/
private long responseTtlSeconds = 600L;
/**
* 允许缓存回放的响应体最大字节数,超过则只做去重不做回放
*/
private int maxResponseCacheBytes = 262144;
}
/**
* 内网接口配置
*/
@Data
public static class Internal {
/**
* admin 服务内网访问地址
*/
private String adminBaseUrl = "http://127.0.0.1:8080";
/**
* 内网调用令牌,需与 admin 的 internal.api.token 保持一致
*/
private String token;
/**
* 连接超时时间(毫秒)
*/
private long connectTimeoutMs = 2000L;
/**
* 响应超时时间(毫秒)
*/
private long readTimeoutMs = 5000L;
/**
* 计费扣费内网接口路径
*/
private String deductPath = InternalApiConstants.BILLING_DEDUCT_PATH;
/**
* 调用日志写入内网接口路径
*/
private String callLogPath = InternalApiConstants.CALL_LOG_RECORD_PATH;
/**
* 调用日志异步线程数
*/
private int callLogPoolSize = 2;
/**
* 调用日志异步队列容量
*/
private int callLogQueueCapacity = 1024;
}
/**
* 上游转发配置
*/
@Data
public static class Forward {
/**
* 上游连接超时时间(毫秒),读超时按接口配置的 timeoutMs 生效
*/
private long connectTimeoutMs = 2000L;
}
}
@@ -0,0 +1,265 @@
package com.starsee.starapi.gateway.constant;
import org.springframework.core.Ordered;
import java.util.List;
/**
* 网关通用常量
* <p>
* 网关独立部署,不依赖业务模块,仅通过 {@code starapi-api} 契约与 Redis 缓存通信,
* 因此状态值、缓存键前缀需要在此单独声明,取值与 openapi / billing / calllog 模块保持一致。
*
* @author x6g2
*/
public interface GatewayConstants {
/**
* 应用标识请求头
*/
String HEADER_APP_KEY = "X-App-Key";
/**
* 请求时间戳请求头(毫秒时间戳)
*/
String HEADER_TIMESTAMP = "X-Timestamp";
/**
* 请求随机串请求头,用于防重放
*/
String HEADER_NONCE = "X-Nonce";
/**
* 请求签名请求头(HMAC-SHA256 小写十六进制)
*/
String HEADER_SIGNATURE = "X-Signature";
/**
* 链路追踪ID请求头,同时作为扣费与调用日志的幂等键
*/
String HEADER_REQUEST_ID = "X-Request-Id";
/**
* 网关回放命中标记响应头
*/
String HEADER_GATEWAY_REPLAY = "X-Gateway-Replay";
/**
* 网关计费告警响应头,扣费失败时提示调用方
*/
String HEADER_GATEWAY_WARNING = "X-Gateway-Warning";
/**
* 转发上游时携带的调用方应用ID
*/
String HEADER_GATEWAY_APP_ID = "X-Gateway-App-Id";
/**
* 转发上游时携带的调用方开发者用户ID
*/
String HEADER_GATEWAY_USER_ID = "X-Gateway-User-Id";
/**
* 转发上游时携带的调用方真实IP
*/
String HEADER_GATEWAY_CLIENT_IP = "X-Gateway-Client-Ip";
/**
* 防重放 nonce 缓存前缀
*/
String NONCE_CACHE_PREFIX = "gateway:nonce:";
/**
* 单应用单接口 QPS 令牌桶缓存前缀
*/
String QPS_CACHE_PREFIX = "gateway:qps:";
/**
* 单应用每日调用次数缓存前缀
*/
String DAILY_CACHE_PREFIX = "gateway:daily:";
/**
* 请求幂等处理锁缓存前缀
*/
String IDEMPOTENT_LOCK_PREFIX = "gateway:idempotent:lock:";
/**
* 请求幂等响应缓存前缀
*/
String IDEMPOTENT_RESPONSE_PREFIX = "gateway:idempotent:response:";
/**
* 请求幂等处理锁占位值
*/
String IDEMPOTENT_LOCK_VALUE = "1";
/**
* 状态-正常
*/
String STATUS_NORMAL = "0";
/**
* 开发者账号状态-正常
*/
String USER_STATUS_NORMAL = "0";
/**
* 接口状态-维护中
*/
String API_STATUS_MAINTAIN = "2";
/**
* 授权状态-已开通
*/
String AUTH_STATUS_OPEN = "0";
/**
* 请求方法-ALL
*/
String METHOD_ALL = "ALL";
/**
* 是否计费-已计费
*/
String IS_CHARGE_YES = "1";
/**
* 是否计费-未计费
*/
String IS_CHARGE_NO = "0";
/**
* 扣费模式-无
*/
String CHARGE_MODE_NONE = "0";
/**
* 应用默认QPS上限
*/
int DEFAULT_APP_QPS_LIMIT = 50;
/**
* 接口默认QPS上限
*/
int DEFAULT_API_QPS_LIMIT = 100;
/**
* 接口默认超时时间(毫秒)
*/
int DEFAULT_TIMEOUT_MS = 5000;
/**
* 上游连接超时默认值(毫秒)
*/
int DEFAULT_CONNECT_TIMEOUT_MS = 2000;
/**
* 令牌桶策略存活时间(秒),到期后按最新配置重建
*/
long RATE_LIMITER_KEEP_ALIVE_SECONDS = 3600L;
/**
* 每日调用计数键过期时间(小时),覆盖跨时区与跨天抖动
*/
long DAILY_KEY_EXPIRE_HOURS = 26L;
/**
* 错误响应内容类型
*/
String CONTENT_TYPE_JSON = "application/json;charset=UTF-8";
/**
* 错误信息最大长度,与 api_call_log.error_msg 列长度一致
*/
int MAX_ERROR_MSG_LENGTH = 512;
/**
* 请求ID在请求属性中的键
*/
String ATTR_REQUEST_ID = "gateway.requestId";
/**
* 网关上下文在请求属性中的键
*/
String ATTR_GATEWAY_CONTEXT = "gateway.context";
/**
* 过滤器顺序-上下文初始化与调用日志(最外层,保证后续任何阶段失败都能记录调用日志)
*/
int FILTER_ORDER_CONTEXT = Ordered.HIGHEST_PRECEDENCE + 100;
/**
* 过滤器顺序-路由匹配与接口维护状态校验
*/
int FILTER_ORDER_ROUTE = Ordered.HIGHEST_PRECEDENCE + 110;
/**
* 过滤器顺序-应用凭证、账号状态与IP白名单
*/
int FILTER_ORDER_CREDENTIAL = Ordered.HIGHEST_PRECEDENCE + 120;
/**
* 过滤器顺序-时间戳、nonce 防重放与签名校验
*/
int FILTER_ORDER_SIGN = Ordered.HIGHEST_PRECEDENCE + 130;
/**
* 过滤器顺序-接口授权校验
*/
int FILTER_ORDER_AUTH = Ordered.HIGHEST_PRECEDENCE + 140;
/**
* 过滤器顺序-请求ID幂等(回放与处理锁)
*/
int FILTER_ORDER_IDEMPOTENT = Ordered.HIGHEST_PRECEDENCE + 150;
/**
* 过滤器顺序-QPS 与应用日限额
*/
int FILTER_ORDER_RATE_LIMIT = Ordered.HIGHEST_PRECEDENCE + 160;
/**
* 过滤器顺序-上游转发、扣费与响应回写
*/
int FILTER_ORDER_FORWARD = Ordered.HIGHEST_PRECEDENCE + 170;
/**
* 转发上游时不透传的请求头(鉴权头、HTTP 逐跳头、网关内部头)
*/
List<String> FORWARD_EXCLUDE_HEADERS = List.of(
HEADER_APP_KEY,
HEADER_TIMESTAMP,
HEADER_NONCE,
HEADER_SIGNATURE,
"host",
"content-length",
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"expect",
"accept-encoding"
);
/**
* 转发上游时不透传的响应头(HTTP 逐跳头与容器自动补齐的头)
*/
List<String> RESPONSE_EXCLUDE_HEADERS = List.of(
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"content-length",
"date"
);
}
@@ -0,0 +1,141 @@
package com.starsee.starapi.gateway.constant;
/**
* 网关错误码
* <p>
* {@code code} 为返回给调用方的业务码,{@code httpStatus} 为 HTTP 状态码,
* 两者同时写入响应,便于调用方按 HTTP 状态做通用重试、按业务码做精确处理。
*
* @author x6g2
*/
public enum GatewayErrorCode {
/**
* 路由不存在或已下线
*/
ROUTE_NOT_FOUND(40401, 404, "开放接口不存在或已下线"),
/**
* 接口维护中
*/
API_MAINTAIN(50301, 503, "开放接口维护中,请稍后重试"),
/**
* 应用凭证无效
*/
APP_CREDENTIAL_INVALID(40101, 401, "应用凭证无效,请检查AppKey"),
/**
* 请求时间戳无效
*/
TIMESTAMP_INVALID(40102, 401, "请求时间戳无效或已超出允许的时间偏移"),
/**
* nonce 重复
*/
NONCE_REPLAYED(40103, 401, "请求nonce已被使用,请勿重复提交"),
/**
* 签名校验未通过
*/
SIGNATURE_INVALID(40104, 401, "请求签名校验未通过"),
/**
* 应用已被禁用
*/
APP_DISABLED(40301, 403, "应用已被禁用"),
/**
* 开发者账号不可用
*/
USER_DISABLED(40302, 403, "开发者账号已被停用或冻结"),
/**
* 来源IP不在白名单
*/
IP_NOT_ALLOWED(40303, 403, "调用来源IP不在应用白名单内"),
/**
* 应用未开通接口
*/
API_NOT_AUTHORIZED(40304, 403, "应用未开通该接口"),
/**
* 请求ID重复
*/
REQUEST_DUPLICATE(40901, 409, "相同的请求ID正在处理或已完成,请勿重复提交"),
/**
* QPS 超限
*/
RATE_LIMITED(42901, 429, "调用频率超过接口QPS限制"),
/**
* 日调用限额超限
*/
DAILY_LIMIT_EXCEEDED(42902, 429, "调用次数已超过应用日调用限额"),
/**
* 上游调用失败
*/
UPSTREAM_ERROR(50201, 502, "上游服务调用失败"),
/**
* 上游调用超时
*/
UPSTREAM_TIMEOUT(50401, 504, "上游服务响应超时"),
/**
* 网关内部错误
*/
GATEWAY_ERROR(50001, 500, "网关内部错误");
/**
* 业务码
*/
private final int code;
/**
* HTTP 状态码
*/
private final int httpStatus;
/**
* 默认提示信息
*/
private final String message;
GatewayErrorCode(int code, int httpStatus, String message) {
this.code = code;
this.httpStatus = httpStatus;
this.message = message;
}
/**
* 获取业务码
*
* @return 业务码
*/
public int getCode() {
return code;
}
/**
* 获取 HTTP 状态码
*
* @return HTTP 状态码
*/
public int getHttpStatus() {
return httpStatus;
}
/**
* 获取默认提示信息
*
* @return 默认提示信息
*/
public String getMessage() {
return message;
}
}
@@ -0,0 +1,204 @@
package com.starsee.starapi.gateway.filter;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.ApiRouteCacheReader;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.server.PathContainer;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;
import java.net.SocketTimeoutException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeoutException;
/**
* 开放接口网关过滤器基类
* <p>
* 统一处理开放接口路径判定、请求上下文获取、网关标准错误响应与异常信息整理,
* 各阶段过滤器只实现自身校验逻辑,链路顺序由各子类上的 {@code @Order} 注解声明,
* 顺序常量见 {@link GatewayConstants} 的 {@code FILTER_ORDER_*}。
*
* @author x6g2
*/
public abstract class AbstractOpenApiFilter extends OncePerRequestFilter {
/**
* 网关配置
*/
protected final GatewayProperties properties;
/**
* 网关响应写出器
*/
protected final GatewayResponseWriter gatewayResponseWriter;
/**
* 开放接口路径匹配规则,首次使用时解析
*/
private volatile List<PathPattern> includePatterns;
/**
* 构造网关过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
*/
protected AbstractOpenApiFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter) {
this.properties = properties;
this.gatewayResponseWriter = gatewayResponseWriter;
}
/**
* 仅接管配置的开放接口路径,其余请求交给后续过滤器与 MVC 处理
*
* @param request 当前请求
* @return true=不经过网关
*/
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
if (!properties.isEnabled()) {
return true;
}
PathContainer pathContainer = PathContainer.parsePath(resolveRequestPath(request));
for (PathPattern pattern : resolveIncludePatterns()) {
if (pattern.matches(pathContainer)) {
return false;
}
}
return true;
}
/**
* 获取当前请求的网关上下文
*
* @param request 当前请求
* @return 网关上下文,上下文尚未建立(如未进入网关)时返回 {@code null}
*/
protected GatewayRequestContext currentContext(HttpServletRequest request) {
Object context = request.getAttribute(GatewayConstants.ATTR_GATEWAY_CONTEXT);
return context instanceof GatewayRequestContext gatewayContext ? gatewayContext : null;
}
/**
* 拒绝请求:记录失败原因并写出统一错误响应
*
* @param response 当前响应
* @param context 网关上下文
* @param errorCode 错误码
* @param message 自定义提示信息,为空时使用错误码默认提示
*/
protected void reject(HttpServletResponse response, GatewayRequestContext context,
GatewayErrorCode errorCode, String message) {
String errorMessage = StringUtils.isNotBlank(message) ? message : errorCode.getMessage();
context.setErrorMsg(truncate(errorMessage));
context.setHttpStatus(errorCode.getHttpStatus());
gatewayResponseWriter.writeError(response, errorCode, context.getRequestId(), errorMessage);
}
/**
* 解析请求路径(去掉 context-path 与查询参数)
*
* @param request 当前请求
* @return 规范化后的请求路径
*/
protected String resolveRequestPath(HttpServletRequest request) {
return ApiRouteCacheReader.normalizePath(resolveRawRequestPath(request));
}
/**
* 解析原始请求路径(仅去掉 context-path,保留尾斜杠)
*
* @param request 当前请求
* @return 原始请求路径
*/
protected String resolveRawRequestPath(HttpServletRequest request) {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
if (StringUtils.isNotBlank(contextPath) && uri.startsWith(contextPath)) {
uri = uri.substring(contextPath.length());
}
return StringUtils.isBlank(uri) ? "/" : uri;
}
/**
* 获取并缓存开放接口路径匹配规则
*
* @return 路径匹配规则
*/
private List<PathPattern> resolveIncludePatterns() {
List<PathPattern> patterns = includePatterns;
if (patterns != null) {
return patterns;
}
synchronized (this) {
if (includePatterns == null) {
PathPatternParser parser = new PathPatternParser();
List<PathPattern> parsed = new ArrayList<>();
String[] includePaths = properties.getIncludePaths();
if (includePaths != null) {
for (String includePath : includePaths) {
if (StringUtils.isNotBlank(includePath)) {
parsed.add(parser.parse(includePath));
}
}
}
includePatterns = parsed;
}
return includePatterns;
}
}
/**
* 判断异常是否为上游超时
*
* @param throwable 异常
* @return true=超时
*/
protected static boolean isTimeout(Throwable throwable) {
Throwable cause = throwable;
while (cause != null) {
if (cause instanceof SocketTimeoutException || cause instanceof TimeoutException
|| cause.getClass().getName().equals("java.net.http.HttpTimeoutException")) {
return true;
}
cause = cause.getCause() == cause ? null : cause.getCause();
}
return false;
}
/**
* 提取最底层异常信息
*
* @param throwable 异常
* @return 异常信息
*/
protected static String rootMessage(Throwable throwable) {
Throwable cause = throwable;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
}
return StringUtils.blankToDefault(cause.getMessage(), cause.getClass().getSimpleName());
}
/**
* 按日志列长度截断错误信息
*
* @param value 原始信息
* @return 截断后的信息
*/
protected static String truncate(String value) {
if (value == null || value.length() <= GatewayConstants.MAX_ERROR_MSG_LENGTH) {
return value;
}
return value.substring(0, GatewayConstants.MAX_ERROR_MSG_LENGTH);
}
}
@@ -0,0 +1,79 @@
package com.starsee.starapi.gateway.filter;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.AppCredentialCacheReader;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import com.starsee.starapi.openapi.api.domain.AppAuthDTO;
import com.starsee.starapi.openapi.api.domain.ApiRouteDTO;
import com.starsee.starapi.openapi.api.domain.AppCredentialDTO;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 网关接口授权过滤器
* <p>
* 校验应用是否已开通当前接口(授权记录存在且状态为已开通),未开通直接拒绝,
* 校验通过后把授权信息写入上下文。
*
* @author x6g2
*/
@Component
@Order(GatewayConstants.FILTER_ORDER_AUTH)
public class OpenApiAuthFilter extends AbstractOpenApiFilter {
/**
* 应用凭证缓存读取器(含接口授权缓存)
*/
private final AppCredentialCacheReader appCredentialCacheReader;
/**
* 构造接口授权过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
* @param appCredentialCacheReader 应用凭证缓存读取器
*/
public OpenApiAuthFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter,
AppCredentialCacheReader appCredentialCacheReader) {
super(properties, gatewayResponseWriter);
this.appCredentialCacheReader = appCredentialCacheReader;
}
/**
* 校验应用对当前接口的授权状态
*
* @param request 当前请求
* @param response 当前响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
GatewayRequestContext context = currentContext(request);
ApiRouteDTO route = context == null ? null : context.getRoute();
AppCredentialDTO credential = context == null ? null : context.getCredential();
if (route == null || credential == null) {
filterChain.doFilter(request, response);
return;
}
AppAuthDTO auth = appCredentialCacheReader.findAuth(credential.getAppId(), route.getApiId());
if (auth == null || !GatewayConstants.AUTH_STATUS_OPEN.equals(auth.getAuthStatus())) {
reject(response, context, GatewayErrorCode.API_NOT_AUTHORIZED, null);
return;
}
context.setAuth(auth);
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,161 @@
package com.starsee.starapi.gateway.filter;
import cn.hutool.core.util.ObjectUtil;
import com.starsee.starapi.billing.api.domain.DeductResultDTO;
import com.starsee.starapi.calllog.api.domain.CallLogDTO;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.gateway.client.CallLogClient;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import com.starsee.starapi.gateway.util.GatewayIpMatcher;
import com.starsee.starapi.gateway.util.OpenApiSignUtils;
import com.starsee.starapi.openapi.api.domain.ApiRouteDTO;
import com.starsee.starapi.openapi.api.domain.AppCredentialDTO;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* 网关上下文过滤器(链路最外层)
* <p>
* 负责创建本次调用的 {@link GatewayRequestContext}:链路追踪ID、请求方法、规范化路径、查询参数、
* 调用端IP、请求体与开始时间,并在请求结束后统一记录调用日志。
* 放在链路最外层是为了保证后续任一阶段失败(直接写错误响应后返回)仍能记录一条调用日志。
*
* @author x6g2
*/
@Slf4j
@Component
@Order(GatewayConstants.FILTER_ORDER_CONTEXT)
public class OpenApiContextFilter extends AbstractOpenApiFilter {
/**
* 请求ID允许的最大长度
*/
private static final int MAX_REQUEST_ID_LENGTH = 64;
/**
* 请求ID允许的字符(字母、数字、下划线、中划线)
*/
private static final String REQUEST_ID_REGEX = "[A-Za-z0-9_-]+";
/**
* 调用日志客户端
*/
private final CallLogClient callLogClient;
/**
* 构造网关上下文过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
* @param callLogClient 调用日志客户端
*/
public OpenApiContextFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter,
CallLogClient callLogClient) {
super(properties, gatewayResponseWriter);
this.callLogClient = callLogClient;
}
/**
* 初始化请求上下文,执行后续过滤器链,并记录调用日志
*
* @param request 当前请求
* @param response 当前响应
* @param filterChain 过滤器链
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
GatewayRequestContext context = new GatewayRequestContext();
context.setStartNanos(System.nanoTime());
context.setRequestId(resolveRequestId(request));
context.setMethod(StringUtils.upperCase(request.getMethod()));
context.setPath(resolveRequestPath(request));
context.setQueryString(request.getQueryString());
context.setClientIp(GatewayIpMatcher.resolveClientIp(request));
context.setRequestTime(LocalDateTime.now());
request.setAttribute(GatewayConstants.ATTR_REQUEST_ID, context.getRequestId());
request.setAttribute(GatewayConstants.ATTR_GATEWAY_CONTEXT, context);
try {
context.setBody(request.getInputStream().readAllBytes());
filterChain.doFilter(request, response);
} catch (Exception e) {
log.error("网关处理请求异常 requestId={} path={}", context.getRequestId(), context.getPath(), e);
context.setErrorMsg(truncate("网关内部错误: " + rootMessage(e)));
if (!response.isCommitted()) {
gatewayResponseWriter.writeError(response, GatewayErrorCode.GATEWAY_ERROR, context.getRequestId());
}
} finally {
if (!context.isReplayed()) {
recordCallLog(context);
}
}
}
/**
* 异步写入调用日志,缺少路由或凭证信息时跳过(admin 侧日志主键校验要求三元组齐全)
*
* @param context 网关上下文
*/
private void recordCallLog(GatewayRequestContext context) {
ApiRouteDTO route = context.getRoute();
AppCredentialDTO credential = context.getCredential();
if (route == null || route.getApiId() == null || credential == null
|| credential.getAppId() == null || credential.getUserId() == null) {
return;
}
DeductResultDTO deduct = context.getDeductResult();
CallLogDTO callLog = new CallLogDTO();
callLog.setRequestId(context.getRequestId());
callLog.setUserId(credential.getUserId());
callLog.setAppId(credential.getAppId());
callLog.setApiId(route.getApiId());
callLog.setRequestTime(ObjectUtil.defaultIfNull(context.getRequestTime(), LocalDateTime.now()));
callLog.setResponseTime(LocalDateTime.now());
callLog.setDurationMs((int) ((System.nanoTime() - context.getStartNanos()) / 1_000_000L));
callLog.setHttpStatus(context.getHttpStatus());
callLog.setBizCode(ObjectUtil.defaultIfNull(context.getBizCode(), 0));
callLog.setClientIp(context.getClientIp());
callLog.setIsCharge(Objects.nonNull(deduct) && Boolean.TRUE.equals(deduct.getCharged())
? GatewayConstants.IS_CHARGE_YES : GatewayConstants.IS_CHARGE_NO);
callLog.setChargeMode(ObjectUtil.isNull(deduct) || ObjectUtil.isNull(deduct.getChargeMode())
? GatewayConstants.CHARGE_MODE_NONE : deduct.getChargeMode());
callLog.setDeductCalls(ObjectUtil.defaultIfNull(deduct == null ? null : deduct.getDeductCalls(), 0));
callLog.setDeductAmount(ObjectUtil.defaultIfNull(deduct == null ? null : deduct.getDeductAmount(), BigDecimal.ZERO));
callLog.setBalanceAfter(deduct == null ? null : deduct.getBalanceAfter());
callLog.setErrorMsg(truncate(context.getErrorMsg()));
callLogClient.record(callLog);
}
/**
* 解析本次调用的链路追踪ID,调用方未传或格式非法时由网关生成
*
* @param request 当前请求
* @return 链路追踪ID
*/
private String resolveRequestId(HttpServletRequest request) {
String requestId = request.getHeader(GatewayConstants.HEADER_REQUEST_ID);
if (StringUtils.isBlank(requestId)) {
return OpenApiSignUtils.newRequestId();
}
String trimmed = requestId.trim();
if (trimmed.length() > MAX_REQUEST_ID_LENGTH || !trimmed.matches(REQUEST_ID_REGEX)) {
log.warn("调用方传入的请求ID格式非法,已由网关重新生成 requestId={}", requestId);
return OpenApiSignUtils.newRequestId();
}
return trimmed;
}
}
@@ -0,0 +1,92 @@
package com.starsee.starapi.gateway.filter;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.AppCredentialCacheReader;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import com.starsee.starapi.gateway.util.GatewayIpMatcher;
import com.starsee.starapi.openapi.api.domain.AppCredentialDTO;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 网关应用凭证过滤器
* <p>
* 按 {@code X-App-Key} 从 Redis 读取应用凭证,校验应用状态、开发者账号状态与来源IP白名单,
* 全部通过后把凭证写入上下文,供签名校验、授权校验与转发阶段使用。
*
* @author x6g2
*/
@Component
@Order(GatewayConstants.FILTER_ORDER_CREDENTIAL)
public class OpenApiCredentialFilter extends AbstractOpenApiFilter {
/**
* 应用凭证缓存读取器
*/
private final AppCredentialCacheReader appCredentialCacheReader;
/**
* 构造应用凭证过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
* @param appCredentialCacheReader 应用凭证缓存读取器
*/
public OpenApiCredentialFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter,
AppCredentialCacheReader appCredentialCacheReader) {
super(properties, gatewayResponseWriter);
this.appCredentialCacheReader = appCredentialCacheReader;
}
/**
* 校验应用凭证、账号状态与来源IP白名单
*
* @param request 当前请求
* @param response 当前响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
GatewayRequestContext context = currentContext(request);
if (context == null) {
filterChain.doFilter(request, response);
return;
}
String appKey = request.getHeader(GatewayConstants.HEADER_APP_KEY);
AppCredentialDTO credential = StringUtils.isBlank(appKey)
? null : appCredentialCacheReader.findCredential(appKey);
if (credential == null) {
reject(response, context, GatewayErrorCode.APP_CREDENTIAL_INVALID, null);
return;
}
context.setCredential(credential);
if (!GatewayConstants.STATUS_NORMAL.equals(credential.getStatus())) {
reject(response, context, GatewayErrorCode.APP_DISABLED, null);
return;
}
if (StringUtils.isNotBlank(credential.getUserStatus())
&& !GatewayConstants.USER_STATUS_NORMAL.equals(credential.getUserStatus())) {
reject(response, context, GatewayErrorCode.USER_DISABLED, null);
return;
}
if (!GatewayIpMatcher.isAllowed(credential.getIpWhiteList(), context.getClientIp())) {
reject(response, context, GatewayErrorCode.IP_NOT_ALLOWED, null);
return;
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,300 @@
package com.starsee.starapi.gateway.filter;
import cn.hutool.core.util.ObjectUtil;
import com.starsee.starapi.billing.api.domain.DeductRequestDTO;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.common.json.utils.JsonUtils;
import com.starsee.starapi.gateway.client.BillingClient;
import com.starsee.starapi.gateway.client.UpstreamClientFactory;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayCachedResponse;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.GatewayIdempotencyStore;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import com.starsee.starapi.openapi.api.domain.ApiRouteDTO;
import com.starsee.starapi.openapi.api.domain.AppCredentialDTO;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.JsonNode;
import java.io.IOException;
import java.net.URI;
import java.util.Enumeration;
import java.util.Locale;
/**
* 网关转发过滤器(链路最后阶段)
* <p>
* 按接口配置的超时时间把请求转发到上游,成功(2xx)后发起扣费并回写上游响应,
* 同时缓存成功响应供相同请求ID回放;扣费失败只追加告警头与错误日志,不改变调用方看到的结果。
*
* @author x6g2
*/
@Slf4j
@Component
@Order(GatewayConstants.FILTER_ORDER_FORWARD)
public class OpenApiForwardFilter extends AbstractOpenApiFilter {
/**
* 计费告警头取值
*/
private static final String WARNING_CHARGE_FAILED = "charge-failed";
/**
* 上游调用客户端工厂
*/
private final UpstreamClientFactory upstreamClientFactory;
/**
* 计费客户端
*/
private final BillingClient billingClient;
/**
* 请求ID幂等存储
*/
private final GatewayIdempotencyStore gatewayIdempotencyStore;
/**
* 构造转发过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
* @param upstreamClientFactory 上游调用客户端工厂
* @param billingClient 计费客户端
* @param gatewayIdempotencyStore 请求ID幂等存储
*/
public OpenApiForwardFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter,
UpstreamClientFactory upstreamClientFactory, BillingClient billingClient,
GatewayIdempotencyStore gatewayIdempotencyStore) {
super(properties, gatewayResponseWriter);
this.upstreamClientFactory = upstreamClientFactory;
this.billingClient = billingClient;
this.gatewayIdempotencyStore = gatewayIdempotencyStore;
}
/**
* 转发上游、扣费、回写响应并缓存成功响应
*
* @param request 当前请求
* @param response 当前响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
GatewayRequestContext context = currentContext(request);
ApiRouteDTO route = context == null ? null : context.getRoute();
AppCredentialDTO credential = context == null ? null : context.getCredential();
if (route == null || credential == null) {
if (context != null) {
reject(response, context, GatewayErrorCode.GATEWAY_ERROR, "网关上下文不完整");
} else {
filterChain.doFilter(request, response);
}
return;
}
ResponseEntity<byte[]> upstream;
try {
upstream = forward(request, context);
} catch (Exception e) {
GatewayErrorCode errorCode = isTimeout(e)
? GatewayErrorCode.UPSTREAM_TIMEOUT : GatewayErrorCode.UPSTREAM_ERROR;
reject(response, context, errorCode, errorCode.getMessage() + ": " + rootMessage(e));
return;
}
byte[] upstreamBody = upstream.getBody() == null ? new byte[0] : upstream.getBody();
context.setHttpStatus(upstream.getStatusCode().value());
context.setBizCode(resolveBizCode(upstream.getHeaders().getContentType(), upstreamBody));
// 成功调用后扣费,扣费失败不改变已返回的上游结果
charge(context, response);
writeUpstreamResponse(response, upstream, upstreamBody);
cacheResponse(context, response, upstreamBody);
}
/**
* 转发请求到接口配置的上游地址
*
* @param request 当前请求
* @param context 网关上下文
* @return 上游响应
*/
private ResponseEntity<byte[]> forward(HttpServletRequest request, GatewayRequestContext context) {
ApiRouteDTO route = context.getRoute();
int timeoutMs = ObjectUtil.isNull(route.getTimeoutMs()) || route.getTimeoutMs() <= 0
? GatewayConstants.DEFAULT_TIMEOUT_MS : route.getTimeoutMs();
byte[] body = context.getBody();
String upstreamUrl = appendQueryString(route.getUpstreamUrl(), context.getQueryString());
// 空请求体(如 GET)不能显式传入 null body,否则消息转换器解析 body 类型时会空指针
RestClient.RequestBodySpec requestSpec = upstreamClientFactory.getClient(timeoutMs)
.method(HttpMethod.valueOf(context.getMethod()))
.uri(URI.create(upstreamUrl))
.headers(headers -> fillForwardHeaders(headers, request, context));
RestClient.RequestHeadersSpec<?> headersSpec = body.length == 0 ? requestSpec : requestSpec.body(body);
return headersSpec.exchange((clientRequest, clientResponse) -> {
byte[] responseBody = StreamUtils.copyToByteArray(clientResponse.getBody());
HttpHeaders responseHeaders = new HttpHeaders();
clientResponse.getHeaders().forEach((headerName, headerValues) -> {
if (!GatewayConstants.RESPONSE_EXCLUDE_HEADERS.contains(headerName.toLowerCase(Locale.ROOT))) {
responseHeaders.put(headerName, headerValues);
}
});
return ResponseEntity.status(clientResponse.getStatusCode())
.headers(responseHeaders)
.body(responseBody);
});
}
/**
* 组装转发到上游的请求头:过滤鉴权头与逐跳头,并透传调用方身份
*
* @param headers 目标请求头
* @param request 当前请求
* @param context 网关上下文
*/
private void fillForwardHeaders(HttpHeaders headers, HttpServletRequest request, GatewayRequestContext context) {
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
if (GatewayConstants.FORWARD_EXCLUDE_HEADERS.contains(headerName.toLowerCase(Locale.ROOT))) {
continue;
}
Enumeration<String> headerValues = request.getHeaders(headerName);
while (headerValues.hasMoreElements()) {
headers.add(headerName, headerValues.nextElement());
}
}
headers.set(GatewayConstants.HEADER_REQUEST_ID, context.getRequestId());
headers.set(GatewayConstants.HEADER_GATEWAY_APP_ID, String.valueOf(context.getCredential().getAppId()));
headers.set(GatewayConstants.HEADER_GATEWAY_USER_ID, String.valueOf(context.getCredential().getUserId()));
headers.set(GatewayConstants.HEADER_GATEWAY_CLIENT_IP,
StringUtils.blankToDefault(context.getClientIp(), ""));
}
/**
* 回写上游响应给调用方
*
* @param response 当前响应
* @param upstream 上游响应
* @param body 上游响应体
* @throws IOException IO 异常
*/
private void writeUpstreamResponse(HttpServletResponse response, ResponseEntity<byte[]> upstream, byte[] body)
throws IOException {
response.setStatus(upstream.getStatusCode().value());
upstream.getHeaders().forEach((headerName, headerValues) -> {
if (!GatewayConstants.RESPONSE_EXCLUDE_HEADERS.contains(headerName.toLowerCase(Locale.ROOT))) {
headerValues.forEach(headerValue -> response.addHeader(headerName, headerValue));
}
});
response.setContentLength(body.length);
if (body.length > 0) {
response.getOutputStream().write(body);
}
response.flushBuffer();
}
/**
* 对成功调用发起扣费,失败只记录告警,不影响已返回的上游结果
*
* @param context 网关上下文
* @param response 当前响应
*/
private void charge(GatewayRequestContext context, HttpServletResponse response) {
if (context.getHttpStatus() < 200 || context.getHttpStatus() >= 300) {
return;
}
ApiRouteDTO route = context.getRoute();
AppCredentialDTO credential = context.getCredential();
DeductRequestDTO request = new DeductRequestDTO();
request.setRequestId(context.getRequestId());
request.setUserId(credential.getUserId());
request.setAppId(credential.getAppId());
request.setApiId(route.getApiId());
request.setChargeType(route.getChargeType());
request.setPricePerCall(route.getPricePerCall());
try {
context.setDeductResult(billingClient.deduct(request));
} catch (Exception e) {
log.error("网关计费失败 requestId={} appId={} apiId={}",
context.getRequestId(), credential.getAppId(), route.getApiId(), e);
context.setErrorMsg(truncate("计费失败: " + rootMessage(e)));
response.addHeader(GatewayConstants.HEADER_GATEWAY_WARNING, WARNING_CHARGE_FAILED);
}
}
/**
* 缓存成功响应,供相同请求ID回放
*
* @param context 网关上下文
* @param response 当前响应
* @param body 上游响应体
*/
private void cacheResponse(GatewayRequestContext context, HttpServletResponse response, byte[] body) {
if (!properties.getIdempotent().isEnabled()
|| context.getHttpStatus() < 200 || context.getHttpStatus() >= 300) {
return;
}
GatewayCachedResponse cached = new GatewayCachedResponse();
cached.setStatus(context.getHttpStatus());
cached.setContentType(response.getContentType());
cached.setBody(body);
gatewayIdempotencyStore.cacheResponse(context.getRequestId(), cached);
}
/**
* 拼装带查询参数的上游地址
*
* @param upstreamUrl 上游地址
* @param queryString 查询参数串
* @return 完整上游地址
*/
private String appendQueryString(String upstreamUrl, String queryString) {
if (StringUtils.isBlank(queryString)) {
return upstreamUrl;
}
return upstreamUrl + (upstreamUrl.contains("?") ? "&" : "?") + queryString;
}
/**
* 解析上游业务返回码(响应体 JSON 中的 code 字段,非 JSON 或缺失按 0 处理)
*
* @param contentType 响应内容类型
* @param body 响应体
* @return 业务返回码
*/
private Integer resolveBizCode(MediaType contentType, byte[] body) {
if (body.length == 0 || contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
return 0;
}
try {
JsonNode node = JsonUtils.getJsonMapper().readTree(body);
JsonNode codeNode = node == null ? null : node.get("code");
if (codeNode != null && codeNode.isNumber()) {
return codeNode.asInt();
}
} catch (Exception e) {
log.debug("解析上游业务返回码失败,按成功处理", e);
}
return 0;
}
}
@@ -0,0 +1,84 @@
package com.starsee.starapi.gateway.filter;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayCachedResponse;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.GatewayIdempotencyStore;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 网关请求ID幂等过滤器
* <p>
* 位于验签与接口授权之后,避免请求ID泄露后可绕过鉴权回放响应:
* 命中首次成功响应缓存直接回放(不消耗QPS、不重复计费与记日志),
* 未命中则抢占处理锁,处理结束后释放。
*
* @author x6g2
*/
@Component
@Order(GatewayConstants.FILTER_ORDER_IDEMPOTENT)
public class OpenApiIdempotencyFilter extends AbstractOpenApiFilter {
/**
* 请求ID幂等存储
*/
private final GatewayIdempotencyStore gatewayIdempotencyStore;
/**
* 构造请求ID幂等过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
* @param gatewayIdempotencyStore 请求ID幂等存储
*/
public OpenApiIdempotencyFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter,
GatewayIdempotencyStore gatewayIdempotencyStore) {
super(properties, gatewayResponseWriter);
this.gatewayIdempotencyStore = gatewayIdempotencyStore;
}
/**
* 幂等回放或抢占处理锁后继续执行后续阶段
*
* @param request 当前请求
* @param response 当前响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
GatewayRequestContext context = currentContext(request);
if (context == null || !properties.getIdempotent().isEnabled()) {
filterChain.doFilter(request, response);
return;
}
GatewayCachedResponse cached = gatewayIdempotencyStore.getCachedResponse(context.getRequestId());
if (cached != null) {
context.setReplayed(true);
gatewayResponseWriter.replay(response, cached);
return;
}
if (!gatewayIdempotencyStore.tryLock(context.getRequestId())) {
reject(response, context, GatewayErrorCode.REQUEST_DUPLICATE, null);
return;
}
try {
filterChain.doFilter(request, response);
} finally {
gatewayIdempotencyStore.releaseLock(context.getRequestId());
}
}
}
@@ -0,0 +1,83 @@
package com.starsee.starapi.gateway.filter;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.GatewayRateLimiter;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import com.starsee.starapi.openapi.api.domain.ApiRouteDTO;
import com.starsee.starapi.openapi.api.domain.AppCredentialDTO;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 网关限流过滤器
* <p>
* 先校验单应用单接口 QPS(取应用与接口配置的较小值),再校验应用日调用限额;
* QPS 超限不占用日调用额度。
*
* @author x6g2
*/
@Component
@Order(GatewayConstants.FILTER_ORDER_RATE_LIMIT)
public class OpenApiRateLimitFilter extends AbstractOpenApiFilter {
/**
* 网关限流器
*/
private final GatewayRateLimiter gatewayRateLimiter;
/**
* 构造限流过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
* @param gatewayRateLimiter 网关限流器
*/
public OpenApiRateLimitFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter,
GatewayRateLimiter gatewayRateLimiter) {
super(properties, gatewayResponseWriter);
this.gatewayRateLimiter = gatewayRateLimiter;
}
/**
* 校验 QPS 与应用日调用限额
*
* @param request 当前请求
* @param response 当前响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
GatewayRequestContext context = currentContext(request);
ApiRouteDTO route = context == null ? null : context.getRoute();
AppCredentialDTO credential = context == null ? null : context.getCredential();
if (route == null || credential == null) {
filterChain.doFilter(request, response);
return;
}
GatewayProperties.RateLimit rateLimit = properties.getRateLimit();
if (rateLimit.isEnabled() && !gatewayRateLimiter.tryAcquireQps(credential.getAppId(), route.getApiId(),
credential.getQpsLimit(), route.getQpsLimit())) {
reject(response, context, GatewayErrorCode.RATE_LIMITED, null);
return;
}
if (rateLimit.isDailyLimitEnabled()
&& !gatewayRateLimiter.tryAcquireDaily(credential.getAppId(), credential.getDailyCallLimit())) {
reject(response, context, GatewayErrorCode.DAILY_LIMIT_EXCEEDED, null);
return;
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,79 @@
package com.starsee.starapi.gateway.filter;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.ApiRouteCacheReader;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import com.starsee.starapi.openapi.api.domain.ApiRouteDTO;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 网关路由匹配过滤器
* <p>
* 按请求方法从 Redis 路由缓存匹配接口(先精确方法,未命中回退 {@code ALL}),
* 未命中返回接口不存在,命中维护中的接口直接拒绝,其余情况把路由写入上下文继续后续阶段。
*
* @author x6g2
*/
@Component
@Order(GatewayConstants.FILTER_ORDER_ROUTE)
public class OpenApiRouteFilter extends AbstractOpenApiFilter {
/**
* 路由缓存读取器
*/
private final ApiRouteCacheReader apiRouteCacheReader;
/**
* 构造路由匹配过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
* @param apiRouteCacheReader 路由缓存读取器
*/
public OpenApiRouteFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter,
ApiRouteCacheReader apiRouteCacheReader) {
super(properties, gatewayResponseWriter);
this.apiRouteCacheReader = apiRouteCacheReader;
}
/**
* 匹配接口路由并校验接口维护状态
*
* @param request 当前请求
* @param response 当前响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
GatewayRequestContext context = currentContext(request);
if (context == null) {
filterChain.doFilter(request, response);
return;
}
ApiRouteDTO route = apiRouteCacheReader.findRoute(context.getMethod(), context.getPath());
if (route == null) {
reject(response, context, GatewayErrorCode.ROUTE_NOT_FOUND, null);
return;
}
context.setRoute(route);
if (GatewayConstants.API_STATUS_MAINTAIN.equals(route.getStatus())) {
reject(response, context, GatewayErrorCode.API_MAINTAIN, null);
return;
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,100 @@
package com.starsee.starapi.gateway.filter;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayRequestContext;
import com.starsee.starapi.gateway.support.GatewayNonceStore;
import com.starsee.starapi.gateway.support.GatewayResponseWriter;
import com.starsee.starapi.gateway.util.OpenApiSignUtils;
import com.starsee.starapi.openapi.api.domain.AppCredentialDTO;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* 网关签名校验过滤器
* <p>
* 校验时间戳偏移、nonce 防重放与 HMAC-SHA256 签名;调用方可能按规范化路径或原始 URI 签名,
* 两种形式都接受,避免带尾斜杠的请求被误判。签名校验关闭时直接放行。
*
* @author x6g2
*/
@Component
@Order(GatewayConstants.FILTER_ORDER_SIGN)
public class OpenApiSignFilter extends AbstractOpenApiFilter {
/**
* 防重放 nonce 存储
*/
private final GatewayNonceStore gatewayNonceStore;
/**
* 构造签名校验过滤器
*
* @param properties 网关配置
* @param gatewayResponseWriter 网关响应写出器
* @param gatewayNonceStore 防重放 nonce 存储
*/
public OpenApiSignFilter(GatewayProperties properties, GatewayResponseWriter gatewayResponseWriter,
GatewayNonceStore gatewayNonceStore) {
super(properties, gatewayResponseWriter);
this.gatewayNonceStore = gatewayNonceStore;
}
/**
* 校验时间戳、nonce 与请求签名
*
* @param request 当前请求
* @param response 当前响应
* @param filterChain 过滤器链
* @throws ServletException Servlet 异常
* @throws IOException IO 异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
GatewayRequestContext context = currentContext(request);
if (context == null || context.getCredential() == null || !properties.getSign().isEnabled()) {
filterChain.doFilter(request, response);
return;
}
AppCredentialDTO credential = context.getCredential();
String appKey = request.getHeader(GatewayConstants.HEADER_APP_KEY);
String timestamp = request.getHeader(GatewayConstants.HEADER_TIMESTAMP);
String nonce = request.getHeader(GatewayConstants.HEADER_NONCE);
String signature = request.getHeader(GatewayConstants.HEADER_SIGNATURE);
if (StringUtils.isBlank(timestamp) || StringUtils.isBlank(nonce) || StringUtils.isBlank(signature)) {
reject(response, context, GatewayErrorCode.SIGNATURE_INVALID, "请求缺少签名参数");
return;
}
if (!OpenApiSignUtils.isTimestampValid(timestamp, properties.getSign().getTimestampSkewSeconds())) {
reject(response, context, GatewayErrorCode.TIMESTAMP_INVALID, null);
return;
}
if (!gatewayNonceStore.tryUse(appKey, nonce)) {
reject(response, context, GatewayErrorCode.NONCE_REPLAYED, null);
return;
}
// 调用方可能按规范化路径或原始 URI 签名,两种形式都接受,避免带尾斜杠的请求被误判
String rawPath = resolveRawRequestPath(request);
boolean verified = OpenApiSignUtils.verify(credential.getAppSecret(), appKey, timestamp, nonce,
context.getMethod(), context.getPath(), context.getBody(), signature);
if (!verified && !StringUtils.equals(rawPath, context.getPath())) {
verified = OpenApiSignUtils.verify(credential.getAppSecret(), appKey, timestamp, nonce,
context.getMethod(), rawPath, context.getBody(), signature);
}
if (!verified) {
reject(response, context, GatewayErrorCode.SIGNATURE_INVALID, null);
return;
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,36 @@
package com.starsee.starapi.gateway.model;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 网关幂等回放响应
* <p>
* 相同请求ID再次到达时直接回放首次上游响应,保证调用方重试不会重复计费与重复执行。
*
* @author x6g2
*/
@Data
public class GatewayCachedResponse implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* HTTP 状态码
*/
private int status;
/**
* 响应内容类型
*/
private String contentType;
/**
* 响应体
*/
private byte[] body;
}
@@ -0,0 +1,68 @@
package com.starsee.starapi.gateway.model;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 网关标准错误响应
*
* @author x6g2
*/
@Data
public class GatewayErrorResponse implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 业务码,见 {@link GatewayErrorCode}
*/
private int code;
/**
* 错误提示信息
*/
private String msg;
/**
* 链路追踪ID,便于调用方与平台侧排查
*/
private String requestId;
/**
* 服务器时间戳(毫秒)
*/
private long timestamp;
/**
* 按错误码构建标准错误响应
*
* @param errorCode 错误码
* @param requestId 链路追踪ID
* @return 标准错误响应
*/
public static GatewayErrorResponse of(GatewayErrorCode errorCode, String requestId) {
return of(errorCode.getCode(), errorCode.getMessage(), requestId);
}
/**
* 按自定义业务码与提示信息构建标准错误响应
*
* @param code 业务码
* @param msg 提示信息
* @param requestId 链路追踪ID
* @return 标准错误响应
*/
public static GatewayErrorResponse of(int code, String msg, String requestId) {
GatewayErrorResponse response = new GatewayErrorResponse();
response.setCode(code);
response.setMsg(msg);
response.setRequestId(requestId);
response.setTimestamp(System.currentTimeMillis());
return response;
}
}
@@ -0,0 +1,101 @@
package com.starsee.starapi.gateway.model;
import com.starsee.starapi.billing.api.domain.DeductResultDTO;
import com.starsee.starapi.openapi.api.domain.ApiRouteDTO;
import com.starsee.starapi.openapi.api.domain.AppAuthDTO;
import com.starsee.starapi.openapi.api.domain.AppCredentialDTO;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 网关请求上下文
* <p>
* 在过滤器链路内传递本次调用的路由、鉴权、计费与耗时信息,请求结束后据此回写调用日志。
*
* @author x6g2
*/
@Data
public class GatewayRequestContext {
/**
* 链路追踪ID,同时作为扣费与调用日志的幂等键
*/
private String requestId;
/**
* 请求方法(大写)
*/
private String method;
/**
* 请求路径(不含查询参数)
*/
private String path;
/**
* 查询参数串(不含问号)
*/
private String queryString;
/**
* 调用端IP
*/
private String clientIp;
/**
* 原始请求体,GET 请求为空数组
*/
private byte[] body;
/**
* 请求到达时间
*/
private LocalDateTime requestTime;
/**
* 请求开始处理时间(纳秒),用于计算调用耗时
*/
private long startNanos;
/**
* 是否命中请求ID幂等回放,回放请求不重复记录调用日志
*/
private boolean replayed;
/**
* 命中的接口路由
*/
private ApiRouteDTO route;
/**
* 调用方应用凭证
*/
private AppCredentialDTO credential;
/**
* 应用对该接口的授权信息
*/
private AppAuthDTO auth;
/**
* 上游响应状态码,未调用上游时为 0
*/
private int httpStatus;
/**
* 上游业务返回码,默认 0 表示成功
*/
private Integer bizCode;
/**
* 计费结果,未计费时为 {@code null}
*/
private DeductResultDTO deductResult;
/**
* 异常或失败原因
*/
private String errorMsg;
}
@@ -0,0 +1,101 @@
package com.starsee.starapi.gateway.support;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.common.redis.utils.RedisUtils;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.openapi.api.constant.OpenApiCacheConstants;
import com.starsee.starapi.openapi.api.domain.ApiRouteDTO;
import org.springframework.stereotype.Component;
/**
* 开放接口路由缓存读取器
* <p>
* 网关只读 openapi 模块发布的 Redis 缓存:路径索引 Hash 存 {@code METHOD:path -> apiId},
* 路由 Hash 存 {@code apiId -> 路由信息}。先按请求方法精确匹配,未命中再回退 ALL 方法,
* 与缓存生产端 {@code ApiRouteCacheServiceImpl} 的写入格式保持一致。
*
* @author x6g2
*/
@Component
public class ApiRouteCacheReader {
/**
* 请求方法与路径的分隔符
*/
private static final String ROUTE_KEY_SEPARATOR = ":";
/**
* 路径分隔符
*/
private static final String PATH_SEPARATOR = "/";
/**
* 按请求方法与路径查找接口路由
*
* @param method 请求方法
* @param path 请求路径,可携带查询参数
* @return 命中的路由信息,未命中返回 {@code null}
*/
public ApiRouteDTO findRoute(String method, String path) {
String normalizedPath = normalizePath(path);
String apiId = findApiId(method, normalizedPath);
if (StringUtils.isBlank(apiId)) {
apiId = findApiId(GatewayConstants.METHOD_ALL, normalizedPath);
}
if (StringUtils.isBlank(apiId)) {
return null;
}
return RedisUtils.getCacheMapValue(OpenApiCacheConstants.API_ROUTE_CACHE, apiId);
}
/**
* 构建路由缓存的路径索引键
*
* @param method 请求方法,为空时按 ALL 处理
* @param path 请求路径,为空时按空串处理
* @return 请求方法:路径
*/
public static String buildRouteKey(String method, String path) {
return StringUtils.blankToDefault(method, GatewayConstants.METHOD_ALL) + ROUTE_KEY_SEPARATOR
+ StringUtils.blankToDefault(path, "");
}
/**
* 归一化请求路径
* <p>
* 去掉查询参数,保证以 / 开头,并去掉末尾多余的 /(根路径保留)。
*
* @param path 原始请求路径
* @return 归一化后的路径
*/
public static String normalizePath(String path) {
if (StringUtils.isBlank(path)) {
return PATH_SEPARATOR;
}
String normalized = StringUtils.trim(path);
int queryIndex = normalized.indexOf('?');
if (queryIndex >= 0) {
normalized = normalized.substring(0, queryIndex);
}
if (!normalized.startsWith(PATH_SEPARATOR)) {
normalized = PATH_SEPARATOR + normalized;
}
while (normalized.length() > 1 && normalized.endsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
/**
* 读取路径索引中的接口ID
*
* @param method 请求方法
* @param normalizedPath 归一化后的请求路径
* @return 接口ID字符串,未命中返回 {@code null}
*/
private String findApiId(String method, String normalizedPath) {
return RedisUtils.getCacheMapValue(OpenApiCacheConstants.API_ROUTE_PATH_CACHE,
buildRouteKey(method, normalizedPath));
}
}
@@ -0,0 +1,48 @@
package com.starsee.starapi.gateway.support;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.common.redis.utils.RedisUtils;
import com.starsee.starapi.openapi.api.constant.OpenApiCacheConstants;
import com.starsee.starapi.openapi.api.domain.AppAuthDTO;
import com.starsee.starapi.openapi.api.domain.AppCredentialDTO;
import org.springframework.stereotype.Component;
/**
* 开放平台应用凭证与授权缓存读取器
* <p>
* 缓存由 openapi 模块发布:凭证 Hash 存 {@code appKey -> 应用凭证},
* 授权 Hash 存 {@code appId 对应的键下 apiId -> 授权信息}。
*
* @author x6g2
*/
@Component
public class AppCredentialCacheReader {
/**
* 按 AppKey 读取应用凭证
*
* @param appKey 应用AppKey
* @return 应用凭证,未命中返回 {@code null}
*/
public AppCredentialDTO findCredential(String appKey) {
if (StringUtils.isBlank(appKey)) {
return null;
}
return RedisUtils.getCacheMapValue(OpenApiCacheConstants.APP_CREDENTIAL_CACHE, appKey);
}
/**
* 读取应用对指定接口的授权信息
*
* @param appId 应用ID
* @param apiId 接口ID
* @return 授权信息,未命中返回 {@code null}
*/
public AppAuthDTO findAuth(Long appId, Long apiId) {
if (appId == null || apiId == null) {
return null;
}
return RedisUtils.getCacheMapValue(OpenApiCacheConstants.appAuthCacheKey(appId), String.valueOf(apiId));
}
}
@@ -0,0 +1,88 @@
package com.starsee.starapi.gateway.support;
import lombok.RequiredArgsConstructor;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.common.redis.utils.RedisUtils;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.model.GatewayCachedResponse;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 网关请求ID幂等存储
* <p>
* 同一请求ID只允许一个请求执行:先抢占处理锁,成功响应再写入回放缓存;
* 重复请求直接回放首次响应,保证调用方重试不会重复计费与重复执行上游。
*
* @author x6g2
*/
@Component
@RequiredArgsConstructor
public class GatewayIdempotencyStore {
private final GatewayProperties properties;
/**
* 读取首次成功响应缓存
*
* @param requestId 链路追踪ID
* @return 缓存的响应,未命中或幂等关闭时返回 {@code null}
*/
public GatewayCachedResponse getCachedResponse(String requestId) {
if (!properties.getIdempotent().isEnabled() || StringUtils.isBlank(requestId)) {
return null;
}
return RedisUtils.getCacheObject(GatewayConstants.IDEMPOTENT_RESPONSE_PREFIX + requestId);
}
/**
* 尝试抢占请求处理锁
*
* @param requestId 链路追踪ID
* @return true=抢占成功(含幂等关闭或请求ID为空的情况);false=已有请求在处理
*/
public boolean tryLock(String requestId) {
GatewayProperties.Idempotent idempotent = properties.getIdempotent();
if (!idempotent.isEnabled() || StringUtils.isBlank(requestId)) {
return true;
}
return RedisUtils.setObjectIfAbsent(GatewayConstants.IDEMPOTENT_LOCK_PREFIX + requestId,
GatewayConstants.IDEMPOTENT_LOCK_VALUE, Duration.ofSeconds(idempotent.getLockTtlSeconds()));
}
/**
* 释放请求处理锁
*
* @param requestId 链路追踪ID
*/
public void releaseLock(String requestId) {
if (StringUtils.isBlank(requestId)) {
return;
}
RedisUtils.deleteObject(GatewayConstants.IDEMPOTENT_LOCK_PREFIX + requestId);
}
/**
* 缓存首次成功响应,供后续相同请求ID回放
* <p>
* 仅幂等开启且响应体未超过配置上限时缓存,避免大响应占用过多内存。
*
* @param requestId 链路追踪ID
* @param response 首次成功响应
*/
public void cacheResponse(String requestId, GatewayCachedResponse response) {
GatewayProperties.Idempotent idempotent = properties.getIdempotent();
if (!idempotent.isEnabled() || StringUtils.isBlank(requestId) || response == null) {
return;
}
byte[] body = response.getBody();
if (body != null && body.length > idempotent.getMaxResponseCacheBytes()) {
return;
}
RedisUtils.setCacheObject(GatewayConstants.IDEMPOTENT_RESPONSE_PREFIX + requestId, response,
Duration.ofSeconds(idempotent.getResponseTtlSeconds()));
}
}
@@ -0,0 +1,42 @@
package com.starsee.starapi.gateway.support;
import lombok.RequiredArgsConstructor;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.common.redis.utils.RedisUtils;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 网关 nonce 防重放存储
* <p>
* 每个 AppKey 下的 nonce 只允许使用一次,借助 Redis setIfAbsent 保证原子性,
* 缓存时长由 {@code gateway.sign.nonceTtlSeconds} 配置,应不小于时间戳允许偏移时间。
*
* @author x6g2
*/
@Component
@RequiredArgsConstructor
public class GatewayNonceStore {
private final GatewayProperties properties;
/**
* 尝试占用一次 nonce
*
* @param appKey 应用AppKey
* @param nonce 请求随机串
* @return true=首次使用;false=重复使用或参数非法
*/
public boolean tryUse(String appKey, String nonce) {
if (StringUtils.isBlank(appKey) || StringUtils.isBlank(nonce)) {
return false;
}
String cacheKey = GatewayConstants.NONCE_CACHE_PREFIX + appKey + ":" + nonce;
return RedisUtils.setObjectIfAbsent(cacheKey, "1",
Duration.ofSeconds(properties.getSign().getNonceTtlSeconds()));
}
}
@@ -0,0 +1,123 @@
package com.starsee.starapi.gateway.support;
import lombok.RequiredArgsConstructor;
import com.starsee.starapi.common.redis.utils.RedisUtils;
import com.starsee.starapi.gateway.config.GatewayProperties;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import org.redisson.api.RAtomicLong;
import org.redisson.api.RateType;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* 网关限流器
* <p>
* QPS 使用 Redisson 令牌桶,按「应用 + 接口 + 有效阈值」隔离统计,阈值取应用级与接口级配置的较小值;
* 日调用量使用 Redis 原子计数,首次自增时设置过期时间,避免跨天计数残留。
*
* @author x6g2
*/
@Component
@RequiredArgsConstructor
public class GatewayRateLimiter {
/**
* 日调用计数键的日期格式,形如 20261101
*/
private static final DateTimeFormatter DAILY_KEY_DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE;
private final GatewayProperties properties;
/**
* 尝试获取一次 QPS 调用许可
* <p>
* 应用级与接口级阈值都未配置时使用默认值,有效阈值取两者较小值。
*
* @param appId 应用ID
* @param apiId 接口ID
* @param appQpsLimit 应用级QPS阈值,为空或非正数时取默认值
* @param apiQpsLimit 接口级QPS阈值,为空或非正数时取默认值
* @return true=允许调用;false=超过QPS限制
*/
public boolean tryAcquireQps(Long appId, Long apiId, Integer appQpsLimit, Integer apiQpsLimit) {
GatewayProperties.RateLimit rateLimit = properties.getRateLimit();
if (!rateLimit.isEnabled()) {
return true;
}
int effectiveQps = resolveEffectiveQps(appQpsLimit, apiQpsLimit);
int intervalSeconds = Math.max(rateLimit.getQpsIntervalSeconds(), 1);
String cacheKey = buildQpsKey(appId, apiId, effectiveQps);
long remainingPermits = RedisUtils.rateLimiter(cacheKey, RateType.OVERALL, effectiveQps, intervalSeconds,
(int) GatewayConstants.RATE_LIMITER_KEEP_ALIVE_SECONDS);
return remainingPermits != -1L;
}
/**
* 尝试占用一次应用日调用额度
* <p>
* 限流关闭、未启用日限额或限额为空/非正数(表示不限)时直接放行。
*
* @param appId 应用ID
* @param dailyCallLimit 应用日调用限额
* @return true=额度充足;false=已超过日调用限额
*/
public boolean tryAcquireDaily(Long appId, Integer dailyCallLimit) {
GatewayProperties.RateLimit rateLimit = properties.getRateLimit();
if (!rateLimit.isEnabled() || !rateLimit.isDailyLimitEnabled()) {
return true;
}
if (dailyCallLimit == null || dailyCallLimit <= 0) {
return true;
}
String cacheKey = buildDailyKey(appId, LocalDate.now());
RAtomicLong counter = RedisUtils.getClient().getAtomicLong(cacheKey);
long currentValue = counter.incrementAndGet();
if (currentValue == 1L) {
counter.expire(Duration.ofHours(GatewayConstants.DAILY_KEY_EXPIRE_HOURS));
}
return currentValue <= dailyCallLimit;
}
/**
* 构建 QPS 令牌桶缓存键
*
* @param appId 应用ID
* @param apiId 接口ID
* @param qps 有效QPS阈值
* @return 令牌桶缓存键
*/
public static String buildQpsKey(Long appId, Long apiId, int qps) {
return GatewayConstants.QPS_CACHE_PREFIX + appId + ":" + apiId + ":" + qps;
}
/**
* 构建日调用计数缓存键
*
* @param appId 应用ID
* @param date 统计日期,为空时取当天
* @return 日调用计数缓存键
*/
public static String buildDailyKey(Long appId, LocalDate date) {
LocalDate targetDate = date == null ? LocalDate.now() : date;
return GatewayConstants.DAILY_CACHE_PREFIX + appId + ":" + targetDate.format(DAILY_KEY_DATE_FORMATTER);
}
/**
* 计算应用级与接口级同时生效的 QPS 阈值
*
* @param appQpsLimit 应用级QPS阈值
* @param apiQpsLimit 接口级QPS阈值
* @return 有效QPS阈值
*/
private static int resolveEffectiveQps(Integer appQpsLimit, Integer apiQpsLimit) {
int appLimit = appQpsLimit == null || appQpsLimit <= 0
? GatewayConstants.DEFAULT_APP_QPS_LIMIT : appQpsLimit;
int apiLimit = apiQpsLimit == null || apiQpsLimit <= 0
? GatewayConstants.DEFAULT_API_QPS_LIMIT : apiQpsLimit;
return Math.min(appLimit, apiLimit);
}
}
@@ -0,0 +1,112 @@
package com.starsee.starapi.gateway.support;
import lombok.extern.slf4j.Slf4j;
import com.starsee.starapi.common.core.utils.StringUtils;
import com.starsee.starapi.common.json.utils.JsonUtils;
import com.starsee.starapi.gateway.constant.GatewayConstants;
import com.starsee.starapi.gateway.constant.GatewayErrorCode;
import com.starsee.starapi.gateway.model.GatewayCachedResponse;
import com.starsee.starapi.gateway.model.GatewayErrorResponse;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
/**
* 网关响应写出器
* <p>
* 统一输出网关标准错误响应、幂等回放响应,响应写出异常只记日志不抛出,
* 避免在过滤器链路中因为客户端提前断连产生二次异常。
*
* @author x6g2
*/
@Slf4j
@Component
public class GatewayResponseWriter {
/**
* 幂等回放标记值
*/
private static final String REPLAY_HEADER_VALUE = "true";
/**
* 写出网关标准错误响应
*
* @param response 当前响应
* @param errorCode 错误码
* @param requestId 链路追踪ID
*/
public void writeError(HttpServletResponse response, GatewayErrorCode errorCode, String requestId) {
writeError(response, errorCode, requestId, null);
}
/**
* 写出网关标准错误响应,并覆盖默认提示信息
*
* @param response 当前响应
* @param errorCode 错误码
* @param requestId 链路追踪ID
* @param message 自定义提示信息,为空时使用错误码默认提示
*/
public void writeError(HttpServletResponse response, GatewayErrorCode errorCode, String requestId, String message) {
if (response == null || errorCode == null) {
return;
}
String errorMessage = StringUtils.blankToDefault(message, errorCode.getMessage());
GatewayErrorResponse body = GatewayErrorResponse.of(errorCode.getCode(), errorMessage, requestId);
writeJson(response, errorCode.getHttpStatus(), body);
}
/**
* 以 JSON 形式写出响应
*
* @param response 当前响应
* @param httpStatus HTTP 状态码
* @param body 响应对象
*/
public void writeJson(HttpServletResponse response, int httpStatus, Object body) {
if (response == null) {
return;
}
try {
response.setStatus(httpStatus);
response.setContentType(GatewayConstants.CONTENT_TYPE_JSON);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
String content = JsonUtils.toJsonString(body);
OutputStream outputStream = response.getOutputStream();
outputStream.write(content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch (IOException e) {
log.error("网关写出JSON响应失败 httpStatus={}", httpStatus, e);
}
}
/**
* 回放首次成功响应
* <p>
* 回放响应会带上重放标记头,便于调用方识别本次为幂等命中。
*
* @param response 当前响应
* @param cached 首次成功响应
*/
public void replay(HttpServletResponse response, GatewayCachedResponse cached) {
if (response == null || cached == null) {
return;
}
try {
response.setStatus(cached.getStatus());
response.setContentType(StringUtils.blankToDefault(cached.getContentType(), GatewayConstants.CONTENT_TYPE_JSON));
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setHeader(GatewayConstants.HEADER_GATEWAY_REPLAY, REPLAY_HEADER_VALUE);
OutputStream outputStream = response.getOutputStream();
byte[] body = cached.getBody();
outputStream.write(body == null ? new byte[0] : body);
outputStream.flush();
} catch (IOException e) {
log.error("网关回放幂等响应失败 status={}", cached.getStatus(), e);
}
}
}
@@ -0,0 +1,73 @@
package com.starsee.starapi.gateway.util;
import com.starsee.starapi.common.core.utils.NetUtils;
import com.starsee.starapi.common.core.utils.ServletUtils;
import com.starsee.starapi.common.core.utils.StringUtils;
import jakarta.servlet.http.HttpServletRequest;
/**
* 网关来源IP匹配工具
* <p>
* 白名单支持精确 IP、通配符与 CIDR 网段,多条目之间可用逗号、分号或换行分隔,
* 白名单为空表示不限制来源 IP。
*
* @author x6g2
*/
public final class GatewayIpMatcher {
/**
* 白名单条目分隔符:逗号、分号、回车、换行
*/
private static final String WHITE_LIST_SEPARATOR_REGEX = "[,;\\r\\n]+";
/**
* 工具类禁止实例化
*/
private GatewayIpMatcher() {
}
/**
* 校验来源IP是否命中应用白名单
*
* @param ipWhiteList 应用IP白名单,多个规则用逗号、分号或换行分隔,空白表示不限制
* @param clientIp 调用端IP
* @return true=允许访问
*/
public static boolean isAllowed(String ipWhiteList, String clientIp) {
if (StringUtils.isBlank(ipWhiteList)) {
return true;
}
String ip = StringUtils.trim(clientIp);
if (StringUtils.isBlank(ip)) {
return false;
}
String[] rules = ipWhiteList.split(WHITE_LIST_SEPARATOR_REGEX);
for (String rule : rules) {
String trimmedRule = StringUtils.trim(rule);
if (StringUtils.isNotBlank(trimmedRule) && NetUtils.isMatchIpRule(trimmedRule, ip)) {
return true;
}
}
return false;
}
/**
* 解析调用端真实IP
* <p>
* 优先取反向代理透传头,取不到时回退到连接的远端地址。
*
* @param request 当前请求
* @return 调用端IP,请求为空时返回 {@code null}
*/
public static String resolveClientIp(HttpServletRequest request) {
if (request == null) {
return null;
}
String clientIp = ServletUtils.getClientIP(request);
if (StringUtils.isNotBlank(clientIp)) {
return clientIp;
}
return request.getRemoteAddr();
}
}
@@ -0,0 +1,145 @@
package com.starsee.starapi.gateway.util;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;
import com.starsee.starapi.common.core.utils.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
import java.util.UUID;
/**
* 开放接口请求签名工具
* <p>
* 签名串由 AppKey、时间戳、随机串、请求方法(大写)、请求路径、请求体 MD5 按固定顺序换行拼接,
* 再使用 AppSecret 做 HMAC-SHA256 得到小写十六进制签名。空值统一按空串参与拼接,
* 请求体为 {@code null} 时按空数组计算 MD5,保证网关与调用方实现一致。
*
* @author x6g2
*/
public final class OpenApiSignUtils {
/**
* 签名串各段之间的分隔符
*/
private static final String SIGN_CONTENT_SEPARATOR = "\n";
/**
* 工具类禁止实例化
*/
private OpenApiSignUtils() {
}
/**
* 构建待签名内容
*
* @param appKey 应用AppKey
* @param timestamp 请求时间戳(毫秒时间戳字符串)
* @param nonce 请求随机串
* @param method 请求方法,内部统一转大写
* @param path 请求路径
* @param body 请求体,为 {@code null} 时按空数组处理
* @return 换行分隔的待签名内容
*/
public static String buildSignContent(String appKey, String timestamp, String nonce, String method,
String path, byte[] body) {
String signMethod = StringUtils.blankToDefault(method, "").toUpperCase(Locale.ROOT);
return StringUtils.blankToDefault(appKey, "") + SIGN_CONTENT_SEPARATOR
+ StringUtils.blankToDefault(timestamp, "") + SIGN_CONTENT_SEPARATOR
+ StringUtils.blankToDefault(nonce, "") + SIGN_CONTENT_SEPARATOR
+ signMethod + SIGN_CONTENT_SEPARATOR
+ StringUtils.blankToDefault(path, "") + SIGN_CONTENT_SEPARATOR
+ md5Hex(body);
}
/**
* 计算请求签名
*
* @param appSecret 应用AppSecret
* @param appKey 应用AppKey
* @param timestamp 请求时间戳(毫秒时间戳字符串)
* @param nonce 请求随机串
* @param method 请求方法
* @param path 请求路径
* @param body 请求体
* @return 小写十六进制 HMAC-SHA256 签名
*/
public static String sign(String appSecret, String appKey, String timestamp, String nonce,
String method, String path, byte[] body) {
String content = buildSignContent(appKey, timestamp, nonce, method, path, body);
byte[] key = StringUtils.blankToDefault(appSecret, "").getBytes(StandardCharsets.UTF_8);
HMac hmac = new HMac(HmacAlgorithm.HmacSHA256, key);
return hmac.digestHex(content);
}
/**
* 校验请求签名
* <p>
* 使用 {@link MessageDigest#isEqual(byte[], byte[])} 做定时安全比较,避免通过响应时间推测签名。
* AppSecret 或签名为空时直接判为不通过,保证失败关闭。
*
* @param appSecret 应用AppSecret
* @param appKey 应用AppKey
* @param timestamp 请求时间戳(毫秒时间戳字符串)
* @param nonce 请求随机串
* @param method 请求方法
* @param path 请求路径
* @param body 请求体
* @param signature 调用方传入的签名(小写十六进制)
* @return true=签名一致
*/
public static boolean verify(String appSecret, String appKey, String timestamp, String nonce,
String method, String path, byte[] body, String signature) {
if (StringUtils.isBlank(appSecret) || StringUtils.isBlank(signature)) {
return false;
}
String expected = sign(appSecret, appKey, timestamp, nonce, method, path, body);
byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8);
byte[] actualBytes = StringUtils.trim(signature).getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(expectedBytes, actualBytes);
}
/**
* 计算请求体的 MD5 小写十六进制值
*
* @param body 请求体,为 {@code null} 时按空数组处理
* @return 32 位小写十六进制 MD5
*/
public static String md5Hex(byte[] body) {
return DigestUtil.md5Hex(body == null ? new byte[0] : body);
}
/**
* 校验请求时间戳是否在允许的时间偏移内
*
* @param timestamp 毫秒时间戳字符串
* @param skewSeconds 允许的时间偏移(秒),小于 0 时按 0 处理
* @return true=时间戳合法且未超出偏移
*/
public static boolean isTimestampValid(String timestamp, long skewSeconds) {
if (StringUtils.isBlank(timestamp)) {
return false;
}
long requestTime;
try {
requestTime = Long.parseLong(StringUtils.trim(timestamp));
} catch (NumberFormatException e) {
return false;
}
long skewMillis = Math.max(skewSeconds, 0L) * 1000L;
long diff = Math.abs(System.currentTimeMillis() - requestTime);
return diff <= skewMillis;
}
/**
* 生成链路追踪ID
*
* @return 32 位无横线的 UUID 字符串
*/
public static String newRequestId() {
return UUID.randomUUID().toString().replace("-", "");
}
}
@@ -0,0 +1,44 @@
--- # 内网接口配置(扣费 / 调用日志回传 admin)
gateway:
internal:
# admin 服务内网访问地址
admin-base-url: http://127.0.0.1:8080
--- # redis 单机配置(单机与集群只能开启一个另一个需要注释掉)
spring.data:
redis:
# 地址
host: 24.233.9.83
# 端口,默认为6379
port: 6379
# 数据库索引
database: 0
# redis 密码必须配置
password: StarSee@1024
# 连接超时时间
timeout: 10s
# 是否开启ssl
ssl.enabled: false
# redisson 配置
redisson:
# redis key前缀
keyPrefix:
# 线程池数量
threads: 4
# Netty线程池数量
nettyThreads: 8
# 单节点配置
singleServerConfig:
# 客户端名称 不能用中文
clientName: StarAPI-Gateway
# 最小空闲连接数
connectionMinimumIdleSize: 8
# 连接池大小
connectionPoolSize: 32
# 连接空闲超时,单位:毫秒
idleConnectionTimeout: 10000
# 命令等待超时,单位:毫秒
timeout: 3000
# 发布和订阅连接池大小
subscriptionConnectionPoolSize: 50
@@ -0,0 +1,44 @@
--- # 内网接口配置(扣费 / 调用日志回传 admin)
gateway:
internal:
# admin 服务内网访问地址(按实际内网部署地址调整)
admin-base-url: http://127.0.0.1:8080
--- # redis 单机配置(单机与集群只能开启一个另一个需要注释掉)
spring.data:
redis:
# 地址
host: 24.233.9.83
# 端口,默认为6379
port: 6379
# 数据库索引
database: 0
# redis 密码必须配置
password: StarSee@1024
# 连接超时时间
timeout: 10s
# 是否开启ssl
ssl.enabled: false
# redisson 配置
redisson:
# redis key前缀
keyPrefix:
# 线程池数量
threads: 16
# Netty线程池数量
nettyThreads: 32
# 单节点配置
singleServerConfig:
# 客户端名称 不能用中文
clientName: StarAPI-Gateway
# 最小空闲连接数
connectionMinimumIdleSize: 32
# 连接池大小
connectionPoolSize: 64
# 连接空闲超时,单位:毫秒
idleConnectionTimeout: 10000
# 命令等待超时,单位:毫秒
timeout: 3000
# 发布和订阅连接池大小
subscriptionConnectionPoolSize: 50
@@ -0,0 +1,104 @@
# 开放接口网关配置
server:
# 网关服务端口,与 admin(8080) 区分部署
port: 8082
servlet:
# 应用的访问路径
context-path: /
# jetty 配置
jetty:
# HTTP post内容的最大大小。当值为-1时,默认值为大小是无限的
max-http-form-post-size: -1
threads:
# 开启虚拟线程后,该值表示最大并发任务数,而非平台线程池大小
max: 8000
# 日志配置
logging:
level:
com.starsee.starapi: @logging.level@
org.springframework: warn
org.redisson: warn
config: classpath:logback-plus.xml
# Spring配置
spring:
application:
name: StarAPI-Gateway
threads:
# 开启虚拟线程 仅jdk21可用
virtual:
enabled: false
profiles:
active: @profiles.active@
mvc:
# 设置静态资源路径 防止所有请求都去查静态资源
static-path-pattern: /static/**
jackson:
serialization:
# 格式化输出
indent_output: false
# 忽略无法转换的对象
fail_on_empty_beans: false
deserialization:
# 允许对象忽略json中不存在的属性
fail_on_unknown_properties: false
# 开放接口网关配置
gateway:
# 是否启用网关过滤器
enabled: true
# 仅这些路径进入网关鉴权与转发流程
include-paths:
- /api/v1/**
# 签名鉴权
sign:
enabled: true
# 请求时间戳允许的时间偏移(秒)
timestamp-skew-seconds: 300
# nonce 防重放缓存时长(秒)
nonce-ttl-seconds: 300
# 限流
rate-limit:
enabled: true
# QPS 统计窗口(秒)
qps-interval-seconds: 1
# 是否启用应用日调用限额
daily-limit-enabled: true
# 幂等
idempotent:
enabled: true
# 处理中请求的锁时长(秒)
lock-ttl-seconds: 60
# 成功响应缓存时长(秒),用于相同请求ID回放
response-ttl-seconds: 600
# 允许回放的响应体最大字节数
max-response-cache-bytes: 262144
# 内网接口(扣费 / 调用日志)
internal:
# admin 内网访问地址
admin-base-url: http://127.0.0.1:8080
# 内网调用令牌,需与 admin 的 internal.api.token 保持一致
token: ${INTERNAL_API_TOKEN:starapi-internal-token}
# 连接超时(毫秒)
connect-timeout-ms: 2000
# 响应超时(毫秒)
read-timeout-ms: 5000
# 调用日志异步线程数
call-log-pool-size: 2
# 调用日志异步队列容量
call-log-queue-capacity: 1024
# 上游转发
forward:
# 上游连接超时(毫秒),读超时按接口配置的 timeoutMs 生效
connect-timeout-ms: 2000
--- # Actuator 监控端点的配置项
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: ALWAYS
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="log.path" value="./logs"/>
<property name="console.log.pattern"
value="%cyan(%d{yyyy-MM-dd HH:mm:ss}) %green([%thread]) %highlight(%-5level) %boldMagenta(%logger{36}%n) - %msg%n"/>
<property name="log.pattern" value="%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"/>
<!-- 控制台输出 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${console.log.pattern}</pattern>
<charset>utf-8</charset>
</encoder>
</appender>
<!-- 控制台输出 -->
<appender name="file_console" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-console.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-console.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大 1天 -->
<maxHistory>1</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
</filter>
</appender>
<!-- 系统日志输出 -->
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-info.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-error.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>ERROR</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- info异步输出 -->
<appender name="async_info" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_info"/>
</appender>
<!-- error异步输出 -->
<appender name="async_error" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_error"/>
</appender>
<!-- 整合 skywalking 控制台输出 tid -->
<!-- <appender name="console" class="ch.qos.logback.core.ConsoleAppender">-->
<!-- <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">-->
<!-- <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">-->
<!-- <pattern>[%tid] ${console.log.pattern}</pattern>-->
<!-- </layout>-->
<!-- <charset>utf-8</charset>-->
<!-- </encoder>-->
<!-- </appender>-->
<!-- 整合 skywalking 推送采集日志 -->
<!-- <appender name="sky_log" class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender">-->
<!-- <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">-->
<!-- <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">-->
<!-- <pattern>[%tid] ${console.log.pattern}</pattern>-->
<!-- </layout>-->
<!-- <charset>utf-8</charset>-->
<!-- </encoder>-->
<!-- </appender>-->
<!--系统操作日志-->
<root level="info">
<appender-ref ref="console" />
<appender-ref ref="async_info" />
<appender-ref ref="async_error" />
<appender-ref ref="file_console" />
<!-- <appender-ref ref="sky_log"/>-->
</root>
</configuration>
@@ -0,0 +1,39 @@
package com.starsee.starapi.billing.controller.internal;
import lombok.RequiredArgsConstructor;
import com.starsee.starapi.billing.api.BillingService;
import com.starsee.starapi.billing.api.domain.DeductRequestDTO;
import com.starsee.starapi.billing.api.domain.DeductResultDTO;
import com.starsee.starapi.common.core.domain.R;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 内网计费接口(仅供网关等服务端内部调用,由内网令牌统一鉴权,不走后台登录鉴权)
*
* @author x6g2
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/internal/billing")
public class InternalBillingController {
private final BillingService billingService;
/**
* 按次扣费:按「免费试用额度 → 次数包 → 钱包余额」优先级完成一次调用计费,
* 同一 {@code requestId} 重复调用返回首次扣费结果
*
* @param request 扣费请求
* @return 扣费结果,含扣费模式、扣减金额与扣费后余额
*/
@PostMapping("/deduct")
public R<DeductResultDTO> deduct(@RequestBody DeductRequestDTO request) {
return R.ok(billingService.deduct(request));
}
}
@@ -0,0 +1,38 @@
package com.starsee.starapi.calllog.controller.internal;
import lombok.RequiredArgsConstructor;
import com.starsee.starapi.calllog.api.CallLogService;
import com.starsee.starapi.calllog.api.domain.CallLogDTO;
import com.starsee.starapi.common.core.domain.R;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 内网调用日志接口(仅供网关等服务端内部调用,由内网令牌统一鉴权,不走后台登录鉴权)
*
* @author x6g2
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/internal/calllog")
public class InternalCallLogController {
private final CallLogService callLogService;
/**
* 写入一次开放接口调用日志,同一 {@code requestId} 重复写入只落库一次
*
* @param callLog 调用日志
* @return 操作结果
*/
@PostMapping("/record")
public R<Void> record(@RequestBody CallLogDTO callLog) {
callLogService.recordCallLog(callLog);
return R.ok();
}
}