初始化
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
package com.mokee.gateway;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 网关服务 — 启动类
|
||||
* <p>
|
||||
* 负责路由转发、Token 校验、API 权限控制、请求头注入、限流、CORS
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = {"com.mokee.gateway", "com.mokee.common"})
|
||||
@MapperScan("com.mokee.gateway.mapper")
|
||||
public class GatewayApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(GatewayApplication.class, args);
|
||||
System.out.println("========================================");
|
||||
System.out.println(" 网关服务启动成功! 端口: 9000");
|
||||
System.out.println("========================================");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.mokee.gateway.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* CORS 跨域配置
|
||||
* <p>
|
||||
* 允许所有来源、所有方法和所有请求头,支持携带凭证
|
||||
*/
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("*")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.mokee.gateway.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.ResponseErrorHandler;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* RestTemplate 配置
|
||||
* <p>
|
||||
* 关键设计:不使用默认的错误处理器(默认会对 4xx/5xx 抛异常),
|
||||
* 而是对所有响应(包括错误响应)直接返回原始 ResponseEntity,
|
||||
* 让网关能原样转发业务后端的错误信息给前端。
|
||||
*/
|
||||
@Configuration
|
||||
public class RestTemplateConfig {
|
||||
|
||||
@Bean
|
||||
public RestTemplate restTemplate() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(5000);
|
||||
factory.setReadTimeout(60000);
|
||||
|
||||
RestTemplate restTemplate = new RestTemplate(factory);
|
||||
// 禁用默认错误处理:遇到 4xx/5xx 不抛异常,原样返回响应体
|
||||
// 这样业务后端的错误信息(code/msg)能完整到达前端
|
||||
restTemplate.setErrorHandler(new ResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
return false; // 永远不认为有错误 → 不抛异常
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleError(org.springframework.http.client.ClientHttpResponse response) {
|
||||
// 不处理,让所有响应原样返回
|
||||
}
|
||||
});
|
||||
return restTemplate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.mokee.gateway.filter;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.mokee.common.Result;
|
||||
import com.mokee.common.dto.response.TokenInfo;
|
||||
import com.mokee.common.entity.SysApi;
|
||||
import com.mokee.common.entity.SysSystem;
|
||||
import com.mokee.gateway.mapper.SysApiMapper;
|
||||
import com.mokee.gateway.mapper.SysSystemMapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API 权限校验过滤器
|
||||
* <p>
|
||||
* 通过 X-System-Code 头匹配业务系统的 system_code,查询该系统中是否配置了当前请求的 API
|
||||
* 支持路径通配(如 /api/v1/user/** 匹配 /api/v1/user/list)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ApiPermissionFilter extends OncePerRequestFilter {
|
||||
|
||||
private final SysSystemMapper sysSystemMapper;
|
||||
private final SysApiMapper sysApiMapper;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// 非 /zgapi/ 路径放行(如静态资源、网关自身接口)
|
||||
if (!uri.startsWith("/zgapi/")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 网关自身的认证接口放行(这些接口属于网关基础设施,由 TokenAuthFilter 校验身份即可,不需 API 白名单)
|
||||
if (uri.startsWith("/zgapi/v1/auth/")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 网关管理后台接口也放行(管理后台不通过业务系统 API 白名单控制)
|
||||
if (uri.startsWith("/zgapi/v1/admin/")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 通过 X-System-Code 头匹配:有该头的为业务系统请求,需要 API 权限校验
|
||||
String systemCode = request.getHeader("X-System-Code");
|
||||
if (systemCode == null || systemCode.isEmpty()) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
SysSystem system = sysSystemMapper.selectOne(
|
||||
new LambdaQueryWrapper<SysSystem>()
|
||||
.eq(SysSystem::getSystemCode, systemCode)
|
||||
.eq(SysSystem::getStatus, 1)
|
||||
);
|
||||
if (system == null) {
|
||||
log.warn("业务系统不存在或已禁用 - systemCode: {}", systemCode);
|
||||
sendForbidden(response, "业务系统不存在或已禁用");
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验接口是否在系统的放行 API 列表中
|
||||
String method = request.getMethod();
|
||||
List<SysApi> apiList = sysApiMapper.selectList(
|
||||
new LambdaQueryWrapper<SysApi>()
|
||||
.eq(SysApi::getSystemId, system.getId())
|
||||
.eq(SysApi::getStatus, 1)
|
||||
);
|
||||
|
||||
boolean matched = apiList.stream().anyMatch(api -> {
|
||||
// 请求方法必须一致(忽略大小写)
|
||||
if (!api.getApiMethod().equalsIgnoreCase(method)) {
|
||||
return false;
|
||||
}
|
||||
// 路径匹配:精确匹配 或 通配符匹配
|
||||
return matchPath(api.getApiPath(), uri);
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
log.warn("接口无访问权限 - systemCode: {}, path: {}, method: {}", systemCode, uri, method);
|
||||
sendForbidden(response, "接口无访问权限");
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径匹配,支持 /** 通配符
|
||||
* <p>
|
||||
* 例如:/api/v1/user/** 可匹配 /api/v1/user/list、/api/v1/user/1/detail 等
|
||||
*/
|
||||
private boolean matchPath(String apiPattern, String requestPath) {
|
||||
// 精确匹配
|
||||
if (apiPattern.equals(requestPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 通配符 /** 匹配
|
||||
if (apiPattern.endsWith("/**")) {
|
||||
String prefix = apiPattern.substring(0, apiPattern.length() - 3);
|
||||
return requestPath.startsWith(prefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 403 禁止访问响应
|
||||
*/
|
||||
private void sendForbidden(HttpServletResponse response, String message) throws IOException {
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(403);
|
||||
response.getWriter().write(JSONUtil.toJsonStr(Result.fail(403, message)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.mokee.gateway.filter;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.mokee.common.dto.response.RoleInfo;
|
||||
import com.mokee.common.dto.response.TokenInfo;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 请求头注入过滤器
|
||||
* <p>
|
||||
* 将 TokenInfo 中的用户信息、角色、当前系统等注入到请求头中,
|
||||
* 供下游服务通过 X-User-Id / X-User-Name 等头获取当前用户信息
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class HeaderInjectFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
TokenInfo tokenInfo = (TokenInfo) request.getAttribute("tokenInfo");
|
||||
|
||||
if (tokenInfo == null) {
|
||||
// 没有 TokenInfo(匿名请求),直接放行
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建包装后的请求,注入自定义请求头
|
||||
HeaderInjectRequestWrapper wrappedRequest = new HeaderInjectRequestWrapper(request, tokenInfo);
|
||||
filterChain.doFilter(wrappedRequest, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 HttpServletRequestWrapper,注入用户信息请求头
|
||||
*/
|
||||
private static class HeaderInjectRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
private final Map<String, String> customHeaders = new HashMap<>();
|
||||
|
||||
public HeaderInjectRequestWrapper(HttpServletRequest request, TokenInfo tokenInfo) {
|
||||
super(request);
|
||||
// 注入用户基础信息
|
||||
putIfNotNull("X-User-Id", tokenInfo.getUserId() != null ? String.valueOf(tokenInfo.getUserId()) : null);
|
||||
// X-User-Name: 真实姓名(URL 编码,防止中文在 HTTP 头中损坏)
|
||||
putIfNotNull("X-User-Name", tokenInfo.getRealName() != null ? URLEncoder.encode(tokenInfo.getRealName(), StandardCharsets.UTF_8) : null);
|
||||
putIfNotNull("X-User-Account", tokenInfo.getUsername());
|
||||
putIfNotNull("X-User-Email", tokenInfo.getEmail());
|
||||
putIfNotNull("X-User-Post", tokenInfo.getPost());
|
||||
|
||||
// 注入角色列表(JSON 格式)
|
||||
List<RoleInfo> roles = tokenInfo.getRoles();
|
||||
if (roles != null && !roles.isEmpty()) {
|
||||
customHeaders.put("X-User-Roles", JSONUtil.toJsonStr(roles));
|
||||
}
|
||||
|
||||
// 注入当前系统信息
|
||||
putIfNotNull("X-System-Id", tokenInfo.getCurrentSystemId() != null ? String.valueOf(tokenInfo.getCurrentSystemId()) : null);
|
||||
putIfNotNull("X-System-Code", tokenInfo.getCurrentSystemCode());
|
||||
putIfNotNull("X-System-Name", tokenInfo.getCurrentSystemName());
|
||||
}
|
||||
|
||||
private void putIfNotNull(String key, String value) {
|
||||
if (value != null) {
|
||||
customHeaders.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统信息头(X-System-*):原始请求已有则保留原始值(业务系统前端主动设置的),
|
||||
* 仅当原始请求没有时才用 tokenInfo 注入的值。
|
||||
* 用户信息头(X-User-*):始终用 tokenInfo 注入的值。
|
||||
*/
|
||||
private static final Set<String> SYSTEM_HEADERS = Set.of(
|
||||
"X-System-Id", "X-System-Code", "X-System-Name"
|
||||
);
|
||||
|
||||
@Override
|
||||
public String getHeader(String name) {
|
||||
// 系统信息头:原始请求已有则保留原始值
|
||||
if (SYSTEM_HEADERS.contains(name)) {
|
||||
String originalValue = super.getHeader(name);
|
||||
if (originalValue != null) {
|
||||
return originalValue;
|
||||
}
|
||||
}
|
||||
// 用户信息头 / 原始请求没有的系统头:返回注入的值
|
||||
String value = customHeaders.get(name);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
return super.getHeader(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> getHeaders(String name) {
|
||||
// 系统信息头:原始请求已有则保留原始值
|
||||
if (SYSTEM_HEADERS.contains(name)) {
|
||||
String originalValue = super.getHeader(name);
|
||||
if (originalValue != null) {
|
||||
return Collections.enumeration(Collections.singletonList(originalValue));
|
||||
}
|
||||
}
|
||||
String value = customHeaders.get(name);
|
||||
if (value != null) {
|
||||
return Collections.enumeration(Collections.singletonList(value));
|
||||
}
|
||||
return super.getHeaders(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> getHeaderNames() {
|
||||
// 合并原始请求头和自定义头
|
||||
Set<String> names = new HashSet<>(customHeaders.keySet());
|
||||
Enumeration<String> originalNames = super.getHeaderNames();
|
||||
while (originalNames.hasMoreElements()) {
|
||||
names.add(originalNames.nextElement());
|
||||
}
|
||||
return Collections.enumeration(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.mokee.gateway.filter;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.mokee.common.Result;
|
||||
import com.mokee.common.exception.ErrorCode;
|
||||
import com.mokee.common.utils.JwtUtil;
|
||||
import com.mokee.common.utils.RedisUtil;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 限流过滤器
|
||||
* <p>
|
||||
* 基于 Redis 计数器实现接口级别的限流,按 Token 标识用户
|
||||
* 只拦截 /zgapi/ 开头的业务请求,登录接口跳过限流
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private final RedisUtil redisUtil;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
/** 每秒钟最大请求数 */
|
||||
private static final int MAX_REQUESTS_PER_SECOND = 100;
|
||||
/** 限流 Redis Key 前缀 */
|
||||
private static final String RATE_KEY_PREFIX = "rate:api:";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// 只过滤 /zgapi/ 开头的请求
|
||||
if (!uri.startsWith("/zgapi/")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 登录接口跳过限流
|
||||
if (uri.equals("/zgapi/v1/auth/login")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 Authorization 头提取 Token,解析 tokenId 作为限流 Key
|
||||
String rateKey = RATE_KEY_PREFIX + "anonymous";
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
try {
|
||||
String token = authHeader.substring(7);
|
||||
Claims claims = jwtUtil.validateToken(token);
|
||||
rateKey = RATE_KEY_PREFIX + jwtUtil.getTokenId(claims);
|
||||
} catch (Exception e) {
|
||||
// Token 无效时使用 IP 作为限流 Key
|
||||
rateKey = RATE_KEY_PREFIX + "ip:" + request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
// Redis 计数器自增,首次设置 1 秒过期
|
||||
Long count = redisUtil.incr(rateKey);
|
||||
if (count == 1) {
|
||||
redisUtil.expire(rateKey, 1, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// 超过阈值返回 429
|
||||
if (count > MAX_REQUESTS_PER_SECOND) {
|
||||
log.warn("接口限流触发 - key: {}, count: {}, uri: {}", rateKey, count, uri);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(429);
|
||||
response.getWriter().write(JSONUtil.toJsonStr(Result.fail(ErrorCode.RATE_LIMITED.getCode(), ErrorCode.RATE_LIMITED.getMessage())));
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.mokee.gateway.filter;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.mokee.common.Result;
|
||||
import com.mokee.common.dto.response.TokenInfo;
|
||||
import com.mokee.common.exception.BusinessException;
|
||||
import com.mokee.common.exception.ErrorCode;
|
||||
import com.mokee.common.utils.JwtUtil;
|
||||
import com.mokee.common.utils.RedisUtil;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Token 认证过滤器(优先级最高)
|
||||
* <p>
|
||||
* 校验请求头中的 JWT Token 有效性,查询 Redis 会话并续期
|
||||
* 登录接口和静态资源直接放行
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(1)
|
||||
@RequiredArgsConstructor
|
||||
public class TokenAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
/** Token 续期时间(小时) */
|
||||
private static final int TOKEN_TTL_HOURS = 2;
|
||||
/** 续期阈值(秒),剩余 TTL 小于此值时续期 */
|
||||
private static final long RENEW_THRESHOLD_SECONDS = 1800;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
// OPTIONS 预检请求直接放行(CORS 跨域预检不带 Authorization 头)
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// 登录接口放行
|
||||
if ("/zgapi/v1/auth/login".equals(uri)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 开放 API — 获取/校验/查询/标记消费 Token 接口放行(无需认证,token 在请求参数中)
|
||||
if ("/zgapi/v1/open/token".equals(uri) || "/zgapi/v1/open/token/validate".equals(uri)
|
||||
|| "/zgapi/v1/open/token/info".equals(uri)
|
||||
|| "/zgapi/v1/open/token/consume".equals(uri)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 系统接入文档接口放行(通过 UUID 令牌访问,无需登录)
|
||||
if (uri.startsWith("/zgapi/v1/admin/system/doc/")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态资源放行(Knife4j / Swagger 文档相关)
|
||||
if (isStaticResource(uri)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 开放 API 请求(/zgapi/v1/open/*):使用 open token 验证
|
||||
if (uri.startsWith("/zgapi/v1/open/")) {
|
||||
authenticateOpenApi(request, response, filterChain);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isZgapiPath = uri.startsWith("/zgapi/");
|
||||
|
||||
// 提取 Token(优先 Authorization 头,其次 Cookie)
|
||||
String token = null;
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.substring(7);
|
||||
} else {
|
||||
token = getTokenFromCookie(request);
|
||||
}
|
||||
|
||||
// /zgapi/ 路径必须有 Token,否则返回 401
|
||||
if (isZgapiPath && (token == null || token.isEmpty())) {
|
||||
sendUnauthorized(response, "未提供有效的认证Token");
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 /zgapi/ 路径:无 Token 直接放行(业务系统匿名访问)
|
||||
if (!isZgapiPath && (token == null || token.isEmpty())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 以下为有 Token 时的统一认证逻辑(/zgapi/ 必须通过,非 /zgapi/ 非阻塞)
|
||||
try {
|
||||
Claims claims = jwtUtil.validateToken(token);
|
||||
String tokenId = jwtUtil.getTokenId(claims);
|
||||
|
||||
String redisKey = RedisUtil.key("auth:token:" + tokenId);
|
||||
String jsonStr = redisUtil.get(redisKey);
|
||||
if (jsonStr == null || jsonStr.isEmpty()) {
|
||||
if (isZgapiPath) {
|
||||
log.warn("Token 对应 Redis 会话不存在 - tokenId: {}", tokenId);
|
||||
sendUnauthorized(response, "Token已过期,请重新登录");
|
||||
return;
|
||||
}
|
||||
log.debug("Token 会话已过期(非阻塞放行) - tokenId: {}", tokenId);
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
TokenInfo tokenInfo = JSONUtil.toBean(jsonStr, TokenInfo.class);
|
||||
|
||||
// 每次请求都刷新 Token TTL(去掉 TTL 检查,GET + EXPIRE 共 2 次 Redis 往返)
|
||||
redisUtil.expire(redisKey, TOKEN_TTL_HOURS, TimeUnit.HOURS);
|
||||
|
||||
request.setAttribute("tokenInfo", tokenInfo);
|
||||
request.setAttribute("tokenId", tokenId);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (BusinessException e) {
|
||||
if (isZgapiPath) {
|
||||
log.warn("Token 校验失败 - {}", e.getMessage());
|
||||
sendUnauthorized(response, e.getMessage());
|
||||
return;
|
||||
}
|
||||
// 非 /zgapi/:Token 校验失败不阻塞
|
||||
log.debug("Token 校验失败(非阻塞放行) - {}", e.getMessage());
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开放 API Token 认证
|
||||
* <p>
|
||||
* Token 优先从 Authorization: Bearer 头提取;若无则从 query 参数 ?token= 提取
|
||||
* (文件下载等场景通过浏览器标签直接访问,无法自定义 Header)。
|
||||
*/
|
||||
private void authenticateOpenApi(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws IOException, ServletException {
|
||||
String token = null;
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.substring(7);
|
||||
} else {
|
||||
// 兜底:从 query 参数读取(用于 GET 下载等浏览器直连场景)
|
||||
token = request.getParameter("token");
|
||||
}
|
||||
if (token == null || token.isEmpty()) {
|
||||
sendUnauthorized(response, "未提供 Open API Token");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Claims claims = jwtUtil.validateToken(token);
|
||||
String tokenId = jwtUtil.getTokenId(claims);
|
||||
String type = claims.get("type", String.class);
|
||||
if (!"open_api".equals(type)) {
|
||||
sendUnauthorized(response, "Token 类型错误,请使用 Open API Token");
|
||||
return;
|
||||
}
|
||||
|
||||
String redisKey = RedisUtil.key("open:token:" + tokenId);
|
||||
String jsonStr = redisUtil.get(redisKey);
|
||||
if (jsonStr == null) {
|
||||
sendUnauthorized(response, "Open API Token 已过期");
|
||||
return;
|
||||
}
|
||||
|
||||
// 将系统信息存入 request 属性(跨进程转发不会丢失)
|
||||
cn.hutool.json.JSONObject info = cn.hutool.json.JSONUtil.parseObj(jsonStr);
|
||||
request.setAttribute("openSystemId", info.getStr("systemId"));
|
||||
request.setAttribute("openSystemCode", info.getStr("systemCode"));
|
||||
request.setAttribute("openTokenId", tokenId);
|
||||
|
||||
// 放行请求,继续过滤链 → GatewayController → admin 服务
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (com.mokee.common.exception.BusinessException e) {
|
||||
sendUnauthorized(response, e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("Open API Token 校验异常 - uri: {}, error: {}", request.getRequestURI(), e.getMessage());
|
||||
sendUnauthorized(response, "Token 校验失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Cookie 中提取 token 值(用于图片等资源请求,浏览器不带 Authorization 头但会发 Cookie)
|
||||
*/
|
||||
private String getTokenFromCookie(HttpServletRequest request) {
|
||||
jakarta.servlet.http.Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) return null;
|
||||
for (jakarta.servlet.http.Cookie cookie : cookies) {
|
||||
if ("token".equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为静态资源路径
|
||||
*/
|
||||
private boolean isStaticResource(String uri) {
|
||||
return uri.startsWith("/doc.html")
|
||||
|| uri.startsWith("/webjars/")
|
||||
|| uri.startsWith("/v3/api-docs")
|
||||
|| uri.startsWith("/swagger-resources")
|
||||
|| uri.startsWith("/favicon.ico");
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 401 未授权响应(带 CORS 头,避免浏览器 ORB 拦截)
|
||||
*/
|
||||
private void sendUnauthorized(HttpServletResponse response, String message) throws IOException {
|
||||
response.setHeader("Access-Control-Allow-Origin", "*");
|
||||
response.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(401);
|
||||
response.getWriter().write(JSONUtil.toJsonStr(Result.fail(401, message)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.mokee.gateway.handler;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.mokee.common.entity.SysSystem;
|
||||
import com.mokee.gateway.mapper.SysSystemMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class GatewayController {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final SysSystemMapper sysSystemMapper;
|
||||
|
||||
@Value("${gateway.route.auth-url:http://127.0.0.1:9001}")
|
||||
private String authServiceUrl;
|
||||
|
||||
@Value("${gateway.route.admin-url:http://127.0.0.1:9002}")
|
||||
private String adminServiceUrl;
|
||||
|
||||
/**
|
||||
* 统一路由入口,匹配所有请求路径
|
||||
* <p>
|
||||
* 在方法内部根据 URI 前缀判断转发目标
|
||||
*/
|
||||
@RequestMapping("/**")
|
||||
public ResponseEntity<?> route(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
String method = request.getMethod();
|
||||
String queryString = request.getQueryString();
|
||||
|
||||
// 静态资源直接返回 200(由前端或 Nginx 处理)
|
||||
if (isStaticResource(uri)) {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
// 根据 URI 前缀确定目标服务地址
|
||||
String targetBaseUrl;
|
||||
if (uri.startsWith("/zgapi/v1/auth/")) {
|
||||
targetBaseUrl = authServiceUrl;
|
||||
} else if (uri.startsWith("/zgapi/v1/admin/")) {
|
||||
targetBaseUrl = adminServiceUrl;
|
||||
} else if (uri.startsWith("/zgapi/v1/open/")) {
|
||||
targetBaseUrl = adminServiceUrl;
|
||||
} else if (uri.startsWith("/zgapi/")) {
|
||||
// 业务系统请求:通过 X-System-Code 头匹配 system_code,查询后端真实地址
|
||||
String systemCode = request.getHeader("X-System-Code");
|
||||
if (systemCode == null || systemCode.isEmpty()) {
|
||||
log.warn("业务系统请求缺少 X-System-Code 头 - uri: {}", uri);
|
||||
return ResponseEntity.status(404)
|
||||
.body("X-System-Code 头缺失,无法识别业务系统");
|
||||
}
|
||||
SysSystem system = sysSystemMapper.selectOne(
|
||||
new LambdaQueryWrapper<SysSystem>()
|
||||
.eq(SysSystem::getSystemCode, systemCode)
|
||||
.eq(SysSystem::getStatus, 1)
|
||||
);
|
||||
if (system == null || system.getBackendUrl() == null) {
|
||||
log.warn("业务系统未找到或后端地址为空 - systemCode: {}", systemCode);
|
||||
return ResponseEntity.status(404)
|
||||
.body("业务系统不存在或后端地址未配置");
|
||||
}
|
||||
// 去掉后端地址末尾的斜杠
|
||||
targetBaseUrl = system.getBackendUrl().replaceAll("/+$", "");
|
||||
} else {
|
||||
// 非 /zgapi/ 路径:业务系统请求,通过 X-System-Code 头转发到业务后端
|
||||
String systemCode = request.getHeader("X-System-Code");
|
||||
if (systemCode != null && !systemCode.isEmpty()) {
|
||||
SysSystem system = sysSystemMapper.selectOne(
|
||||
new LambdaQueryWrapper<SysSystem>()
|
||||
.eq(SysSystem::getSystemCode, systemCode)
|
||||
.eq(SysSystem::getStatus, 1)
|
||||
);
|
||||
if (system != null && system.getBackendUrl() != null) {
|
||||
targetBaseUrl = system.getBackendUrl().replaceAll("/+$", "");
|
||||
} else {
|
||||
log.warn("业务系统不存在或未配置后端地址 - systemCode: {}", systemCode);
|
||||
return ResponseEntity.status(502)
|
||||
.body("{\"code\":502,\"msg\":\"业务系统未配置后端地址\"}");
|
||||
}
|
||||
} else {
|
||||
log.debug("非网关路由请求(无 X-System-Code) - uri: {}", uri);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接完整的目标 URL
|
||||
String targetUrl = targetBaseUrl + uri;
|
||||
if (queryString != null && !queryString.isEmpty()) {
|
||||
targetUrl += "?" + queryString;
|
||||
}
|
||||
|
||||
log.info("网关转发 - method: {}, uri: {} → {}", method, uri, targetUrl);
|
||||
|
||||
// 复制请求头(跳过不需要转发的头)
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
if ("host".equalsIgnoreCase(headerName)
|
||||
|| "content-length".equalsIgnoreCase(headerName)
|
||||
|| "transfer-encoding".equalsIgnoreCase(headerName)) {
|
||||
continue;
|
||||
}
|
||||
Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
List<String> values = new ArrayList<>();
|
||||
while (headerValues.hasMoreElements()) {
|
||||
values.add(headerValues.nextElement());
|
||||
}
|
||||
headers.put(headerName, values);
|
||||
}
|
||||
|
||||
// 读取请求体
|
||||
final byte[] body;
|
||||
try {
|
||||
body = request.getInputStream().readAllBytes();
|
||||
} catch (Exception e) {
|
||||
log.warn("读取请求体失败 - {}", e.getMessage());
|
||||
return ResponseEntity.status(500).body("{\"code\":500,\"msg\":\"读取请求体失败\"}");
|
||||
}
|
||||
|
||||
// 转发请求:multipart 请求使用流式转发避免 Content-Type 被 RestTemplate 修改
|
||||
try {
|
||||
ResponseEntity<byte[]> response;
|
||||
if (headers.getContentType() != null
|
||||
&& headers.getContentType().toString().startsWith("multipart/")) {
|
||||
// multipart 请求直接用 execute() 写入原始 body,保留 boundary
|
||||
final HttpHeaders fwdHeaders = headers;
|
||||
response = restTemplate.execute(
|
||||
URI.create(targetUrl),
|
||||
HttpMethod.valueOf(method.toUpperCase()),
|
||||
reqCallback -> {
|
||||
reqCallback.getHeaders().putAll(fwdHeaders);
|
||||
reqCallback.getBody().write(body);
|
||||
},
|
||||
clientHttpResponse -> {
|
||||
byte[] respBody = clientHttpResponse.getBody().readAllBytes();
|
||||
HttpHeaders respHeaders = new HttpHeaders();
|
||||
clientHttpResponse.getHeaders().forEach(
|
||||
(k, v) -> respHeaders.put(k, v));
|
||||
return new ResponseEntity<>(respBody, respHeaders,
|
||||
clientHttpResponse.getStatusCode());
|
||||
}
|
||||
);
|
||||
} else {
|
||||
HttpEntity<byte[]> httpEntity = new HttpEntity<>(body, headers);
|
||||
response = restTemplate.exchange(
|
||||
URI.create(targetUrl),
|
||||
HttpMethod.valueOf(method.toUpperCase()),
|
||||
httpEntity,
|
||||
byte[].class
|
||||
);
|
||||
}
|
||||
|
||||
// 返回响应,透传关键响应头
|
||||
HttpHeaders responseHeaders = new HttpHeaders();
|
||||
response.getHeaders().forEach((key, values) -> {
|
||||
// 跳过 HTTP/2 连接管理和不需要转发的头
|
||||
if ("transfer-encoding".equalsIgnoreCase(key)
|
||||
|| "connection".equalsIgnoreCase(key)) {
|
||||
return;
|
||||
}
|
||||
responseHeaders.put(key, values);
|
||||
});
|
||||
return new ResponseEntity<>(response.getBody(), responseHeaders, response.getStatusCode());
|
||||
} catch (Exception e) {
|
||||
log.error("网关转发失败 - uri: {}, targetUrl: {}, error: {}", uri, targetUrl, e.getMessage());
|
||||
return ResponseEntity.status(502)
|
||||
.body("{\"code\":502,\"msg\":\"网关转发失败: " + e.getMessage() + "\"}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为静态资源路径
|
||||
*/
|
||||
private boolean isStaticResource(String uri) {
|
||||
return uri.startsWith("/doc.html")
|
||||
|| uri.startsWith("/webjars/")
|
||||
|| uri.startsWith("/v3/api-docs")
|
||||
|| uri.startsWith("/swagger-resources")
|
||||
|| uri.startsWith("/favicon.ico");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.mokee.gateway.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.mokee.common.entity.SysApi;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 系统 API Mapper
|
||||
* <p>
|
||||
* 网关需要查询已配置的放行 API 列表用于权限校验
|
||||
*/
|
||||
@Mapper
|
||||
public interface SysApiMapper extends BaseMapper<SysApi> {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.mokee.gateway.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.mokee.common.entity.SysSystem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 系统信息 Mapper
|
||||
* <p>
|
||||
* 网关需要查询业务系统的后端地址用于路由转发
|
||||
*/
|
||||
@Mapper
|
||||
public interface SysSystemMapper extends BaseMapper<SysSystem> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.mokee.gateway.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.mokee.common.entity.SysUserSystem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 用户系统关联 Mapper
|
||||
*/
|
||||
@Mapper
|
||||
public interface SysUserSystemMapper extends BaseMapper<SysUserSystem> {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
spring:
|
||||
servlet:
|
||||
multipart:
|
||||
enabled: false
|
||||
datasource:
|
||||
url: jdbc:mysql://db1.prod.zgitm.com:3306/mokeegateway?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
|
||||
username: mokeegateway
|
||||
password: mokee.
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
data:
|
||||
redis:
|
||||
host: redis.db1.zgitm.com
|
||||
port: 6379
|
||||
password: mokee2016.
|
||||
database: 1
|
||||
timeout: 3000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
|
||||
|
||||
gateway:
|
||||
route:
|
||||
auth-url: http://10.10.1.170:9001
|
||||
admin-url: http://10.10.1.170:9002
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.mokee: INFO
|
||||
org.springframework.web.servlet.DispatcherServlet: DEBUG
|
||||
34
mokee-gateway-gateway/src/main/resources/application-qa.yml
Normal file
34
mokee-gateway-gateway/src/main/resources/application-qa.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
spring:
|
||||
servlet:
|
||||
multipart:
|
||||
enabled: false
|
||||
datasource:
|
||||
url: jdbc:mysql://db1.qa.zgitm.com:3306/mokeegatewayqa?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
|
||||
username: mokeegatewayqa
|
||||
password: mokee.
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
data:
|
||||
redis:
|
||||
host: redis.db2.zgitm.com
|
||||
port: 6379
|
||||
password: mokee2016.
|
||||
database: 2
|
||||
timeout: 3000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
|
||||
gateway:
|
||||
route:
|
||||
auth-url: http://127.0.0.1:9001
|
||||
admin-url: http://127.0.0.1:9002
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.mokee: INFO
|
||||
17
mokee-gateway-gateway/src/main/resources/application.yml
Normal file
17
mokee-gateway-gateway/src/main/resources/application.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
server:
|
||||
port: 9000
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: mokee-gateway-gateway
|
||||
profiles:
|
||||
active: @spring.profiles.active@
|
||||
|
||||
mybatis-plus:
|
||||
type-aliases-package: com.mokee.common.entity
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
|
||||
jwt:
|
||||
secret: mokee-gateway-jwt-secret-key-2024-must-be-256-bits!!
|
||||
expiration: 7200
|
||||
Reference in New Issue
Block a user