diff --git a/src/main/java/com/aida/gateway/filter/WebSocketAuthFilter.java b/src/main/java/com/aida/gateway/filter/WebSocketAuthFilter.java
new file mode 100644
index 0000000..f0f9ddd
--- /dev/null
+++ b/src/main/java/com/aida/gateway/filter/WebSocketAuthFilter.java
@@ -0,0 +1,187 @@
+package com.aida.gateway.filter;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import com.aida.gateway.common.AuthConstants;
+import com.aida.gateway.common.AuthPrincipalVo;
+import com.aida.gateway.config.GatewayAuthProperties;
+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.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.data.redis.core.ReactiveRedisTemplate;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * WebSocket 认证过滤器(原生模式)
+ *
+ * 拦截所有 WebSocket 升级请求(/ws/**),复用 JWT 验证逻辑,
+ * 验证通过后将 X-User-Id 和 X-User-Info 以 HTTP Header 形式注入到请求中。
+ *
+ * 由于浏览器原生 WebSocket API 不会自动携带 Authorization Header,
+ * 客户端在连接时需通过 query 参数传递 token:ws://host/ws?token=<JWT>
+ * (此方式同时适用于原生 WebSocket 和 SockJS 握手)。
+ *
+ * 执行顺序:HIGHEST_PRECEDENCE + 1(略高于 WebSocketRoutingFilter)
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class WebSocketAuthFilter implements GlobalFilter, Ordered {
+
+ private static final int ORDER = Ordered.HIGHEST_PRECEDENCE + 1;
+ private static final String WEBSOCKET_PATH_PREFIX = "/ws";
+
+ private final GatewayAuthProperties authProperties;
+ private final ReactiveRedisTemplate redisTemplate;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public int getOrder() {
+ return ORDER;
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ String path = exchange.getRequest().getURI().getPath();
+
+ // 仅处理 WebSocket 路径
+ if (!path.startsWith(WEBSOCKET_PATH_PREFIX)) {
+ return chain.filter(exchange);
+ }
+
+ // OPTIONS 预检放行
+ if ("OPTIONS".equalsIgnoreCase(exchange.getRequest().getMethod().name())) {
+ return chain.filter(exchange);
+ }
+
+ // ------------------- JWT 验证 -------------------
+ // 优先从 Authorization Header 获取
+ String rawHeader = exchange.getRequest().getHeaders().getFirst(AuthConstants.TOKEN_HEADER);
+
+ // 浏览器 WebSocket API 不会自动携带 Authorization Header,
+ // 通过 query 参数传递 token(ws://host/ws?token=xxx)
+ if (StrUtil.isBlank(rawHeader)) {
+ rawHeader = exchange.getRequest().getQueryParams().getFirst("token");
+ }
+ if (StrUtil.isBlank(rawHeader)) {
+ return writeUnauthorized(exchange, AuthConstants.MSG_MISSING_TOKEN + " (provide Authorization header or ?token= query param)");
+ }
+
+ String token = rawHeader;
+ // 仅当从 Header 获取时才尝试去掉 Bearer- 前缀(query param 直接使用原始值)
+ if (rawHeader.startsWith(authProperties.getJwtTokenPrefix())) {
+ token = rawHeader.substring(authProperties.getJwtTokenPrefix().length());
+ }
+
+ Claims claims;
+ try {
+ claims = parseToken(token);
+ } catch (Exception e) {
+ log.warn("[WS-Filter] JWT signature invalid or expired: {}", e.getMessage());
+ return writeUnauthorized(exchange, AuthConstants.MSG_TOKEN_EXPIRED);
+ }
+
+ AuthPrincipalVo principal;
+ try {
+ principal = objectMapper.readValue(claims.getSubject(), AuthPrincipalVo.class);
+ } catch (Exception e) {
+ log.warn("[WS-Filter] Failed to parse AuthPrincipalVo from JWT subject: {}", e.getMessage());
+ return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN);
+ }
+
+ if (principal == null || principal.getId() == null) {
+ return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN);
+ }
+
+ // ------------------- 黑名单检查 -------------------
+ if (authProperties.isBlacklistEnabled()) {
+ String blacklistKey = AuthConstants.BLACKLIST_PREFIX + principal.getId();
+ return redisTemplate.hasKey(blacklistKey)
+ .flatMap(isBlacklisted -> {
+ if (Boolean.TRUE.equals(isBlacklisted)) {
+ return writeUnauthorized(exchange, AuthConstants.MSG_TOKEN_BLACKLISTED);
+ }
+ return injectUserHeadersAndContinue(exchange, chain, principal);
+ })
+ .onErrorResume(e -> {
+ log.error("[WS-Filter] Redis check failed, allowing request", e);
+ return injectUserHeadersAndContinue(exchange, chain, principal);
+ });
+ }
+
+ return injectUserHeadersAndContinue(exchange, chain, principal);
+ }
+
+ /**
+ * 将用户信息以 HTTP Header 形式注入请求,传递给下游 WebSocket 服务。
+ *
+ * Spring Cloud Gateway 的 WebSocketRoutingFilter 会将这些 Header 原样转发,
+ * 下游 aida-buyer 通过 MessageWebSocketHandler 读取。
+ */
+ private Mono injectUserHeadersAndContinue(
+ ServerWebExchange exchange,
+ GatewayFilterChain chain,
+ AuthPrincipalVo principal) {
+
+ String userInfoJson;
+ try {
+ userInfoJson = objectMapper.writeValueAsString(principal);
+ } catch (Exception e) {
+ log.error("[WS-Filter] Failed to serialize principal", e);
+ return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN);
+ }
+
+ // 将用户信息作为 HTTP Header 注入,后续 WebSocketRoutingFilter 会原样转发到下游服务
+ ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
+ .header(AuthConstants.USER_ID_HEADER, String.valueOf(principal.getId()))
+ .header(AuthConstants.USER_INFO_HEADER, userInfoJson)
+ .build();
+
+ log.info("[WS-Filter] JWT verified for user {}, injecting auth headers", principal.getId());
+
+ return chain.filter(exchange.mutate().request(mutatedRequest).build());
+ }
+
+ private Claims parseToken(String token) {
+ SecretKey key = buildSigningKey();
+ return Jwts.parser()
+ .verifyWith(key)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ }
+
+ private SecretKey buildSigningKey() {
+ byte[] raw = authProperties.getJwtSecret().getBytes(StandardCharsets.UTF_8);
+ if (raw.length < 32) {
+ raw = DigestUtil.sha256(raw);
+ }
+ return Keys.hmacShaKeyFor(raw);
+ }
+
+ private Mono writeUnauthorized(ServerWebExchange exchange, String message) {
+ ServerHttpResponse response = exchange.getResponse();
+ response.setStatusCode(HttpStatus.UNAUTHORIZED);
+ response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
+ String body = String.format("{\"code\":401,\"message\":\"%s\"}", message);
+ DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
+ return response.writeWith(Mono.just(buffer));
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 1a51019..a808083 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -56,6 +56,20 @@ spring:
- Path=/buyer/**
filters:
- StripPrefix=1
+
+ # WebSocket 路由(STOMP over SockJS)
+ - id: aida-buyer-websocket
+ uri: lb://aida-buyer
+ predicates:
+ - Path=/ws/**
+ filters:
+ - StripPrefix=0
+ - id: payment-service
+ uri: lb://payment-service
+ predicates:
+ - Path=/payment/**
+ filters:
+ - StripPrefix=1
# ---------- Knife4j 网关聚合配置 ----------
knife4j:
gateway:
@@ -78,6 +92,11 @@ knife4j:
service-name: aida-buyer
context-path: /buyer
order: 3
+ - name: 支付服务 (Payment)
+ url: /payment/v3/api-docs
+ service-name: payment-service
+ context-path: /payment
+ order: 4
# ---------- Gateway JWT 认证(gateway 独有) ----------
gateway:
@@ -155,7 +174,8 @@ gateway:
- /aida/api/stripe/trade/notify
# Notification
- /notification/**
- # buyer
+ # WebSocket (由 WebSocketAuthFilter 负责 JWT 验证和 Header 注入)
+ - /ws/**
- /buyer/account/**
- /buyer/designer/shop/**
- /buyer/designer/search