This commit is contained in:
jiangh277
2025-12-24 14:17:19 +08:00
parent 3eb445291f
commit 4c7d59f87b
89 changed files with 3525 additions and 311 deletions

View File

@@ -2,12 +2,22 @@ package com.timeline.user.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();

View File

@@ -0,0 +1,61 @@
package com.timeline.user.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import java.security.Principal;
import java.util.Map;
/**
* STOMP 拦截器,用于在 STOMP CONNECT 时设置 Principal
*/
@Slf4j
@Component
public class StompPrincipalInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
// 从session attributes中获取userId
Map<String, Object> sessionAttrs = accessor.getSessionAttributes();
if (sessionAttrs != null) {
Object userId = sessionAttrs.get("userId");
if (userId != null && accessor.getUser() == null) {
String userIdStr = userId.toString();
log.info("STOMP CONNECT: 从session attributes设置Principal为userId: {}", userIdStr);
Principal principal = new XUserIdHandshakeInterceptor.UserPrincipal(userIdStr);
accessor.setUser(principal);
}
}
// 如果还没有Principal尝试从请求头获取
if (accessor.getUser() == null) {
String userId = accessor.getFirstNativeHeader("X-User-Id");
if (userId != null && !userId.isEmpty()) {
log.info("STOMP CONNECT: 从请求头设置Principal为userId: {}", userId);
Principal principal = new XUserIdHandshakeInterceptor.UserPrincipal(userId);
accessor.setUser(principal);
}
}
// 打印调试信息
Principal principal = accessor.getUser();
if (principal != null) {
log.info("STOMP CONNECT: Principal已设置用户: {}", principal.getName());
} else {
log.warn("STOMP CONNECT: 未设置PrincipalsessionAttributes: {}", sessionAttrs);
}
}
return message;
}
}

View File

@@ -0,0 +1,41 @@
package com.timeline.user.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
import java.security.Principal;
import java.util.Map;
@Slf4j
public class UserIdPrincipalHandshakeHandler extends DefaultHandshakeHandler {
@Override
protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
// 首先尝试从attributes中获取userId由XUserIdHandshakeInterceptor放入
Object userId = attributes.get("userId");
if (userId != null) {
String userIdStr = userId.toString();
log.info("WebSocket握手设置Principal为userId: {}attributes: {}", userIdStr, attributes.keySet());
// 使用 UserPrincipal 类而不是 lambda确保 Principal 对象正确
return new XUserIdHandshakeInterceptor.UserPrincipal(userIdStr);
}
// 如果没有userId则尝试获取username
Object username = attributes.get("username");
if (username != null) {
String usernameStr = username.toString();
log.info("WebSocket握手设置Principal为username: {}", usernameStr);
return new XUserIdHandshakeInterceptor.UserPrincipal(usernameStr);
}
// 如果都没有,则使用默认实现
log.warn("WebSocket握手未找到userId或usernameattributes: {}使用默认Principal", attributes.keySet());
Principal defaultPrincipal = super.determineUser(request, wsHandler, attributes);
if (defaultPrincipal != null) {
log.info("WebSocket握手使用默认Principal: {}", defaultPrincipal.getName());
}
return defaultPrincipal;
}
}

View File

@@ -1,21 +1,31 @@
package com.timeline.user.config;
import com.timeline.user.interceptor.UserContextInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private UserContextInterceptor userContextInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login", "/api/auth/register");
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 为SockJS添加静态资源映射处理/info等端点
registry.addResourceHandler("/user/ws/**")
.addResourceLocations("classpath:/static/");
// 添加对测试页面的静态资源映射
registry.addResourceHandler("/test/**")
.addResourceLocations("classpath:/static/");
}
}
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
// 配置内容协商策略确保能正确处理JSON响应
configurer.defaultContentType(MediaType.APPLICATION_JSON)
.favorParameter(false)
.favorPathExtension(false)
.ignoreAcceptHeader(false);
}
}

View File

@@ -0,0 +1,51 @@
package com.timeline.user.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Slf4j
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Autowired
private ApplicationContext applicationContext;
@Autowired
private StompPrincipalInterceptor stompPrincipalInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/user/ws")
.setAllowedOriginPatterns("*")
.addInterceptors(new XUserIdHandshakeInterceptor(applicationContext))
.setHandshakeHandler(new UserIdPrincipalHandshakeHandler())
.withSockJS();
log.info("WebSocket 端点已注册: /user/ws");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 启用简单的消息代理,支持 /topic 和 /queue 前缀
registry.enableSimpleBroker("/topic", "/queue");
// 设置应用程序目标前缀
registry.setApplicationDestinationPrefixes("/app");
// 设置用户目标前缀,用于点对点消息
registry.setUserDestinationPrefix("/user");
log.info("WebSocket 消息代理已配置: /topic, /queue, /app, /user");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// 添加STOMP拦截器用于在CONNECT时设置Principal
registration.interceptors(stompPrincipalInterceptor);
log.info("已配置客户端入站通道拦截器");
}
}

View File

@@ -0,0 +1,137 @@
package com.timeline.user.config;
import com.timeline.user.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.lang.NonNull;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import java.net.URI;
import java.security.Principal;
import java.util.Map;
@Slf4j
public class XUserIdHandshakeInterceptor implements HandshakeInterceptor {
// 从配置中获取JWT密钥
@Value("${jwt.secret:timelineSecretKey}")
private String jwtSecret = "timelineSecretKey";
@SuppressWarnings("unused")
private ApplicationContext applicationContext;
public XUserIdHandshakeInterceptor(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
public boolean beforeHandshake(@NonNull ServerHttpRequest request,
@NonNull ServerHttpResponse response,
@NonNull WebSocketHandler wsHandler,
@NonNull Map<String, Object> attributes) {
if (request instanceof ServletServerHttpRequest servletRequest) {
HttpServletRequest httpServletRequest = servletRequest.getServletRequest();
// 首先尝试从X-User-Id请求头获取userId来自网关转发
String userId = httpServletRequest.getHeader("X-User-Id");
String username = httpServletRequest.getHeader("X-Username");
// 如果没有从请求头获取到则尝试从查询参数中获取token并解析
if (userId == null || userId.isEmpty()) {
URI uri = request.getURI();
String query = uri.getRawQuery();
if (query != null) {
String[] params = query.split("&");
String token = null;
for (String param : params) {
// 支持 token=xxx 格式
if (param.startsWith("token=")) {
token = param.substring(6);
break;
}
// 支持 Authorization=Bearer xxx 或 Authorization=xxx 格式
if (param.startsWith("Authorization=")) {
String authValue = param.substring(14);
if (authValue.startsWith("Bearer ")) {
token = authValue.substring(7);
} else {
token = authValue;
}
break;
}
}
// 解析 token
if (token != null && !token.isEmpty()) {
try {
// URL 解码
token = java.net.URLDecoder.decode(token, java.nio.charset.StandardCharsets.UTF_8);
Claims claims = JwtUtils.parseToken(token, jwtSecret);
if (claims != null) {
userId = claims.get("userId", String.class);
username = claims.getSubject();
log.info("WebSocket握手从查询参数解析token获取到userId: {}", userId);
}
} catch (Exception e) {
log.warn("WebSocket握手解析token失败", e);
}
}
}
}
// 如果获取到userId则将其放入attributes中供后续使用
if (userId != null && !userId.isEmpty()) {
attributes.put("userId", userId);
servletRequest.getServletRequest().setAttribute("userId", userId);
log.info("WebSocket握手从请求头获取到userId: {}", userId);
} else {
log.warn("WebSocket握手未获取到userId可能无法正确建立连接");
}
// 如果获取到username也将其存储
if (username != null && !username.isEmpty()) {
attributes.put("username", username);
servletRequest.getServletRequest().setAttribute("username", username);
log.debug("WebSocket握手获取到username: {}", username);
}
}
return true;
}
@Override
public void afterHandshake(@NonNull ServerHttpRequest request,
@org.springframework.lang.Nullable ServerHttpResponse response,
@NonNull WebSocketHandler wsHandler,
@org.springframework.lang.Nullable Exception exception) {
// 在握手完成后检查userId是否已设置
if (request instanceof ServletServerHttpRequest servletRequest) {
String userId = (String) servletRequest.getServletRequest().getAttribute("userId");
if (userId != null) {
log.info("WebSocket握手完成userId: {}", userId);
} else {
log.warn("WebSocket握手完成但未找到userId");
}
}
}
public static class UserPrincipal implements Principal {
private final String name;
public UserPrincipal(String name) {
this.name = name;
}
@Override
public String getName() {
return name;
}
}
}

View File

@@ -1,34 +1,105 @@
package com.timeline.user.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.common.response.ResponseEnum;
import com.timeline.user.dto.LoginRequest;
import com.timeline.user.dto.LoginResponse;
import com.timeline.user.dto.RegisterRequest;
import com.timeline.user.dto.RefreshRequest;
import com.timeline.user.entity.User;
import com.timeline.user.service.UserAuthService;
import com.timeline.user.service.UserService;
import com.timeline.user.utils.JwtUtils;
import com.timeline.common.utils.RedisUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.NonNull;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequestMapping("/user/auth")
public class AuthController {
@Autowired
private UserService userService;
private UserAuthService userAuthService;
@Autowired
private JwtUtils jwtUtils;
@Autowired
private RedisUtils redisUtils;
private static final String TOKEN_BLACKLIST_PREFIX = "auth:token:blacklist:";
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest) {
log.info("用户登录请求: {}", loginRequest.getUsername());
LoginResponse response = userService.login(loginRequest);
LoginResponse response = userAuthService.login(loginRequest);
return ResponseEntity.success(response);
}
@PostMapping("/register")
public ResponseEntity<User> register(@RequestBody RegisterRequest registerRequest) {
log.info("用户注册请求: {}", registerRequest.getUsername());
User user = userService.register(registerRequest);
User user = userAuthService.register(registerRequest);
return ResponseEntity.success(user);
}
@PostMapping("/refresh")
public ResponseEntity<LoginResponse> refresh(@RequestBody RefreshRequest request) {
String refreshToken = request.getRefreshToken();
if (refreshToken == null || !jwtUtils.validateToken(refreshToken) || jwtUtils.isTokenExpired(refreshToken)) {
return ResponseEntity.error(ResponseEnum.UNAUTHORIZED, "无效的刷新令牌");
}
if (!"refresh".equals(jwtUtils.getTokenType(refreshToken))) {
return ResponseEntity.error(ResponseEnum.UNAUTHORIZED, "令牌类型错误");
}
String userId = jwtUtils.getUserIdFromToken(refreshToken);
String username = jwtUtils.getUsernameFromToken(refreshToken);
String newAccess = jwtUtils.generateAccessToken(userId, username);
String newRefresh = jwtUtils.generateRefreshToken(userId, username);
LoginResponse resp = new LoginResponse(newAccess, newRefresh, jwtUtils.getAccessExpirationSeconds(), userId, username);
return ResponseEntity.success(resp);
}
@PostMapping("/logout")
public ResponseEntity<String> logout(@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody(required = false) RefreshRequest request) {
String accessToken = extractToken(authHeader);
String refreshToken = request != null ? request.getRefreshToken() : null;
boolean hasToken = false;
if (accessToken != null) {
blacklist(accessToken);
hasToken = true;
}
if (refreshToken != null && jwtUtils.validateToken(refreshToken)) {
blacklist(refreshToken);
hasToken = true;
}
if (!hasToken) {
return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "缺少可注销的令牌");
}
return ResponseEntity.success("已退出登录");
}
@SuppressWarnings("null")
private void blacklist(@NonNull String token) {
long ttlSeconds = jwtUtils.getRemainingSeconds(token);
if (ttlSeconds <= 0) {
return;
}
Duration ttl = Duration.ofSeconds(ttlSeconds);
redisUtils.set(TOKEN_BLACKLIST_PREFIX + token, "1", ttl);
}
private String extractToken(String authHeader) {
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
}

