From 4ef46e6fa31d28d8ee91987f4d7a3faac6413884 Mon Sep 17 00:00:00 2001 From: Toyas Date: Sat, 29 Mar 2025 21:30:20 +0800 Subject: [PATCH] feat(gateway): Implement gateway module foundation --- video-gateway/pom.xml | 56 +++++++ .../nuaa/video/gateway/config/CorsConfig.java | 30 ++++ .../gateway/config/DynamicRouteConfig.java | 70 +++++++++ .../gateway/config/GatewayFallbackConfig.java | 78 ++++++++++ .../gateway/filter/AuthGlobalFilter.java | 144 ++++++++++++++++++ .../gateway/filter/RequestLogFilter.java | 49 ++++++ .../handler/GlobalExceptionHandler.java | 77 ++++++++++ .../src/main/resources/application-dev.yml | 15 ++ .../src/main/resources/application.yml | 42 ++++- 9 files changed, 556 insertions(+), 5 deletions(-) create mode 100644 video-gateway/src/main/java/com/nuaa/video/gateway/config/CorsConfig.java create mode 100644 video-gateway/src/main/java/com/nuaa/video/gateway/config/DynamicRouteConfig.java create mode 100644 video-gateway/src/main/java/com/nuaa/video/gateway/config/GatewayFallbackConfig.java create mode 100644 video-gateway/src/main/java/com/nuaa/video/gateway/filter/AuthGlobalFilter.java create mode 100644 video-gateway/src/main/java/com/nuaa/video/gateway/filter/RequestLogFilter.java create mode 100644 video-gateway/src/main/java/com/nuaa/video/gateway/handler/GlobalExceptionHandler.java create mode 100644 video-gateway/src/main/resources/application-dev.yml diff --git a/video-gateway/pom.xml b/video-gateway/pom.xml index 58927f2..c26f197 100644 --- a/video-gateway/pom.xml +++ b/video-gateway/pom.xml @@ -24,6 +24,12 @@ spring-cloud-starter-alibaba-nacos-discovery + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + com.alibaba.cloud @@ -41,5 +47,55 @@ org.springframework.boot spring-boot-starter-actuator + + + + org.projectlombok + lombok + true + + + + + commons-io + commons-io + 2.15.1 + + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + \ No newline at end of file diff --git a/video-gateway/src/main/java/com/nuaa/video/gateway/config/CorsConfig.java b/video-gateway/src/main/java/com/nuaa/video/gateway/config/CorsConfig.java new file mode 100644 index 0000000..4144ecb --- /dev/null +++ b/video-gateway/src/main/java/com/nuaa/video/gateway/config/CorsConfig.java @@ -0,0 +1,30 @@ +package com.nuaa.video.gateway.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsWebFilter; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * 跨域配置 + */ +@Configuration +public class CorsConfig { + + @Bean + public CorsWebFilter corsFilter() { + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOriginPattern("*"); // 允许所有来源 + config.addAllowedHeader("*"); // 允许所有请求头 + config.addAllowedMethod("*"); // 允许所有方法 + config.setAllowCredentials(true); // 允许携带凭证 + config.setMaxAge(3600L); // 预检请求的有效期,单位为秒 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser()); + source.registerCorsConfiguration("/**", config); + + return new CorsWebFilter(source); + } +} \ No newline at end of file diff --git a/video-gateway/src/main/java/com/nuaa/video/gateway/config/DynamicRouteConfig.java b/video-gateway/src/main/java/com/nuaa/video/gateway/config/DynamicRouteConfig.java new file mode 100644 index 0000000..b0ecd4b --- /dev/null +++ b/video-gateway/src/main/java/com/nuaa/video/gateway/config/DynamicRouteConfig.java @@ -0,0 +1,70 @@ +package com.nuaa.video.gateway.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.event.RefreshRoutesEvent; +import org.springframework.cloud.gateway.route.RouteDefinition; +import org.springframework.cloud.gateway.route.RouteDefinitionWriter; +import org.springframework.cloud.gateway.support.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import jakarta.annotation.Resource; + +/** + * 动态路由配置 + */ +@Slf4j +@Component +public class DynamicRouteConfig implements ApplicationEventPublisherAware { + + @Resource + private RouteDefinitionWriter routeDefinitionWriter; + + private ApplicationEventPublisher publisher; + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.publisher = applicationEventPublisher; + } + + /** + * 添加路由 + */ + public void addRoute(RouteDefinition definition) { + try { + routeDefinitionWriter.save(Mono.just(definition)).subscribe(); + this.publisher.publishEvent(new RefreshRoutesEvent(this)); + log.info("成功添加路由: {}", definition.getId()); + } catch (Exception e) { + log.error("添加路由失败: {}", definition.getId(), e); + } + } + + /** + * 更新路由 + */ + public void updateRoute(RouteDefinition definition) { + try { + this.deleteRoute(definition.getId()); + this.addRoute(definition); + log.info("成功更新路由: {}", definition.getId()); + } catch (Exception e) { + log.error("更新路由失败: {}", definition.getId(), e); + } + } + + /** + * 删除路由 + */ + public void deleteRoute(String id) { + try { + this.routeDefinitionWriter.delete(Mono.just(id)).subscribe(); + this.publisher.publishEvent(new RefreshRoutesEvent(this)); + log.info("成功删除路由: {}", id); + } catch (Exception e) { + log.error("删除路由失败: {}", id, e); + } + } +} \ No newline at end of file diff --git a/video-gateway/src/main/java/com/nuaa/video/gateway/config/GatewayFallbackConfig.java b/video-gateway/src/main/java/com/nuaa/video/gateway/config/GatewayFallbackConfig.java new file mode 100644 index 0000000..fff0c5b --- /dev/null +++ b/video-gateway/src/main/java/com/nuaa/video/gateway/config/GatewayFallbackConfig.java @@ -0,0 +1,78 @@ +package com.nuaa.video.gateway.config; + +import com.alibaba.csp.sentinel.adapter.gateway.common.rule.GatewayFlowRule; +import com.alibaba.csp.sentinel.adapter.gateway.common.rule.GatewayRuleManager; +import com.alibaba.csp.sentinel.adapter.gateway.sc.callback.GatewayCallbackManager; +import com.alibaba.csp.sentinel.adapter.gateway.sc.callback.BlockRequestHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.server.ServerResponse; + +import jakarta.annotation.PostConstruct; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 网关降级配置 + */ +@Configuration +public class GatewayFallbackConfig { + + /** + * 初始化自定义限流响应和规则 + */ + @PostConstruct + public void doInit() { + // 自定义限流响应 + initBlockHandlers(); + // 初始化QPS限流规则 + initQpsLimitRule(); + } + + /** + * 自定义限流响应 + */ + private void initBlockHandlers() { + BlockRequestHandler blockRequestHandler = (serverWebExchange, throwable) -> { + Map response = new HashMap<>(); + response.put("code", HttpStatus.TOO_MANY_REQUESTS.value()); + response.put("message", "请求过于频繁,请稍后再试"); + + return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(response)); + }; + + GatewayCallbackManager.setBlockHandler(blockRequestHandler); + } + + /** + * 初始化QPS限流规则 + */ + private void initQpsLimitRule() { + Set rules = new HashSet<>(); + + // 对每个API路径设置限流 + // 用户服务限流 + rules.add(new GatewayFlowRule("video-user") + .setCount(100) + .setIntervalSec(1)); + + // 内容服务限流 + rules.add(new GatewayFlowRule("video-content") + .setCount(200) + .setIntervalSec(1)); + + // 认证服务限流 + rules.add(new GatewayFlowRule("video-auth") + .setCount(50) + .setIntervalSec(1)); + + // 加载限流规则 + GatewayRuleManager.loadRules(rules); + } +} \ No newline at end of file diff --git a/video-gateway/src/main/java/com/nuaa/video/gateway/filter/AuthGlobalFilter.java b/video-gateway/src/main/java/com/nuaa/video/gateway/filter/AuthGlobalFilter.java new file mode 100644 index 0000000..6fb68a5 --- /dev/null +++ b/video-gateway/src/main/java/com/nuaa/video/gateway/filter/AuthGlobalFilter.java @@ -0,0 +1,144 @@ +package com.nuaa.video.gateway.filter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * JWT认证过滤器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthGlobalFilter implements GlobalFilter, Ordered { + + @Value("${jwt.secret:video-platform-secret-key-2024-nuaa}") + private String secret; + + @Value("${jwt.token-header:Authorization}") + private String tokenHeader; + + @Value("${gateway.ignore.urls:/auth/login,/auth/register,/auth/captcha}") + private List ignoreUrls; + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + + // 跳过不需要验证的路径 + if (isIgnorePath(path)) { + return chain.filter(exchange); + } + + // 获取token + String token = getToken(request); + if (!StringUtils.hasText(token)) { + return unauthorized(exchange, "缺少token"); + } + + try { + // 验证token + Claims claims = parseToken(token); + + // 将用户信息传递到下游服务 + ServerHttpRequest.Builder mutate = request.mutate(); + mutate.header("userId", claims.getSubject()); + mutate.header("username", claims.get("username", String.class)); + return chain.filter(exchange.mutate().request(mutate.build()).build()); + } catch (Exception e) { + log.error("Token验证失败", e); + return unauthorized(exchange, "Token验证失败"); + } + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + + /** + * 检查是否为忽略路径 + */ + private boolean isIgnorePath(String path) { + return ignoreUrls.stream().anyMatch(ignore -> pathMatcher.match(ignore, path)); + } + + /** + * 从请求中获取token + */ + private String getToken(ServerHttpRequest request) { + String token = request.getHeaders().getFirst(tokenHeader); + if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { + return token.substring(7); + } + return null; + } + + /** + * 解析token + */ + private Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * 获取签名密钥 + */ + private SecretKey getSigningKey() { + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * 返回未授权响应 + */ + private Mono unauthorized(ServerWebExchange exchange, String message) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().add("Content-Type", "application/json;charset=UTF-8"); + + Map result = new HashMap<>(); + result.put("code", HttpStatus.UNAUTHORIZED.value()); + result.put("message", message); + + try { + byte[] bytes = objectMapper.writeValueAsBytes(result); + DataBuffer buffer = response.bufferFactory().wrap(bytes); + return response.writeWith(Mono.just(buffer)); + } catch (JsonProcessingException e) { + log.error("响应JSON转换异常", e); + return response.setComplete(); + } + } +} \ No newline at end of file diff --git a/video-gateway/src/main/java/com/nuaa/video/gateway/filter/RequestLogFilter.java b/video-gateway/src/main/java/com/nuaa/video/gateway/filter/RequestLogFilter.java new file mode 100644 index 0000000..02635fa --- /dev/null +++ b/video-gateway/src/main/java/com/nuaa/video/gateway/filter/RequestLogFilter.java @@ -0,0 +1,49 @@ +package com.nuaa.video.gateway.filter; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +/** + * 请求日志过滤器 + */ +@Slf4j +@Component +public class RequestLogFilter implements GlobalFilter, Ordered { + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + URI uri = request.getURI(); + String method = request.getMethod().name(); + String host = request.getRemoteAddress().getHostString(); + + // 记录请求日志 + log.info("Request: {} {} from {}", method, uri, host); + + // 记录请求头信息(生产环境中可以根据需要调整日志级别) + if (log.isDebugEnabled()) { + Map> headers = request.getHeaders(); + headers.forEach((name, values) -> + log.debug("Header: {} = {}", name, String.join(",", values))); + } + + // 执行后续过滤器 + return chain.filter(exchange).then(Mono.fromRunnable(() -> + log.info("Response for: {} {}", method, uri))); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE + 10; + } +} \ No newline at end of file diff --git a/video-gateway/src/main/java/com/nuaa/video/gateway/handler/GlobalExceptionHandler.java b/video-gateway/src/main/java/com/nuaa/video/gateway/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..2e894d6 --- /dev/null +++ b/video-gateway/src/main/java/com/nuaa/video/gateway/handler/GlobalExceptionHandler.java @@ -0,0 +1,77 @@ +package com.nuaa.video.gateway.handler; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; // 导入HttpStatusCode +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +/** + * 全局异常处理器 + */ +@Slf4j +@Order(-1) +@Component +@RequiredArgsConstructor +public class GlobalExceptionHandler implements ErrorWebExceptionHandler { + + private final ObjectMapper objectMapper; + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + ServerHttpResponse response = exchange.getResponse(); + + // 设置响应的状态码 + if (response.isCommitted()) { + return Mono.error(ex); + } + + HttpStatusCode status; // 修改为HttpStatusCode类型 + String message; + int statusCode; // 添加一个int类型的状态码变量 + + // 根据异常类型设置响应状态和消息 + if (ex instanceof ResponseStatusException) { + ResponseStatusException responseStatusException = (ResponseStatusException) ex; + status = responseStatusException.getStatusCode(); + message = responseStatusException.getReason(); + statusCode = status.value(); // 获取状态码的数值 + } else { + status = HttpStatus.INTERNAL_SERVER_ERROR; + message = "服务器内部错误"; + statusCode = HttpStatus.INTERNAL_SERVER_ERROR.value(); + } + + log.error("网关异常: {}", message, ex); + + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + response.setStatusCode(status); + + // 构建响应体 + Map result = new HashMap<>(); + result.put("code", statusCode); // 使用状态码数值 + result.put("message", message); + + try { + byte[] bytes = objectMapper.writeValueAsBytes(result); + DataBuffer buffer = response.bufferFactory().wrap(bytes); + return response.writeWith(Mono.just(buffer)); + } catch (JsonProcessingException e) { + log.error("响应JSON转换异常", e); + return response.setComplete(); + } + } +} \ No newline at end of file diff --git a/video-gateway/src/main/resources/application-dev.yml b/video-gateway/src/main/resources/application-dev.yml new file mode 100644 index 0000000..a6a47c6 --- /dev/null +++ b/video-gateway/src/main/resources/application-dev.yml @@ -0,0 +1,15 @@ +spring: + cloud: + sentinel: + log: + dir: ./logs/sentinel/ + switch-pid: true + gateway: + httpclient: + connect-timeout: 60000 + response-timeout: 60000 + +logging: + level: + org.springframework.cloud.gateway: debug + reactor.netty.http.client: debug \ No newline at end of file diff --git a/video-gateway/src/main/resources/application.yml b/video-gateway/src/main/resources/application.yml index d89b735..66798c2 100644 --- a/video-gateway/src/main/resources/application.yml +++ b/video-gateway/src/main/resources/application.yml @@ -4,12 +4,26 @@ server: spring: application: name: video-gateway + profiles: + active: dev + config: + import: + - optional:nacos:${spring.application.name}.yaml cloud: nacos: discovery: - server-addr: 5.180.253.115:8848 - namespace: public - group: DEFAULT_GROUP + server-addr: ${NACOS_HOST:5.180.253.115}:${NACOS_PORT:8848} + namespace: ${NACOS_NAMESPACE:public} + group: ${NACOS_GROUP:DEFAULT_GROUP} + config: + server-addr: ${spring.cloud.nacos.discovery.server-addr} + namespace: ${spring.cloud.nacos.discovery.namespace} + group: ${spring.cloud.nacos.discovery.group} + file-extension: yaml + extension-configs: + - data-id: video-gateway.yaml + group: ${NACOS_GROUP:DEFAULT_GROUP} + refresh: true gateway: discovery: locator: @@ -54,7 +68,7 @@ spring: - StripPrefix=1 sentinel: transport: - dashboard: 5.180.253.115:8080 + dashboard: ${SENTINEL_DASHBOARD_HOST:5.180.253.115}:${SENTINEL_DASHBOARD_PORT:8080} port: 8719 eager: true scg: @@ -66,4 +80,22 @@ management: endpoints: web: exposure: - include: '*' \ No newline at end of file + include: '*' + endpoint: + health: + show-details: always + gateway: + enabled: true + +# 不需要鉴权的URL列表 +gateway: + ignore: + urls: + - /auth/login + - /auth/register + - /auth/captcha + - /*/v3/api-docs + - /swagger-resources/** + - /swagger-ui/** + - /actuator/** + - /favicon.ico \ No newline at end of file