TASK:买家端 站内信

This commit is contained in:
2026-05-29 19:04:05 +08:00
parent 74efef7c24
commit 574ce00657
3 changed files with 214 additions and 1 deletions

View File

@@ -118,6 +118,12 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<!-- WebFlux (provides WebSocket support for Spring Cloud Gateway) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
</dependencies>
<dependencyManagement>

View File

@@ -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 认证过滤器(原生模式)
* <p>
* 拦截所有 WebSocket 升级请求(/ws/**),复用 JWT 验证逻辑,
* 验证通过后将 X-User-Id 和 X-User-Info 以 HTTP Header 形式注入到请求中。
* <p>
* 由于浏览器原生 WebSocket API 不会自动携带 Authorization Header
* 客户端在连接时需通过 query 参数传递 tokenws://host/ws?token=&lt;JWT&gt;
* (此方式同时适用于原生 WebSocket 和 SockJS 握手)。
* <p>
* 执行顺序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<String, String> redisTemplate;
private final ObjectMapper objectMapper;
@Override
public int getOrder() {
return ORDER;
}
@Override
public Mono<Void> 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 参数传递 tokenws://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 服务。
* <p>
* Spring Cloud Gateway 的 WebSocketRoutingFilter 会将这些 Header 原样转发,
* 下游 aida-buyer 通过 MessageWebSocketHandler 读取。
*/
private Mono<Void> 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<Void> 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));
}
}

View File

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