Merge pull request #11 from SlimusMinus/add_chat

added chats with another users
This commit is contained in:
SlimusMinus
2026-06-22 01:09:27 +03:00
committed by GitHub
19 changed files with 433 additions and 177 deletions

View File

@@ -1,5 +1,7 @@
package com.krylov.refound.config; package com.krylov.refound.config;
import com.krylov.refound.util.WsHandshakeInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
@@ -8,18 +10,22 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
@Configuration @Configuration
@EnableWebSocketMessageBroker @EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WsHandshakeInterceptor wsHandshakeInterceptor;
@Override @Override
public void configureMessageBroker(MessageBrokerRegistry config) { public void configureMessageBroker(MessageBrokerRegistry registry) {
config.enableSimpleBroker("/topic"); registry.enableSimpleBroker("/topic"); // сервер → клиент
config.setApplicationDestinationPrefixes("/app"); registry.setApplicationDestinationPrefixes("/app"); // клиент → сервер
} }
@Override @Override
public void registerStompEndpoints(StompEndpointRegistry registry) { public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws") registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*") .setAllowedOriginPatterns("*")
.addInterceptors(wsHandshakeInterceptor)
.withSockJS(); .withSockJS();
} }
} }

View File

@@ -1,37 +1,45 @@
package com.krylov.refound.controller; package com.krylov.refound.controller;
import com.krylov.refound.dto.ChatMessage; import com.krylov.refound.dto.*;
import com.krylov.refound.entity.Message; import com.krylov.refound.service.ChatService;
import com.krylov.refound.entity.User; import com.krylov.refound.service.UserService;
import com.krylov.refound.repository.UserRepository;
import com.krylov.refound.service.MessageService;
import java.security.Principal;
import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.security.core.Authentication;
import org.springframework.messaging.handler.annotation.Payload; import org.springframework.web.bind.annotation.*;
import org.springframework.messaging.simp.SimpMessagingTemplate; import java.util.List;
import org.springframework.stereotype.Controller;
@Controller @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api/v1/chats")
public class ChatController { public class ChatController {
private final MessageService service;
private final SimpMessagingTemplate messagingTemplate;
private final UserRepository userRepository;
private final UserService userService;
private final ChatService chatService;
@MessageMapping("/chat") // Создать чат с пользователем или получить существующий
public void send(@Payload ChatMessage message, Principal principal) { @PostMapping("/with/{otherUserId}")
public ChatDto createOrGetChat(@PathVariable Long otherUserId, @RequestParam(required = false) Long postId, Authentication auth) {
Long currentUserId = extractUserId(auth); // см. ниже как достать id
return chatService.getOrCreateChat(currentUserId, otherUserId, postId);
}
Optional<User> sender = userRepository.findByLogin(principal.getName()); // Список всех чатов текущего пользователя
Message saved = new Message(); @GetMapping
if(sender.isPresent()){ public List<ChatDto> getMyChats(Authentication auth) {
saved = service.send(message.getReceiverId(), message.getContent()); return chatService.getUserChats(extractUserId(auth));
} }
// отправка получателю // История сообщений конкретного чата
messagingTemplate.convertAndSend("/topic/messages/" + message.getReceiverId(), saved); @GetMapping("/{chatId}/messages")
public List<MessageDto> getMessages(@PathVariable Long chatId) {
return chatService.getMessages(chatId);
}
private Long extractUserId(Authentication auth) {
// Подставь свою логику получения userId из JWT/Authentication
// Например если в токене login, а не id - подтяни через UserService
String login = auth.getName();
return userService.getUserIdByLogin(login);
} }
} }

View File

@@ -0,0 +1,22 @@
package com.krylov.refound.controller;
import com.krylov.refound.dto.SendMessageRequest;
import com.krylov.refound.service.ChatService;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class ChatWebSocketController {
private final ChatService chatService;
// Клиент шлёт сюда: /app/chat.send
@MessageMapping("/chat.send")
public void sendMessage(SendMessageRequest request, SimpMessageHeaderAccessor headerAccessor) {
Long senderId = (Long) headerAccessor.getSessionAttributes().get("userId");
chatService.sendMessage(senderId, request.getChatId(), request.getContent());
// ChatService сам отправит сообщение всем подписчикам через messagingTemplate
}
}

View File