View File

@@ -0,0 +1,67 @@
package com.timeline.user.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.user.dto.FriendRequestDto;
import com.timeline.user.dto.FriendUserDto;
import com.timeline.user.entity.Friend;
import com.timeline.user.entity.FriendNotify;
import com.timeline.user.service.FriendService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/user/friend")
public class FriendController {
@Autowired
private FriendService friendService;
@PostMapping("/request")
public ResponseEntity<String> requestFriend(@RequestBody FriendRequestDto dto) {
friendService.requestFriend(dto.getFriendId());
return ResponseEntity.success("请求已发送");
}
@PostMapping("/accept")
public ResponseEntity<String> accept(@RequestBody FriendRequestDto dto) {
friendService.acceptFriend(dto.getFriendId());
return ResponseEntity.success("已接受");
}
@PostMapping("/reject")
public ResponseEntity<String> reject(@RequestBody FriendRequestDto dto) {
friendService.rejectFriend(dto.getFriendId());
return ResponseEntity.success("已拒绝");
}
@GetMapping("/list")
public ResponseEntity<List<FriendUserDto>> list() {
return ResponseEntity.success(friendService.listFriends());
}
/* @GetMapping("/ids")
public ResponseEntity<List<String>> friendIds() {
return ResponseEntity.success(friendService.listFriendIds());
} */
@GetMapping("/pending")
public ResponseEntity<List<Friend>> pending() {
return ResponseEntity.success(friendService.listPending());
}
@GetMapping("/notify/unread")
public ResponseEntity<List<FriendNotify>> unreadNotify() {
return ResponseEntity.success(friendService.listUnreadNotify());
}
@PostMapping("/notify/read/{id}")
public ResponseEntity<String> markRead(@PathVariable Long id) {
friendService.markNotifyRead(id);
return ResponseEntity.success("已读");
}
}

View File

@@ -0,0 +1,129 @@
package com.timeline.user.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.user.service.UserMessageService;
import com.timeline.user.ws.WsNotifyService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/user/message")
public class MessagePushController {
@Autowired
private WsNotifyService wsNotifyService;
@Autowired
private UserMessageService userMessageService;
/**
* 向指定用户推送自定义消息
*
* @param toUserId 目标用户ID
* @param destination 消息目的地
* @param message 消息内容
* @return 响应结果
*/
@PostMapping("/push")
public ResponseEntity<String> pushMessageToUser(
@RequestParam String toUserId,
@RequestParam String destination,
@RequestBody String message) {
Map<String, Object> payload = new HashMap<>();
payload.put("message", message);
payload.put("timestamp", System.currentTimeMillis());
payload.put("type", "custom");
wsNotifyService.pushMessageToUser(toUserId, destination, payload);
log.info("向用户 {} 推送消息到 {}: {}", toUserId, destination, message);
return ResponseEntity.success("消息已推送");
}
/**
* 向指定用户推送通知
*
* @param toUserId 目标用户ID
* @param request 通知内容
* @return 响应结果
*/
@PostMapping("/notify")
public ResponseEntity<String> sendNotificationToUser(
@RequestParam String toUserId,
@RequestBody NotificationRequest request) {
NotificationPayload payload = new NotificationPayload();
payload.setTitle(request.getTitle());
payload.setContent(request.getContent());
payload.setType(request.getType());
payload.setTimestamp(System.currentTimeMillis());
wsNotifyService.sendNotificationToUser(toUserId, payload);
// 同时存储为未读消息
userMessageService.addUnreadMessage(toUserId, Map.of(
"type", "notification",
"title", request.getTitle(),
"content", request.getContent(),
"notificationType", request.getType(),
"timestamp", System.currentTimeMillis()
));
log.info("向用户 {} 发送通知: {}", toUserId, request.getTitle());
return ResponseEntity.success("通知已发送");
}
/**
* 向指定用户添加未读消息(不会立即推送,只在下次连接时推送)
*
* @param toUserId 目标用户ID
* @param request 消息内容
* @return 响应结果
*/
@PostMapping("/unread")
public ResponseEntity<String> addUnreadMessage(
@RequestParam String toUserId,
@RequestBody UnreadMessageRequest request) {
userMessageService.addUnreadMessage(toUserId, Map.of(
"type", request.getType(),
"title", request.getTitle(),
"content", request.getContent(),
"timestamp", System.currentTimeMillis()
));
log.info("为用户 {} 添加未读消息: {}", toUserId, request.getTitle());
return ResponseEntity.success("未读消息已添加");
}
@Data
public static class NotificationRequest {
private String title;
private String content;
private String type; // info, warning, error
}
@Data
public static class NotificationPayload {
private String title;
private String content;
private String type;
private long timestamp;
}
@Data
public static class UnreadMessageRequest {
private String title;
private String content;
private String type;
}
}

View File

