初始化

This commit is contained in:
zg
2026-07-15 16:13:27 +08:00
parent 6d6f559793
commit c4023c1623
276 changed files with 42622 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.mokee</groupId>
<artifactId>mokee-gateway</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>mokee-gateway-gateway</artifactId>
<name>mokee-gateway-gateway</name>
<description>网关服务 — 路由转发/Token校验/请求头注入/限流/CORS</description>
<dependencies>
<dependency>
<groupId>com.mokee</groupId>
<artifactId>mokee-gateway-common</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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("========================================");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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)));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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)));
}
}

View File

@@ -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");
}
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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

View 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

View 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