@@ -1,41 +0,0 @@
package com.krylov.refound.controller;
import com.krylov.refound.dto.DialogResponse;
import com.krylov.refound.entity.Message;
import com.krylov.refound.service.MessageService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/messages")
@RequiredArgsConstructor
public class MessageController {
private final MessageService service;
@PostMapping
public void send(@RequestParam Long receiverId,
@RequestParam String content) {
service.send(receiverId, content);
}
@GetMapping("/{userId}")
public List<Message> chat(@PathVariable Long userId) {
return service.getChat(userId);
}
@GetMapping("/dialogs")
public List<DialogResponse> getDialogs() {
return service.getDialogs();
}
@PostMapping("/read/{userId}")
public void markAsRead(@PathVariable Long userId) {
service.markAsRead(userId);
}
}

View File

@@ -0,0 +1,16 @@
package com.krylov.refound.dto;
import java.time.LocalDateTime;
import lombok.Data;
@Data
public class ChatDto {
private Long id;
private Long otherUserId;
private String otherUserName;
private String otherUserAvatar;
private Long postId;
private String lastMessage;
private LocalDateTime lastMessageAt;
private long unreadCount;
}

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.dto;
import lombok.Data;
@Data
public class CreateChatRequest {
private Long postId; // опционально, с каким объявлением связан чат
}

View File

@@ -0,0 +1,14 @@
package com.krylov.refound.dto;
import java.time.LocalDateTime;
import lombok.Data;
@Data
public class MessageDto {
private Long id;
private Long chatId;
private Long senderId;
private String content;
private boolean isRead;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,9 @@
package com.krylov.refound.dto;
import lombok.Data;
@Data
public class SendMessageRequest {
private Long chatId;
private String content;
}

View File

@@ -0,0 +1,74 @@
package com.krylov.refound.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
import java.util.Objects;
import lombok.Data;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.hibernate.proxy.HibernateProxy;
@Entity
@Table(name = "chats", uniqueConstraints = {@UniqueConstraint(columnNames = {"user_one_id", "user_two_id"})})
@Getter
@Setter
@ToString
@RequiredArgsConstructor
public class Chat {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_one_id", nullable = false)
private Long userOneId; // всегда меньший id (для уникальности пары)
@Column(name = "user_two_id", nullable = false)
private Long userTwoId; // всегда больший id
@Column(name = "post_id")
private Long postId; // объявление, по поводу которого начат чат (опционально)
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
@Override
public final boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null) {
return false;
}
Class<?> oEffectiveClass =
o instanceof HibernateProxy proxy ? proxy.getHibernateLazyInitializer()
.getPersistentClass() : o.getClass();
Class<?> thisEffectiveClass =
this instanceof HibernateProxy proxy ? proxy.getHibernateLazyInitializer()
.getPersistentClass() : this.getClass();
if (thisEffectiveClass != oEffectiveClass) {
return false;
}
Chat chat = (Chat) o;
return getId() != null && Objects.equals(getId(), chat.getId());
}
@Override
public final int hashCode() {
return this instanceof HibernateProxy proxy ? proxy.getHibernateLazyInitializer()
.getPersistentClass().hashCode() : getClass().hashCode();
}
}

View File

@@ -6,6 +6,7 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Getter; import lombok.Getter;
@@ -20,16 +21,23 @@ public class Message {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@ManyToOne @Column(name = "chat_id", nullable = false)
private User sender; private Long chatId;
@ManyToOne @Column(name = "sender_id", nullable = false)
private User receiver; private Long senderId;
@Column(length = 2000) @Column(nullable = false, columnDefinition = "TEXT")
private String content; private String content;
@Column(name = "is_read", nullable = false)
private boolean isRead = false;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
private boolean isRead; @PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
} }

View File

@@ -0,0 +1,16 @@
package com.krylov.refound.repository;
import com.krylov.refound.entity.Chat;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface ChatRepository extends JpaRepository<Chat, Long> {
@Query("SELECT c FROM Chat c WHERE c.userOneId = :u1 AND c.userTwoId = :u2")
Optional<Chat> findByUsers(Long u1, Long u2);
@Query("SELECT c FROM Chat c WHERE c.userOneId = :userId OR c.userTwoId = :userId ORDER BY c.createdAt DESC")
List<Chat> findAllByUserId(Long userId); // Changed from Optional to List
}

View File