@@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api/permission")
@RequestMapping("/user/permission")
public class PermissionController {
@Autowired

View File

@@ -0,0 +1,43 @@
package com.timeline.user.controller;
import com.timeline.user.listener.StompSubscriptionListener;
import com.timeline.common.response.ResponseEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Set;
/**
* 订阅信息控制器用于查询用户的STOMP订阅状态
*/
@Slf4j
@RestController
@RequestMapping("/user/subscription")
public class SubscriptionController {
@Autowired
private StompSubscriptionListener stompSubscriptionListener;
/**
* 获取指定用户的订阅信息
* @param userId 用户ID
* @return 订阅信息
*/
@GetMapping("/{userId}")
public ResponseEntity<Set<String>> getUserSubscription(@PathVariable String userId) {
Set<String> subscriptions = stompSubscriptionListener.getUserSubscriptions(userId);
return ResponseEntity.success(subscriptions);
}
/**
* 获取所有用户的订阅信息
* @return 所有用户的订阅信息
*/
@GetMapping("/all")
public ResponseEntity<Map<String, Set<String>>> getAllSubscriptions() {
Map<String, Set<String>> subscriptions = stompSubscriptionListener.getAllSubscriptions();
return ResponseEntity.success(subscriptions);
}
}

View File

@@ -0,0 +1,117 @@
package com.timeline.user.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.user.ws.WsNotifyService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 测试消息控制器用于手动发送测试WebSocket消息
*/
@Slf4j
@RestController
@RequestMapping("/user/test-message")
public class TestNotificationPayload {
@Autowired
private WsNotifyService wsNotifyService;
/**
* 发送测试好友通知
*
* @param toUserId 目标用户ID
* @return 响应结果
*/
@PostMapping("/friend")
public ResponseEntity<String> sendFriendTestMessage(@RequestParam String toUserId) {
Map<String, Object> payload = buildMessagePayload(
"test_friend_notification",
"这是一条测试好友通知"
);
wsNotifyService.sendFriendNotify(toUserId, payload);
log.info("已发送测试好友通知给用户: {}", toUserId);
return ResponseEntity.success("测试好友通知已发送");
}
/**
* 发送测试好友通知到所有频道
*
* @param toUserId 目标用户ID
* @return 响应结果
*/
@PostMapping("/friend-all")
public ResponseEntity<String> sendFriendTestMessageToAllChannels(@RequestParam String toUserId) {
Map<String, Object> payload = buildMessagePayload(
"test_friend_notification_all",
"这是一条发送到所有频道的测试好友通知"
);
wsNotifyService.sendFriendNotifyToAllChannels(toUserId, payload);
log.info("已发送测试好友通知到所有频道给用户: {}", toUserId);
return ResponseEntity.success("测试好友通知已发送到所有频道");
}
/**
* 发送测试聊天消息
*
* @param toUserId 目标用户ID
* @return 响应结果
*/
@PostMapping("/chat")
public ResponseEntity<String> sendChatTestMessage(@RequestParam String toUserId) {
Map<String, Object> payload = buildMessagePayload(
"test_chat_message",
"这是一条测试聊天消息"
);
wsNotifyService.sendChatMessage(toUserId, payload);
log.info("已发送测试聊天消息给用户: {}", toUserId);
return ResponseEntity.success("测试聊天消息已发送");
}
/**
* 发送测试通知
*
* @param toUserId 目标用户ID
* @return 响应结果
*/
@PostMapping("/notification")
public ResponseEntity<String> sendNotificationTestMessage(@RequestParam String toUserId) {
Map<String, Object> payload = buildMessagePayload(
"test_notification",
"这是一条测试通知消息"
);
// 添加通知特有的字段
payload.put("title", "测试通知");
payload.put("type", "info");
wsNotifyService.sendNotificationToUser(toUserId, payload);
log.info("已发送测试通知给用户: {}", toUserId);
return ResponseEntity.success("测试通知已发送");
}
/**
* 构建通用消息负载
*
* @param type 消息类型
* @param message 消息内容
* @return 消息负载Map
*/
private Map<String, Object> buildMessagePayload(String type, String message) {
Map<String, Object> payload = new HashMap<>();
payload.put("type", type);
payload.put("message", message);
payload.put("timestamp", System.currentTimeMillis());
return payload;
}
}

View File

@@ -1,17 +1,23 @@
package com.timeline.user.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.user.dto.RegisterRequest;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.user.dto.UpdateUser;
import com.timeline.user.entity.User;
import com.timeline.user.service.UserService;
import com.timeline.user.utils.UserContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Slf4j
@RestController
@RequestMapping("/api/user")
@RequestMapping("/user")
public class UserController {
@Autowired
@@ -19,23 +25,28 @@ public class UserController {
@GetMapping("/info")
public ResponseEntity<User> getCurrentUserInfo() {
String userId = UserContext.getCurrentUserId();
log.info("获取当前用户信息: {}", userId);
User user = userService.getCurrentUser();
return ResponseEntity.success(user);
}
/**
* 查询指定用户信息
* @param userId
* @return
*/
@GetMapping("/{userId}")
public ResponseEntity<User> updateUserInfo(@PathVariable String userId) {
User user = userService.getUserByUserId(userId);
return ResponseEntity.success(user);
}
@PutMapping("/info")
public ResponseEntity<User> updateUserInfo(@RequestBody RegisterRequest updateRequest) {
String userId = UserContext.getCurrentUserId();
log.info("更新用户信息: {}", userId);
User user = userService.updateUserInfo(userId, updateRequest);
return ResponseEntity.success(user);
@GetMapping("/search")
public ResponseEntity<List<User>> getMethodName(User user) {
log.info(user.toString());
return ResponseEntity.success(userService.searchUsers(user));
}
@DeleteMapping
public ResponseEntity<String> deleteUser() {
String userId = UserContext.getCurrentUserId();
String userId = UserContextUtils.getCurrentUserId();
log.info("删除用户: {}", userId);
userService.deleteUser(userId);
return ResponseEntity.success("用户删除成功");

View File

@@ -0,0 +1,162 @@
package com.timeline.user.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.user.dao.FriendNotifyMapper;
import com.timeline.user.dto.UserMessageDto;
import com.timeline.user.entity.FriendNotify;
import com.timeline.user.service.UserMessageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 用户消息查询接口:未读消息 & 历史消息摘要
*/
@Slf4j
@RestController
@RequestMapping("/user/message")
public class UserMessageController {
@Autowired
private UserMessageService userMessageService;
@Autowired
private FriendNotifyMapper friendNotifyMapper;
private String currentUserId() {
return UserContextUtils.getCurrentUserId();
}
/**
* 获取当前用户的未读消息(包含系统/通知 + 好友请求等)
*/
@GetMapping("/unread")
public ResponseEntity<List<UserMessageDto>> unreadMessages() {
String userId = currentUserId();
List<UserMessageDto> result = new ArrayList<>();
// 1. 来自 UserMessageService 的未读消息(内存/缓存)
List<Map<String, Object>> unread = userMessageService.getUnreadMessages(userId);
long now = System.currentTimeMillis();
for (Map<String, Object> m : unread) {
UserMessageDto dto = new UserMessageDto();
dto.setCategory((String) m.getOrDefault("category", "notification"));
dto.setType((String) m.getOrDefault("type", "generic"));
dto.setFromUserId((String) m.get("fromUserId"));
dto.setToUserId((String) m.getOrDefault("toUserId", userId));
dto.setTitle((String) m.get("title"));
dto.setContent((String) m.getOrDefault("content", m.get("message")));
Object ts = m.get("timestamp");
dto.setTimestamp(ts instanceof Number ? ((Number) ts).longValue() : now);
dto.setStatus((String) m.getOrDefault("status", "unread"));
result.add(dto);
}
// 2. 未读好友通知(来自 friend_notify 表)
List<FriendNotify> friendNotifies = friendNotifyMapper.selectUnread(userId);
for (FriendNotify fn : friendNotifies) {
UserMessageDto dto = new UserMessageDto();
dto.setId(fn.getId());
dto.setCategory("friend");
dto.setType("friend_" + fn.getType()); // request / accept / reject -> friend_request 等
dto.setFromUserId(fn.getFromUserId());
dto.setToUserId(fn.getToUserId());
dto.setTitle("好友通知");
dto.setContent(fn.getContent());
dto.setTimestamp(fn.getCreateTime() != null ? fn.getCreateTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() : now);
dto.setStatus(fn.getStatus());
result.add(dto);
}
// 简单按时间倒序
result.sort((a, b) -> Long.compare(
b.getTimestamp() != null ? b.getTimestamp() : 0L,
a.getTimestamp() != null ? a.getTimestamp() : 0L
));
log.info("用户 {} 未读消息数量: {}", userId, result.size());
return ResponseEntity.success(result);
}
/**
* 历史消息摘要:目前返回最近的好友通知(不限已读/未读)
* 如需更完整的历史,可扩展独立的消息表。
*/
@GetMapping("/history/friend")
public ResponseEntity<List<UserMessageDto>> friendHistory() {
String userId = currentUserId();
// 复用 friend_notify 表,这里按“每个好友一条记录(取最新状态)”进行归并
List<FriendNotify> all = friendNotifyMapper.selectAllByUser(userId);
List<UserMessageDto> result = new ArrayList<>();
long now = System.currentTimeMillis();
// key: fromUserId谁发起/操作好友关系value: 该好友的最新一条通知
java.util.Map<String, FriendNotify> latestByFromUser = new java.util.HashMap<>();
for (FriendNotify fn : all) {
String from = fn.getFromUserId();
if (from == null) {
continue;
}
FriendNotify exist = latestByFromUser.get(from);
if (exist == null) {
latestByFromUser.put(from, fn);
} else {
// 按 create_time 取最新一条
java.time.LocalDateTime existTime = exist.getCreateTime();
java.time.LocalDateTime curTime = fn.getCreateTime();
if (curTime != null && (existTime == null || curTime.isAfter(existTime))) {
latestByFromUser.put(from, fn);
}
}
}
// 将每个好友的最新一条记录转换为摘要 DTO
for (FriendNotify fn : latestByFromUser.values()) {
UserMessageDto dto = new UserMessageDto();
dto.setId(fn.getId());
dto.setCategory("friend");
String rawType = fn.getType(); // request / accept / reject
dto.setType("friend_" + rawType);
dto.setFromUserId(fn.getFromUserId());
dto.setToUserId(fn.getToUserId());
dto.setTitle("好友通知");
// 如果 content 为空,根据类型给出默认文案
String content = fn.getContent();
if (content == null || content.isEmpty()) {
if ("request".equals(rawType)) {
content = "向你发送了好友请求";
} else if ("accept".equals(rawType)) {
content = "已接受你的好友请求";
} else if ("reject".equals(rawType)) {
content = "已拒绝你的好友请求";
} else {
content = "好友关系发生变更";
}
}
dto.setContent(content);
dto.setTimestamp(fn.getCreateTime() != null
? fn.getCreateTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
: now);
dto.setStatus(fn.getStatus());
result.add(dto);
}
result.sort((a, b) -> Long.compare(
b.getTimestamp() != null ? b.getTimestamp() : 0L,
a.getTimestamp() != null ? a.getTimestamp() : 0L
));
log.info("用户 {} 好友通知历史数量: {}", userId, result.size());
return ResponseEntity.success(result);
}
}

View File

@@ -0,0 +1,81 @@
package com.timeline.user.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.user.ws.WsNotifyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* WebSocket 测试控制器,用于测试消息推送功能
*/
@Slf4j
@RestController
@RequestMapping("/user/ws/test")
public class WebSocketTestController {
@Autowired
private WsNotifyService wsNotifyService;
/**
* 测试推送通知消息给当前用户
*/
@PostMapping("/notify/self")
public ResponseEntity<String> testNotifySelf(@RequestBody Map<String, Object> message) {
String userId = UserContextUtils.getCurrentUserId();
if (userId == null || userId.isEmpty()) {
return ResponseEntity.error(401, "未获取到用户身份");
}
log.info("测试推送通知给用户: {}", userId);
// 确保消息包含必要字段
if (!message.containsKey("timestamp")) {
message.put("timestamp", System.currentTimeMillis());
}
if (!message.containsKey("type")) {
message.put("type", "test");
}
wsNotifyService.sendNotificationToUser(userId, message);
return ResponseEntity.success("通知已推送给用户: " + userId);
}
/**
* 测试推送通知消息给指定用户
*/
@PostMapping("/notify/{targetUserId}")
public ResponseEntity<String> testNotifyUser(
@PathVariable String targetUserId,
@RequestBody Map<String, Object> message) {
log.info("测试推送通知给用户: {}", targetUserId);
wsNotifyService.sendNotificationToUser(targetUserId, message);
return ResponseEntity.success("通知已推送");
}
/**
* 测试推送好友通知
*/
@PostMapping("/friend/{targetUserId}")
public ResponseEntity<String> testFriendNotify(
@PathVariable String targetUserId,
@RequestBody Map<String, Object> message) {
log.info("测试推送好友通知给用户: {}", targetUserId);
wsNotifyService.sendFriendNotifyToAllChannels(targetUserId, message);
return ResponseEntity.success("好友通知已推送");
}
/**
* 测试推送聊天消息
*/
@PostMapping("/chat/{targetUserId}")
public ResponseEntity<String> testChatMessage(
@PathVariable String targetUserId,
@RequestBody Map<String, Object> message) {
log.info("测试推送聊天消息给用户: {}", targetUserId);
wsNotifyService.sendChatMessage(targetUserId, message);
return ResponseEntity.success("聊天消息已推送");
}
}

View File

@@ -0,0 +1,28 @@
package com.timeline.user.dao;
import com.timeline.user.dto.FriendUserDto;
import com.timeline.user.entity.Friend;
import com.timeline.user.entity.Friendship;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FriendMapper {
void insert(Friendship friendship);
Friendship selectByUsers(@Param("userId") String userId, @Param("friendId") String friendId);
void updateStatus(@Param("userId") String userId,
@Param("friendId") String friendId,
@Param("status") String status);
List<FriendUserDto> selectFriends(@Param("userId") String userId);
List<Friend> selectPending(@Param("toUserId") String toUserId);
// List<FriendUserDto> selectFriendUsers(FriendUserDto userDto);
}

View File

@@ -0,0 +1,20 @@
package com.timeline.user.dao;
import com.timeline.user.entity.FriendNotify;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FriendNotifyMapper {
void insert(FriendNotify notify);
List<FriendNotify> selectUnread(@Param("toUserId") String toUserId);
void markRead(@Param("id") Long id);
/**
* 查询某个用户的全部好友通知,按时间倒序(用于历史记录)
*/
List<FriendNotify> selectAllByUser(@Param("toUserId") String toUserId);
}

View File

@@ -1,6 +1,9 @@
package com.timeline.user.dao;
import com.timeline.user.entity.User;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
@Mapper
@@ -9,6 +12,7 @@ public interface UserMapper {
User selectById(Long id);
User selectByUserId(String userId);
User selectByUsername(String username);
List<User> searchUsers(User user);
void update(User user);
void deleteByUserId(String userId);
}

View File

@@ -0,0 +1,32 @@
package com.timeline.user.dto;
import lombok.Data;
@Data
public class ChatMessage {
/**
* 接收方用户 ID
*/
private String toUserId;
/**
* 发送方用户 ID由服务端在 WebSocket 会话中填充)
*/
private String fromUserId;
/**
* 发送方用户名(可选)
*/
private String fromUsername;
/**
* 消息内容
*/
private String content;
/**
* 发送时间戳(毫秒)
*/
private Long timestamp;
}

View File

@@ -0,0 +1,37 @@
package com.timeline.user.dto;
import lombok.Data;
@Data
public class FriendNotifyPayload {
/**
* 消息大类,固定为 friend
*/
private String category = "friend";
/**
* 好友操作类型request / accept / reject
*/
private String type;
/**
* 发送方用户 ID
*/
private String fromUserId;
/**
* 发送方用户名(可选,用于前端展示)
*/
private String fromUsername;
/**
* 提示文案或说明
*/
private String content;
/**
* 事件时间戳(毫秒)
*/
private Long timestamp;
}

View File

@@ -0,0 +1,10 @@
package com.timeline.user.dto;
import lombok.Data;
@Data
public class FriendRequestDto {
private String friendId;
private String remark;
}

View File

@@ -0,0 +1,16 @@
package com.timeline.user.dto;
import java.time.LocalDateTime;
import com.timeline.user.entity.User;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper=false)
public class FriendUserDto extends User{
private LocalDateTime createFriendTime;
private String remark;
private String friendStatus;
}

View File

@@ -2,11 +2,15 @@ package com.timeline.user.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginResponse {
private String token;
private String accessToken;
private String refreshToken;
private Long accessTokenExpiresInSeconds;
private String userId;
private String username;
}

View File

@@ -0,0 +1,9 @@
package com.timeline.user.dto;
import lombok.Data;
@Data
public class RefreshRequest {
private String refreshToken;
}

View File

@@ -0,0 +1,15 @@
package com.timeline.user.dto;
import lombok.Data;
@Data
public class UpdateUser {
private String username;
private String nickname;
private String email;
private String phone;
private String avatar;
private String description;
private String location;
private String tag;
}

View File

@@ -0,0 +1,56 @@
package com.timeline.user.dto;
import lombok.Data;
/**
* 通用用户消息 DTO用于 WebSocket 推送和历史记录查询
*/
@Data
public class UserMessageDto {
/**
* 数据库主键(如果来源于持久化表,可以为 null
*/
private Long id;
/**
* 消息大类system / notification / friend / chat 等
*/
private String category;
/**
* 业务类型:如 friend_request / friend_accepted / friend_rejected / connection_established 等
*/
private String type;
/**
* 发送方用户 ID
*/
private String fromUserId;
/**
* 接收方用户 ID
*/
private String toUserId;
/**
* 可选标题(用于通知类消息)
*/
private String title;
/**
* 文本内容
*/
private String content;
/**
* 发送时间戳(毫秒)
*/
private Long timestamp;
/**
* 状态unread / read 等
*/
private String status;
}

View File

@@ -0,0 +1,17 @@
package com.timeline.user.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class Friend {
private Long id;
private String userId;
private String friendId;
private Integer status; // 0 pending, 1 accepted, 2 rejected/blocked
private String remark;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,18 @@
package com.timeline.user.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class FriendNotify {
private Long id;
private String fromUserId;
private String toUserId;
private String type; // request / accept / reject
private String status; // unread / read
private String content;
private LocalDateTime createTime;
private LocalDateTime readTime;
}

View File

@@ -0,0 +1,15 @@
package com.timeline.user.entity;
import java.time.LocalDateTime;
import lombok.Data;
@Data
public class Friendship {
private Long id;
private String userId;
private String friendId;
private Integer sortKey;
private String status; // 0 pending, 1 accepted, 2 rejected/blocked
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -15,6 +15,10 @@ public class User {
private String phone;
private Integer status; // 0-正常1-禁用
private Integer isDeleted; // 0-未删除1-已删除
private String avatar;
private String description;
private String location;
private String tag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")

View File

@@ -1,42 +0,0 @@
package com.timeline.user.interceptor;
import com.timeline.user.entity.User;
import com.timeline.user.service.UserService;
import com.timeline.user.utils.JwtUtils;
import com.timeline.user.utils.UserContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class UserContextInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserService userService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
if (jwtUtils.validateToken(token) && !jwtUtils.isTokenExpired(token)) {
String userId = jwtUtils.getUserIdFromToken(token);
User user = userService.getUserByUserId(userId);
if (user != null) {
UserContext.setUser(user);
}
}
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserContext.clear();
}
}

View File

@@ -0,0 +1,20 @@
package com.timeline.user.listener;
import com.timeline.user.service.UserMessageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component;
@Component
public class ApplicationStartupListener implements ApplicationListener<ContextRefreshedEvent> {
@Autowired
private UserMessageService userMessageService;
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
// 应用启动完成后初始化测试消息
userMessageService.initializeTestMessages();
}
}

View File

@@ -0,0 +1,95 @@
package com.timeline.user.listener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
import org.springframework.web.socket.messaging.SessionUnsubscribeEvent;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* STOMP订阅监听器用于跟踪用户的订阅信息
*/
@Component
@Slf4j
public class StompSubscriptionListener {
// 存储用户订阅信息userId -> destinations
private final Map<String, Set<String>> userSubscriptions = new ConcurrentHashMap<>();
/**
* 监听STOMP订阅事件
* @param event 订阅事件
*/
@EventListener
public void handleWebSocketSubscribeListener(SessionSubscribeEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String userId = getUserId(headerAccessor);
String destination = headerAccessor.getDestination();
String subscriptionId = headerAccessor.getSubscriptionId();
if (userId != null && destination != null) {
userSubscriptions.computeIfAbsent(userId, k -> new HashSet<>()).add(destination);
log.info("用户 {} 订阅了 {}", userId, destination);
}
}
/**
* 监听STOMP取消订阅事件
* @param event 取消订阅事件
*/
@EventListener
public void handleWebSocketUnsubscribeListener(SessionUnsubscribeEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String userId = getUserId(headerAccessor);
String subscriptionId = headerAccessor.getSubscriptionId();
if (userId != null) {
log.info("用户 {} 取消订阅 (subscriptionId: {})", userId, subscriptionId);
}
}
/**
* 从headerAccessor中提取用户ID
* @param headerAccessor STOMP头部访问器
* @return 用户ID
*/
private String getUserId(StompHeaderAccessor headerAccessor) {
// 从simpUser属性中获取用户ID
if (headerAccessor.getUser() != null) {
return headerAccessor.getUser().getName();
}
// 如果没有从simpUser获取到尝试从session属性中获取
Object userIdAttr = headerAccessor.getSessionAttributes().get("userId");
if (userIdAttr instanceof String) {
return (String) userIdAttr;
}
return null;
}
/**
* 获取用户的订阅信息
* @param userId 用户ID
* @return 订阅的目标地址集合
*/
public Set<String> getUserSubscriptions(String userId) {
return userSubscriptions.getOrDefault(userId, new HashSet<>());
}
/**
* 获取所有用户的订阅信息
* @return 所有用户的订阅信息
*/
public Map<String, Set<String>> getAllSubscriptions() {
return userSubscriptions;
}
}

View File

@@ -0,0 +1,24 @@
package com.timeline.user.service;
import com.timeline.user.dto.FriendUserDto;
import com.timeline.user.entity.Friend;
import com.timeline.user.entity.FriendNotify;
import java.util.List;
public interface FriendService {
void requestFriend(String targetUserId);
void acceptFriend(String targetUserId);
void rejectFriend(String targetUserId);
List<FriendUserDto> listFriends();
List<Friend> listPending();
List<FriendNotify> listUnreadNotify();
void markNotifyRead(Long id);
}

View File

@@ -0,0 +1,11 @@
package com.timeline.user.service;
import com.timeline.user.dto.LoginRequest;
import com.timeline.user.dto.LoginResponse;
import com.timeline.user.dto.RegisterRequest;
import com.timeline.user.entity.User;
public interface UserAuthService {
LoginResponse login(LoginRequest loginRequest);
User register(RegisterRequest registerRequest);
}

View File

@@ -0,0 +1,66 @@
package com.timeline.user.service;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
import java.util.Collections;
/**
* 用户消息服务,用于管理用户的未读消息
*/
@Service
public class UserMessageService {
// 模拟存储用户未读消息的数据结构
// 在实际应用中,这应该存储在数据库或缓存中
private final Map<String, List<Map<String, Object>>> userUnreadMessages = new ConcurrentHashMap<>();
/**
* 为用户添加一条未读消息(统一结构建议):
* category/type/fromUserId/toUserId/title/content/timestamp/status 等
* @param userId 用户ID
* @param message 消息内容
*/
public void addUnreadMessage(String userId, Map<String, Object> message) {
userUnreadMessages.computeIfAbsent(userId, k -> new ArrayList<>()).add(message);
}
/**
* 获取用户的所有未读消息
* @param userId 用户ID
* @return 未读消息列表
*/
public List<Map<String, Object>> getUnreadMessages(String userId) {
return userUnreadMessages.getOrDefault(userId, Collections.emptyList());
}
/**
* 清除用户的所有未读消息
* @param userId 用户ID
*/
public void clearUnreadMessages(String userId) {
userUnreadMessages.remove(userId);
}
/**
* 初始化一些测试消息
*/
public void initializeTestMessages() {
// 添加一些测试消息
addUnreadMessage("testUser1", Map.of(
"type", "notification",
"title", "欢迎使用系统",
"content", "感谢您注册我们的系统",
"timestamp", System.currentTimeMillis()
));
addUnreadMessage("testUser1", Map.of(
"type", "friend_request",
"title", "好友请求",
"content", "用户John Doe想要添加您为好友",
"timestamp", System.currentTimeMillis() - 3600000 // 1小时前
));
}
}

View File

@@ -1,15 +1,17 @@
package com.timeline.user.service;
import com.timeline.user.entity.User;
import com.timeline.user.dto.LoginRequest;
import com.timeline.user.dto.RegisterRequest;
import com.timeline.user.dto.LoginResponse;
import java.util.List;
import com.timeline.user.dto.UpdateUser;
public interface UserService {
LoginResponse login(LoginRequest loginRequest);
User register(RegisterRequest registerRequest);
User getUserByUserId(String userId);
User updateUserInfo(String userId, RegisterRequest updateRequest);
User getCurrentUser();
User updateUserInfo(UpdateUser updateUser);
void deleteUser(String userId);
boolean checkUserPermission(String userId, String requiredPermission);
List<User> searchUsers(User user);
User getUserInfo(String userId);
}

View File

@@ -0,0 +1,225 @@
package com.timeline.user.service.impl;
import com.timeline.common.constants.CommonConstants;
import com.timeline.common.exception.CustomException;
import com.timeline.common.response.ResponseEnum;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.user.dao.FriendMapper;
import com.timeline.user.dao.FriendNotifyMapper;
import com.timeline.user.dao.UserMapper;
import com.timeline.user.dto.FriendUserDto;
import com.timeline.user.dto.FriendNotifyPayload;
import com.timeline.user.entity.Friend;
import com.timeline.user.entity.FriendNotify;
import com.timeline.user.entity.Friendship;
import com.timeline.user.entity.User;
import com.timeline.user.service.FriendService;
import com.timeline.user.service.UserMessageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
public class FriendServiceImpl implements FriendService {
@Autowired
private FriendMapper friendMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private FriendNotifyMapper friendNotifyMapper;
@Autowired
private com.timeline.user.ws.WsNotifyService wsNotifyService;
@Autowired
private UserMessageService userMessageService;
private String currentUser() {
String uid = UserContextUtils.getCurrentUserId();
if (uid == null || uid.isEmpty()) {
throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份");
}
return uid;
}
private String currentUsername() {
String username = UserContextUtils.getCurrentUsername();
if (username == null || username.isEmpty()) {
throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份");
}
return username;
}
@Override
public void requestFriend(String targetUserId) {
String uid = currentUser();
log.info("用户 {} 向用户 {} 发送好友请求", uid, targetUserId);
User targetUser = userMapper.selectByUserId(targetUserId);
if (targetUser == null) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "目标用户不存在");
}
if (uid.equals(targetUserId)) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "不能添加自己为好友");
}
Friendship exist = friendMapper.selectByUsers(uid, targetUserId);
if (exist != null && exist.getStatus() == CommonConstants.FRIENDSHIP_ACCEPTED) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "已是好友");
}
LocalDateTime now = LocalDateTime.now();
if (exist == null) {
Friendship f = new Friendship();
f.setUserId(uid);
f.setFriendId(targetUserId);
f.setStatus(CommonConstants.FRIENDSHIP_PENDING);
f.setSortKey(CommonConstants.DEFAULT_SORT_KEY);
f.setCreateTime(now);
f.setUpdateTime(now);
friendMapper.insert(f);
} else {
friendMapper.updateStatus(uid, targetUserId, CommonConstants.FRIENDSHIP_PENDING);
}
FriendNotify notify = new FriendNotify();
notify.setFromUserId(uid);
notify.setToUserId(targetUserId);
notify.setType("request");
notify.setStatus("unread");
notify.setCreateTime(now);
friendNotifyMapper.insert(notify);
FriendNotifyPayload payload = new FriendNotifyPayload();
payload.setType("request");
payload.setFromUserId(uid);
payload.setFromUsername(currentUsername());
payload.setContent("向你发送了好友请求");
payload.setTimestamp(System.currentTimeMillis());
log.info("准备发送好友请求通知给用户: {}", targetUserId);
wsNotifyService.sendFriendNotifyToAllChannels(targetUserId, payload);
// 存储未读消息,以便用户下次连接时能收到
userMessageService.addUnreadMessage(targetUserId, Map.of(
"category", "friend",
"type", "friend_request",
"fromUserId", uid,
"fromUsername", currentUsername(),
"toUserId", targetUserId,
"title", "好友请求",
"content", "您收到了一个好友请求",
"timestamp", System.currentTimeMillis(),
"status", "unread"
));
log.info("好友请求已处理完毕");
}
@Override
public void acceptFriend(String targetUserId) {
String uid = currentUser();
log.info("用户 {} 接受了用户 {} 的好友请求", uid, targetUserId);
LocalDateTime now = LocalDateTime.now();
friendMapper.updateStatus(targetUserId, uid, CommonConstants.FRIENDSHIP_ACCEPTED); // 请求方记录
Friendship reverse = friendMapper.selectByUsers(uid, targetUserId);
if (reverse == null) {
Friendship f = new Friendship();
f.setUserId(uid);
f.setFriendId(targetUserId);
f.setStatus(CommonConstants.FRIENDSHIP_ACCEPTED);
f.setCreateTime(now);
f.setUpdateTime(now);
friendMapper.insert(f);
} else {
friendMapper.updateStatus(uid, targetUserId, CommonConstants.FRIENDSHIP_ACCEPTED);
}
FriendNotify notify = new FriendNotify();
notify.setFromUserId(uid);
notify.setToUserId(targetUserId);
notify.setType("accept");
notify.setStatus("unread");
notify.setCreateTime(now);
friendNotifyMapper.insert(notify);
FriendNotifyPayload payload = new FriendNotifyPayload();
payload.setType("accept");
payload.setFromUserId(uid);
payload.setFromUsername(currentUsername());
payload.setContent("接受了你的好友请求");
payload.setTimestamp(System.currentTimeMillis());
log.info("准备发送好友接受通知给用户: {}", targetUserId);
wsNotifyService.sendFriendNotifyToAllChannels(targetUserId, payload);
// 存储未读消息,以便用户下次连接时能收到
userMessageService.addUnreadMessage(targetUserId, Map.of(
"category", "friend",
"type", "friend_accepted",
"fromUserId", uid,
"fromUsername", currentUsername(),
"toUserId", targetUserId,
"title", "好友请求已通过",
"content", "您的好友请求已被接受",
"timestamp", System.currentTimeMillis(),
"status", "unread"
));
log.info("好友接受已处理完毕");
}
@Override
public void rejectFriend(String targetUserId) {
String uid = currentUser();
log.info("用户 {} 拒绝了用户 {} 的好友请求", uid, targetUserId);
friendMapper.updateStatus(targetUserId, uid, CommonConstants.FRIENDSHIP_REJECTED);
FriendNotify notify = new FriendNotify();
notify.setFromUserId(uid);
notify.setToUserId(targetUserId);
notify.setType("reject");
notify.setStatus("unread");
notify.setCreateTime(LocalDateTime.now());
friendNotifyMapper.insert(notify);
FriendNotifyPayload payload = new FriendNotifyPayload();
payload.setType("reject");
payload.setFromUserId(uid);
payload.setContent("拒绝了你的好友请求");
payload.setTimestamp(System.currentTimeMillis());
log.info("准备发送好友拒绝通知给用户: {}", targetUserId);
wsNotifyService.sendFriendNotifyToAllChannels(targetUserId, payload);
// 存储未读消息,以便用户下次连接时能收到
userMessageService.addUnreadMessage(targetUserId, Map.of(
"category", "friend",
"type", "friend_rejected",
"fromUserId", uid,
"toUserId", targetUserId,
"title", "好友请求被拒绝",
"content", "您的好友请求已被拒绝",
"timestamp", System.currentTimeMillis(),
"status", "unread"
));
log.info("好友拒绝已处理完毕");
}
@Override
public List<FriendUserDto> listFriends() {
return friendMapper.selectFriends(currentUser());
}
@Override
public List<Friend> listPending() {
return friendMapper.selectPending(currentUser());
}
@Override
public List<FriendNotify> listUnreadNotify() {
return friendNotifyMapper.selectUnread(currentUser());
}
@Override
public void markNotifyRead(Long id) {
friendNotifyMapper.markRead(id);
}
}

View File

@@ -0,0 +1,100 @@
package com.timeline.user.service.impl;
import java.time.LocalDateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import com.timeline.common.constants.CommonConstants;
import com.timeline.common.exception.CustomException;
import com.timeline.common.response.ResponseEnum;
import com.timeline.common.utils.IdUtils;
import com.timeline.common.utils.RedisUtils;
import com.timeline.user.dao.UserMapper;
import com.timeline.user.dto.LoginRequest;
import com.timeline.user.dto.LoginResponse;
import com.timeline.user.dto.RegisterRequest;
import com.timeline.user.entity.User;
import com.timeline.user.service.UserAuthService;
import com.timeline.user.utils.JwtUtils;
import lombok.extern.slf4j.Slf4j;
@Service
@Slf4j
public class UserAuthServiceImpl implements UserAuthService {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtUtils jwtUtils;
@Autowired
private RedisUtils redisUtils;
@SuppressWarnings("null")
@Override
public LoginResponse login(LoginRequest loginRequest) {
try {
User user = userMapper.selectByUsername(loginRequest.getUsername());
if (user == null) {
throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户名或密码错误");
}
if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) {
throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户名或密码错误");
}
if (user.getStatus() == 1) {
throw new CustomException(ResponseEnum.FORBIDDEN, "用户已被禁用");
}
String accessToken = jwtUtils.generateAccessToken(user.getUserId(), user.getUsername());
String refreshToken = jwtUtils.generateRefreshToken(user.getUserId(), user.getUsername());
redisUtils.set(loginRequest.getUsername(), refreshToken, jwtUtils.getAccessExpirationSeconds());
return new LoginResponse(accessToken, refreshToken, jwtUtils.getAccessExpirationSeconds(), user.getUserId(), user.getUsername());
} catch (CustomException e) {
throw e;
} catch (Exception e) {
log.error("用户登录失败", e);
throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "登录失败");
}
}
@Override
public User register(RegisterRequest registerRequest) {
try {
// 检查用户名是否已存在
User existingUser = userMapper.selectByUsername(registerRequest.getUsername());
if (existingUser != null) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "用户名已存在");
}
User user = new User();
user.setUserId(IdUtils.randomUuidUpper());
user.setUsername(registerRequest.getUsername());
user.setNickname(registerRequest.getNickname());
user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
user.setEmail(registerRequest.getEmail());
user.setPhone(registerRequest.getPhone());
user.setStatus(CommonConstants.USER_STATUS_NORMAL); // 正常状态
user.setIsDeleted(CommonConstants.NOT_DELETED);
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
userMapper.insert(user);
return user;
} catch (CustomException e) {
throw e;
} catch (Exception e) {
log.error("用户注册失败", e);
throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "注册失败");
}
}
}