@@ -9,48 +9,7 @@ import org.springframework.data.jpa.repository.Query;
public interface MessageRepository extends JpaRepository<Message, Long> { public interface MessageRepository extends JpaRepository<Message, Long> {
@Query(""" List<Message> findByChatIdOrderByCreatedAtAsc(Long chatId);
SELECT m FROM Message m long countByChatIdAndIsReadFalseAndSenderIdNot(Long chatId, Long currentUserId);
WHERE (m.sender.id = :user1 AND m.receiver.id = :user2)
OR (m.sender.id = :user2 AND m.receiver.id = :user1)
ORDER BY m.createdAt
""")
List<Message> findChat(Long user1, Long user2);
@Query("""
SELECT m FROM Message m
WHERE m.id IN (
SELECT MAX(m2.id)
FROM Message m2
WHERE m2.sender.id = :userId OR m2.receiver.id = :userId
GROUP BY
CASE
WHEN m2.sender.id = :userId THEN m2.receiver.id
ELSE m2.sender.id
END
)
ORDER BY m.createdAt DESC
""")
List<Message> findDialogs(Long userId);
// 🔥 количество непрочитанных от пользователя
@Query("""
SELECT COUNT(m) FROM Message m
WHERE m.receiver.id = :userId
AND m.sender.id = :otherUserId
AND m.isRead = false
""")
int countUnread(Long userId, Long otherUserId);
// 🔥 пометить как прочитанные
@Modifying
@Query("""
UPDATE Message m
SET m.isRead = true
WHERE m.receiver.id = :userId
AND m.sender.id = :otherUserId
AND m.isRead = false
""")
void markAsRead(Long userId, Long otherUserId);
} }

View File

@@ -3,9 +3,14 @@ package com.krylov.refound.repository;
import com.krylov.refound.entity.User; import com.krylov.refound.entity.User;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByLogin(String email); Optional<User> findByLogin(String email);
Boolean existsByLogin(String login); Boolean existsByLogin(String login);
@Query("SELECT u.id FROM User u WHERE u.login = :login")
Optional<Long> findUserByLogin(@Param("login") String login);
} }

View File

@@ -0,0 +1,125 @@
package com.krylov.refound.service;
import com.krylov.refound.dto.ChatDto;
import com.krylov.refound.dto.MessageDto;
import com.krylov.refound.entity.Chat;
import com.krylov.refound.entity.Message;
import com.krylov.refound.repository.ChatRepository;
import com.krylov.refound.repository.MessageRepository;
import com.krylov.refound.repository.UserRepository;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ChatService {
private final ChatRepository chatRepository;
private final MessageRepository messageRepository;
private final SimpMessagingTemplate messagingTemplate;
private final UserRepository userRepository; // твой существующий репозиторий юзеров
// Создать чат с пользователем (или вернуть существующий)
@Transactional
public ChatDto getOrCreateChat(Long currentUserId, Long otherUserId, Long postId) {
if (currentUserId.equals(otherUserId)) {
throw new IllegalArgumentException("Нельзя создать чат с самим собой");
}
Long u1 = Math.min(currentUserId, otherUserId);
Long u2 = Math.max(currentUserId, otherUserId);
Chat chat = chatRepository.findByUsers(u1, u2)
.orElseGet(() -> {
Chat newChat = new Chat();
newChat.setUserOneId(u1);
newChat.setUserTwoId(u2);
newChat.setPostId(postId);
return chatRepository.save(newChat);
});
return toDto(chat, currentUserId);
}
// Все чаты пользователя
public List<ChatDto> getUserChats(Long userId) {
return chatRepository.findAllByUserId(userId).stream()
.map(c -> toDto(c, userId))
.collect(Collectors.toList());
}
// История сообщений в чате
public List<MessageDto> getMessages(Long chatId) {
return messageRepository.findByChatIdOrderByCreatedAtAsc(chatId).stream()
.map(this::toMessageDto)
.collect(Collectors.toList());
}
// Отправка сообщения (вызывается из WebSocket контроллера)
@Transactional
public MessageDto sendMessage(Long senderId, Long chatId, String content) {
Chat chat = chatRepository.findById(chatId)
.orElseThrow(() -> new IllegalArgumentException("Чат не найден"));
// Проверка что отправитель - участник чата
if (!chat.getUserOneId().equals(senderId) && !chat.getUserTwoId().equals(senderId)) {
throw new SecurityException("Вы не участник этого чата");
}
Message message = new Message();
message.setChatId(chatId);
message.setSenderId(senderId);
message.setContent(content);
message = messageRepository.save(message);
MessageDto dto = toMessageDto(message);
// Отправляем сообщение через WebSocket в топик конкретного чата
messagingTemplate.convertAndSend("/topic/chat/" + chatId, dto);
return dto;
}
private ChatDto toDto(Chat chat, Long currentUserId) {
Long otherUserId = chat.getUserOneId().equals(currentUserId)
? chat.getUserTwoId() : chat.getUserOneId();
ChatDto dto = new ChatDto();
dto.setId(chat.getId());
dto.setOtherUserId(otherUserId);
dto.setPostId(chat.getPostId());
// Подтягиваем имя/аватар собеседника из твоего UserRepository
userRepository.findById(otherUserId).ifPresent(user -> {
dto.setOtherUserName(user.getName() + " " + user.getLastName());
dto.setOtherUserAvatar(user.getAvatarUrl());
});
List<Message> messages = messageRepository.findByChatIdOrderByCreatedAtAsc(chat.getId());
if (!messages.isEmpty()) {
Message last = messages.getLast();
dto.setLastMessage(last.getContent());
dto.setLastMessageAt(last.getCreatedAt());
}
dto.setUnreadCount(
messageRepository.countByChatIdAndIsReadFalseAndSenderIdNot(chat.getId(), currentUserId)
);
return dto;
}
private MessageDto toMessageDto(Message m) {
MessageDto dto = new MessageDto();
dto.setId(m.getId());
dto.setChatId(m.getChatId());
dto.setSenderId(m.getSenderId());
dto.setContent(m.getContent());
dto.setRead(m.isRead());
dto.setCreatedAt(m.getCreatedAt());
return dto;
}
}