View File

@@ -1,22 +1,21 @@
package com.timeline.user.service.impl;
import com.timeline.common.constants.CommonConstants;
import com.timeline.common.exception.CustomException;
import com.timeline.common.response.ResponseEnum;
import com.timeline.common.utils.IdUtils;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.user.dao.UserMapper;
import com.timeline.user.entity.User;
import com.timeline.user.dto.LoginRequest;
import com.timeline.user.dto.RegisterRequest;
import com.timeline.user.dto.LoginResponse;
import com.timeline.user.dto.UpdateUser;
import com.timeline.user.service.UserService;
import com.timeline.user.utils.JwtUtils;
import com.timeline.user.ws.WsNotifyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@@ -24,88 +23,60 @@ public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private PasswordEncoder passwordEncoder;
private WsNotifyService wsNotifyService;
@Autowired
private JwtUtils jwtUtils;
@Override
public LoginResponse login(LoginRequest loginRequest) {
try {
User user = userMapper.selectByUsername(loginRequest.getUsername());
if (user == null) {
throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户名或密码错误");
}
if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) {
throw new CustomException(ResponseEnum.UNAUTHORIZED, "用户名或密码错误");
}
if (user.getStatus() == 1) {
throw new CustomException(ResponseEnum.FORBIDDEN, "用户已被禁用");
}
String token = jwtUtils.generateToken(user.getUserId(), user.getUsername());
return new LoginResponse(token, user.getUserId(), user.getUsername());
} catch (CustomException e) {
throw e;
} catch (Exception e) {
log.error("用户登录失败", e);
throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "登录失败");
private String getCurrentUserId() {
String uid = UserContextUtils.getCurrentUserId();
if (uid == null) {
throw new CustomException(ResponseEnum.UNAUTHORIZED);
}
return uid;
}
@Override
public User register(RegisterRequest registerRequest) {
try {
// 检查用户名是否已存在
User existingUser = userMapper.selectByUsername(registerRequest.getUsername());
if (existingUser != null) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "用户名已存在");
}
User user = new User();
user.setUserId(IdUtils.randomUuidUpper());
user.setUsername(registerRequest.getUsername());
user.setNickname(registerRequest.getNickname());
user.setPassword(passwordEncoder.encode(registerRequest.getPassword()));
user.setEmail(registerRequest.getEmail());
user.setPhone(registerRequest.getPhone());
user.setStatus(CommonConstants.USER_STATUS_NORMAL); // 正常状态
user.setIsDeleted(CommonConstants.NOT_DELETED);
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
userMapper.insert(user);
return user;
} catch (CustomException e) {
throw e;
} catch (Exception e) {
log.error("用户注册失败", e);
throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "注册失败");
}
}
@Override
public User getUserByUserId(String userId) {
return userMapper.selectByUserId(userId);
}
@Override
public User getCurrentUser() {
String userId = getCurrentUserId();
log.info("获取当前用户信息: {}",userId);
return userMapper.selectByUserId(userId);
}
@Override
public User getUserInfo(String userId) {
log.info("获取指定用户信息: {}",userId);
return userMapper.selectByUserId(userId);
}
@Override
public User updateUserInfo(String userId, RegisterRequest updateRequest) {
public User updateUserInfo(UpdateUser updateUser) {
try {
String userId = getCurrentUserId();
User user = userMapper.selectByUserId(userId);
if (user == null) {
throw new CustomException(ResponseEnum.NOT_FOUND, "用户不存在");
}
user.setEmail(updateRequest.getEmail());
user.setPhone(updateRequest.getPhone());
user.setNickname(updateUser.getNickname());
user.setEmail(updateUser.getEmail());
user.setPhone(updateUser.getPhone());
user.setAvatar(updateUser.getAvatar());
user.setDescription(updateUser.getDescription());
user.setLocation(updateUser.getLocation());
user.setTag(updateUser.getTag());
user.setUpdateTime(LocalDateTime.now());
userMapper.update(user);
// 用户信息更新后通过WebSocket推送通知
Map<String, Object> notification = new HashMap<>();
notification.put("type", "user_profile_updated");
notification.put("message", "您的个人信息已成功更新");
notification.put("timestamp", System.currentTimeMillis());
wsNotifyService.sendNotificationToUser(userId, notification);
return user;
} catch (CustomException e) {
throw e;
@@ -139,4 +110,9 @@ public class UserServiceImpl implements UserService {
User user = userMapper.selectByUserId(userId);
return user != null && user.getStatus() == 0; // 用户存在且未被禁用
}
}
@Override
public List<User> searchUsers(User user) {
return userMapper.searchUsers(user);
}
}

View File

@@ -3,12 +3,15 @@ package com.timeline.user.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Component
public class JwtUtils {
@@ -16,25 +19,39 @@ public class JwtUtils {
@Value("${jwt.secret:timelineSecretKey}")
private String secret;
@Value("${jwt.expiration:86400}")
private Long expiration;
@Value("${jwt.access-expiration:900}")
private Long accessExpirationSeconds;
public String generateToken(String userId, String username) {
@Value("${jwt.refresh-expiration:604800}")
private Long refreshExpirationSeconds;
public String generateAccessToken(String userId, String username) {
return buildToken(userId, username, "access", accessExpirationSeconds);
}
public String generateRefreshToken(String userId, String username) {
return buildToken(userId, username, "refresh", refreshExpirationSeconds);
}
private String buildToken(String userId, String username, String tokenType, long expiresInSeconds) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
claims.put("tokenType", tokenType);
long now = System.currentTimeMillis();
return Jwts.builder()
.setClaims(claims)
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(SignatureAlgorithm.HS512, secret)
.setIssuedAt(new Date(now))
.setExpiration(new Date(now + expiresInSeconds * 1000))
.signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS512)
.compact();
}
public Claims getClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(token)
.getBody();
}
@@ -47,9 +64,20 @@ public class JwtUtils {
return getClaimsFromToken(token).getSubject();
}
public String getTokenType(String token) {
return getClaimsFromToken(token).get("tokenType", String.class);
}
public Long getAccessExpirationSeconds() {
return accessExpirationSeconds;
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
@@ -60,4 +88,29 @@ public class JwtUtils {
Date expiration = getClaimsFromToken(token).getExpiration();
return expiration.before(new Date());
}
/**
* 计算 token 剩余有效期(秒),过期返回 0。
*/
public long getRemainingSeconds(String token) {
try {
Date exp = getClaimsFromToken(token).getExpiration();
long diff = exp.getTime() - System.currentTimeMillis();
return diff > 0 ? TimeUnit.MILLISECONDS.toSeconds(diff) : 0L;
} catch (Exception e) {
return 0L;
}
}
public static Claims parseToken(String token, String jwtSecret) {
try {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)))
.build()
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
return null;
}
}
}

View File

@@ -1,29 +0,0 @@
package com.timeline.user.utils;
import com.timeline.user.entity.User;
public class UserContext {
private static final ThreadLocal<User> userHolder = new ThreadLocal<>();
public static void setUser(User user) {
userHolder.set(user);
}
public static User getCurrentUser() {
return userHolder.get();
}
public static String getCurrentUserId() {
User user = getCurrentUser();
return user != null ? user.getUserId() : null;
}
public static String getCurrentUsername() {
User user = getCurrentUser();
return user != null ? user.getUsername() : null;
}
public static void clear() {
userHolder.remove();
}
}

View File

@@ -0,0 +1,34 @@
package com.timeline.user.ws;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.user.dto.ChatMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Controller;
import java.security.Principal;
@Slf4j
@Controller
public class ChatWsController {
@Autowired
private WsNotifyService wsNotifyService;
@MessageMapping("/chat/send")
public void send(@Payload ChatMessage msg, Principal principal) {
if (principal == null) {
log.warn("未认证用户尝试发送聊天消息");
return;
}
String fromUserId = principal.getName();
msg.setFromUserId(fromUserId);
msg.setFromUsername(UserContextUtils.getCurrentUsername());
msg.setTimestamp(System.currentTimeMillis());
log.info("用户 {}({}) 向 {} 发送消息: {}", fromUserId, msg.getFromUsername(), msg.getToUserId(), msg.getContent());
wsNotifyService.sendChatMessage(msg.getToUserId(), msg);
}
}