View File

@@ -19,65 +19,5 @@ import org.springframework.transaction.annotation.Transactional;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MessageService { public class MessageService {
private final MessageRepository messageRepository;
private final UserRepository userRepository;
private final UserService userService;
public Message send(Long receiverId, String content) {
User sender = userService.getCurrentUser();
User receiver = userRepository.findById(receiverId)
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
Message message = new Message();
message.setSender(sender);
message.setReceiver(receiver);
message.setContent(content);
message.setCreatedAt(LocalDateTime.now());
message.setRead(false);
return messageRepository.save(message);
}
public List<Message> getChat(Long userId) {
User current = userService.getCurrentUser();
return messageRepository.findChat(current.getId(), userId);
}
public List<DialogResponse> getDialogs() {
Long userId = 1L;
List<Message> dialogs = messageRepository.findDialogs(userId);
return dialogs.stream().map(m -> {
boolean isMeSender = m.getSender().getId().equals(userId);
Long otherUserId = isMeSender
? m.getReceiver().getId()
: m.getSender().getId();
String email = isMeSender
? m.getReceiver().getLogin()
: m.getSender().getLogin();
int unread = messageRepository.countUnread(userId, otherUserId);
return new DialogResponse(
otherUserId,
email,
m.getContent(),
m.getCreatedAt(),
(long )unread
);
}).toList();
}
@Transactional
public void markAsRead(Long otherUserId) {
User current = userService.getCurrentUser();
messageRepository.markAsRead(current.getId(), otherUserId);
}
} }

View File

@@ -77,4 +77,9 @@ public class UserService {
.avatar(user.getAvatarUrl()) .avatar(user.getAvatarUrl())
.build(); .build();
} }
public Long getUserIdByLogin(String login) {
return repository.findUserByLogin(login)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
}
} }

View File

@@ -0,0 +1,37 @@
package com.krylov.refound.util;
import com.krylov.refound.security.JwtService;
import com.krylov.refound.service.UserService;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
@Component
@RequiredArgsConstructor
public class WsHandshakeInterceptor implements HandshakeInterceptor {
private final JwtService jwtService;
private final UserService userService;
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) {
if (request instanceof ServletServerHttpRequest servletRequest) {
String token = servletRequest.getServletRequest().getParameter("token");
if (token != null && jwtService.isTokenValid(token)) {
String login = jwtService.extractLogin(token);
Long userId = userService.getUserIdByLogin(login);
attributes.put("userId", userId);
return true;
}
}
return false; // отклонить подключение без валидного токена
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {}
}

View File

@@ -0,0 +1,17 @@
databaseChangeLog:
- changeSet:
id: 004-create-chats-messages
author: you
changes:
- createTable:
tableName: chats
columns:
- column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } }
- column: { name: user_one_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: user_two_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: post_id, type: BIGINT }
- column: { name: created_at, type: TIMESTAMP, constraints: { nullable: false } }
- addUniqueConstraint:
tableName: chats
columnNames: user_one_id, user_two_id
constraintName: uq_chat_users

View File

@@ -0,0 +1,28 @@
databaseChangeLog:
- changeSet:
id: 005-drop-old-messages
author: you
changes:
- dropTable:
tableName: messages
cascadeConstraints: true
- changeSet:
id: 006-create-messages-new
author: you
changes:
- createTable:
tableName: messages
columns:
- column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } }
- column: { name: chat_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: sender_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: content, type: TEXT, constraints: { nullable: false } }
- column: { name: is_read, type: BOOLEAN, defaultValueBoolean: false }
- column: { name: created_at, type: TIMESTAMP, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: messages
baseColumnNames: chat_id
referencedTableName: chats
referencedColumnNames: id
constraintName: fk_messages_chat