View File

@@ -0,0 +1,192 @@
package com.timeline.user.ws;
import com.timeline.user.service.UserMessageService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
import org.springframework.messaging.simp.stomp.StompCommand;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@Component
public class WebSocketEventListener {
@Autowired
@SuppressWarnings("unused")
private SimpMessagingTemplate messagingTemplate;
@Autowired
private UserMessageService userMessageService;
@Autowired
private WsNotifyService wsNotifyService;
/**
* WebSocket 连接建立事件STOMP CONNECT
*/
@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
Principal principal = headerAccessor.getUser();
String sessionId = headerAccessor.getSessionId();
StompCommand command = headerAccessor.getCommand();
log.info("WebSocket 连接建立事件会话ID: {}STOMP命令: {}Principal: {}",
sessionId, command, principal != null ? principal.getName() : "null");
// 打印所有session attributes
Map<String, Object> sessionAttrs = headerAccessor.getSessionAttributes();
log.info("会话属性: {}", sessionAttrs);
if (principal != null) {
String userId = principal.getName();
log.info("WebSocket 连接建立会话ID: {}用户ID: {}", sessionId, userId);
// 检查用户是否在注册表中
org.springframework.messaging.simp.user.SimpUser user = userRegistry.getUser(userId);
if (user != null) {
log.info("用户 {} 已在注册表中,会话数: {}", userId, user.getSessions().size());
} else {
log.warn("用户 {} 不在注册表中!", userId);
}
} else {
log.warn("WebSocket 连接建立会话ID: {},但未获取到用户身份", sessionId);
}
}
/**
* WebSocket 断开连接事件
*/
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
Principal principal = headerAccessor.getUser();
String sessionId = headerAccessor.getSessionId();
if (principal != null) {
String userId = principal.getName();
log.info("WebSocket 连接断开用户ID: {}会话ID: {}", userId, sessionId);
// 清除初始消息标记,允许下次连接时重新推送
initialMessageSent.remove(userId);
} else {
log.warn("断开连接事件中未获取到用户身份会话ID: {}", sessionId);
}
}
// 用于跟踪已推送初始消息的用户,避免重复推送
private final java.util.Set<String> initialMessageSent = java.util.concurrent.ConcurrentHashMap.newKeySet();
@Autowired
private org.springframework.messaging.simp.user.SimpUserRegistry userRegistry;
/**
* 订阅事件 - 当用户订阅某个频道时触发
*/
@EventListener
public void handleSubscribeEvent(SessionSubscribeEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
Principal principal = headerAccessor.getUser();
String destination = headerAccessor.getDestination();
String sessionId = headerAccessor.getSessionId();
if (principal != null) {
String userId = principal.getName();
log.info("用户 {} 订阅了频道: {}会话ID: {}", userId, destination, sessionId);
// 检查用户是否在注册表中
org.springframework.messaging.simp.user.SimpUser user = userRegistry.getUser(userId);
if (user != null) {
log.info("用户 {} 已在注册表中,会话数: {}", userId, user.getSessions().size());
} else {
log.warn("用户 {} 不在注册表中!当前注册用户: {}", userId,
userRegistry.getUsers().stream()
.map(org.springframework.messaging.simp.user.SimpUser::getName)
.toList());
}
// 如果是第一次订阅(任意频道),推送初始消息
if (!initialMessageSent.contains(userId)) {
initialMessageSent.add(userId);
log.info("用户 {} 首次订阅,准备推送初始消息", userId);
CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS).execute(() -> {
pushInitialMessages(userId);
});
}
// 如果订阅的是通知频道,立即推送未读消息
if (destination != null && destination.contains("/queue/notification")) {
CompletableFuture.delayedExecutor(300, TimeUnit.MILLISECONDS).execute(() -> {
pushUnreadNotifications(userId);
});
}
} else {
log.warn("订阅事件中未获取到用户身份会话ID: {},目标: {}sessionAttributes: {}",
sessionId, destination, headerAccessor.getSessionAttributes());
}
}
/**
* 推送初始消息给刚连接的用户
*/
private void pushInitialMessages(String userId) {
try {
log.info("开始推送初始消息给用户: {}", userId);
long now = System.currentTimeMillis();
// 1. 推送连接成功的欢迎消息(统一结构)
Map<String, Object> welcomeMsg = Map.of(
"category", "system",
"type", "connection_established",
"fromUserId", "system",
"toUserId", userId,
"title", "连接成功",
"content", "WebSocket 连接已建立",
"timestamp", now,
"status", "unread"
);
wsNotifyService.sendNotificationToUser(userId, welcomeMsg);
log.info("已推送欢迎消息给用户: {}", userId);
// 2. 推送未读通知
pushUnreadNotifications(userId);
} catch (Exception e) {
log.error("推送初始消息失败用户ID: {}", userId, e);
}
}
/**
* 推送未读通知
*/
private void pushUnreadNotifications(String userId) {
try {
List<Map<String, Object>> unreadMessages = userMessageService.getUnreadMessages(userId);
log.info("用户 {} 有 {} 条未读消息", userId, unreadMessages.size());
for (Map<String, Object> message : unreadMessages) {
wsNotifyService.sendNotificationToUser(userId, message);
log.debug("已推送未读消息给用户 {}: {}", userId, message);
}
// 推送完成后清除未读消息
if (!unreadMessages.isEmpty()) {
userMessageService.clearUnreadMessages(userId);
log.info("已清除用户 {} 的未读消息", userId);
}
} catch (Exception e) {
log.error("推送未读通知失败用户ID: {}", userId, e);
}
}
}

View File

@@ -0,0 +1,58 @@
package com.timeline.user.ws;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.user.SimpUser;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.stereotype.Component;
import java.util.Set;
import java.util.stream.Collectors;
/**
* WebSocket 会话注册表,用于调试和管理用户会话
*/
@Slf4j
@Component
public class WebSocketSessionRegistry {
private final SimpUserRegistry userRegistry;
private final SimpMessagingTemplate messagingTemplate;
public WebSocketSessionRegistry(SimpUserRegistry userRegistry, SimpMessagingTemplate messagingTemplate) {
this.userRegistry = userRegistry;
this.messagingTemplate = messagingTemplate;
}
/**
* 获取所有已连接的用户
*/
public Set<String> getConnectedUsers() {
return userRegistry.getUsers().stream()
.map(SimpUser::getName)
.collect(Collectors.toSet());
}
/**
* 检查用户是否在线
*/
public boolean isUserOnline(String userId) {
SimpUser user = userRegistry.getUser(userId);
boolean online = user != null;
log.info("用户 {} 在线状态: {}", userId, online);
if (online) {
log.info("用户 {} 的会话数: {}", userId, user.getSessions().size());
}
return online;
}
/**
* 打印所有在线用户
*/
public void printOnlineUsers() {
Set<String> users = getConnectedUsers();
log.info("当前在线用户数: {}", users.size());
users.forEach(userId -> log.info("在线用户: {}", userId));
}
}

View File

@@ -0,0 +1,93 @@
package com.timeline.user.ws;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.user.SimpUser;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
@Service
@Slf4j
public class WsNotifyService {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private SimpUserRegistry userRegistry;
public void sendFriendNotify(String toUserId, Object payload) {
log.info("发送好友通知给用户:{},内容:{}", toUserId, payload);
checkUserOnline(toUserId);
messagingTemplate.convertAndSendToUser(toUserId, "/queue/friend", payload);
log.info("好友通知已发送到目标路径: /user/{}/queue/friend", toUserId);
}
public void sendChatMessage(String toUserId, Object payload) {
log.info("发送聊天消息给用户:{},内容:{}", toUserId, payload);
checkUserOnline(toUserId);
messagingTemplate.convertAndSendToUser(toUserId, "/queue/chat", payload);
log.info("聊天消息已发送到目标路径: /user/{}/queue/chat", toUserId);
}
/**
* 向指定用户推送任意消息
* @param toUserId 目标用户ID
* @param destination 消息目的地
* @param payload 消息内容
*/
public void pushMessageToUser(String toUserId, String destination, Object payload) {
log.info("推送消息给用户:{},目的地:{},内容:{}", toUserId, destination, payload);
messagingTemplate.convertAndSendToUser(toUserId, destination, payload);
log.debug("消息已推送");
}
/**
* 向指定用户推送通知消息
* @param toUserId 目标用户ID
* @param payload 消息内容
*/
public void sendNotificationToUser(String toUserId, Object payload) {
log.info("发送通知给用户:{},内容:{}", toUserId, payload);
checkUserOnline(toUserId);
messagingTemplate.convertAndSendToUser(toUserId, "/queue/notification", payload);
log.info("通知已发送到目标路径: /user/{}/queue/notification", toUserId);
}
/**
* 向指定用户推送好友相关通知到所有可能的频道
* @param toUserId 目标用户ID
* @param payload 消息内容
*/
public void sendFriendNotifyToAllChannels(String toUserId, Object payload) {
log.info("向用户 {} 的所有频道发送好友通知,内容:{}", toUserId, payload);
// 发送到好友通知频道
messagingTemplate.convertAndSendToUser(toUserId, "/queue/friend", payload);
// 发送到通知频道
messagingTemplate.convertAndSendToUser(toUserId, "/queue/notification", payload);
log.info("好友通知已发送到所有频道");
}
/**
* 检查用户是否在线并打印调试信息
*/
private void checkUserOnline(String userId) {
SimpUser user = userRegistry.getUser(userId);
if (user != null) {
log.info("用户 {} 在线,会话数: {}", userId, user.getSessions().size());
user.getSessions().forEach(session -> {
log.debug("会话ID: {}, 订阅数: {}", session.getId(), session.getSubscriptions().size());
});
} else {
log.warn("用户 {} 不在线,消息可能无法送达", userId);
log.info("当前在线用户: {}", userRegistry.getUsers().stream()
.map(SimpUser::getName)
.toList());
}
}
}