Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
package com.sofa.linkiving.domain.chat.controller;

import org.springframework.validation.annotation.Validated;

import com.sofa.linkiving.domain.chat.dto.request.CreateChatReq;
import com.sofa.linkiving.domain.chat.dto.response.ChatsRes;
import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes;
import com.sofa.linkiving.domain.chat.dto.response.MessagesRes;
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.global.common.BaseResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;

@Validated
@Tag(name = "Chat", description = """
AI 채팅 통합 명세 (HTTP + WebSocket)

Expand All @@ -27,12 +34,24 @@

""")
public interface ChatApi {
@Operation(summary = "채팅 기록 조회", description = "채팅 기록을 최신순으로 조회합니다. 무한 스크롤 방식으로 제공됩니다.")
BaseResponse<MessagesRes> getMessages(
Member member,
@Parameter(description = "채팅방 ID") Long chatId,
@Parameter(description = "페이징을 위한 마지막 메시지 ID, 첫 조회 시 null") Long lastId,

@Parameter(description = "페이지 크기")
@Min(value = 1, message = "최소 1개 이상 조회해야 합니다.")
@Max(value = 50, message = "한 번에 최대 50개까지만 조회할 수 있습니다.")
int size
);

@Operation(summary = "채팅방 목록 조회", description = "사용자의 채팅방 목록 정보(채팅방 Id, 제목)을 조회합니다.")
BaseResponse<ChatsRes> getChats(Member member);

@Operation(summary = "새로운 채팅 생성", description = "새로운 채팅을 생성합니다.")
BaseResponse<CreateChatRes> createChat(
CreateChatReq req,
@Valid CreateChatReq req,
Member member
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.sofa.linkiving.domain.chat.dto.request.CreateChatReq;
import com.sofa.linkiving.domain.chat.dto.response.ChatsRes;
import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes;
import com.sofa.linkiving.domain.chat.dto.response.MessagesRes;
import com.sofa.linkiving.domain.chat.facade.ChatFacade;
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.global.common.BaseResponse;
import com.sofa.linkiving.security.annotation.AuthMember;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
Expand All @@ -37,7 +38,7 @@ public BaseResponse<ChatsRes> getChats(@AuthMember Member member) {

@Override
@PostMapping
public BaseResponse<CreateChatRes> createChat(@RequestBody @Valid CreateChatReq req, @AuthMember Member member) {
public BaseResponse<CreateChatRes> createChat(@RequestBody CreateChatReq req, @AuthMember Member member) {
CreateChatRes res = chatFacade.createChat(req.firstChat(), member);
return BaseResponse.success(res, "채팅방 생성 완료");
}
Expand All @@ -60,4 +61,16 @@ public void sendMessage(@DestinationVariable Long chatId, @Payload String messag
public void cancelMessage(@DestinationVariable Long chatId, @AuthMember Member member) {
chatFacade.cancelAnswer(chatId, member);
}

@Override
@GetMapping("/{chatId}")
public BaseResponse<MessagesRes> getMessages(
@AuthMember Member member,
@PathVariable Long chatId,
@RequestParam(required = false) Long lastId,
@RequestParam(defaultValue = "20") int size
) {
MessagesRes res = chatFacade.getMessages(member, chatId, lastId, size);
return BaseResponse.success(res, "채팅 기록을 가져오는데 성공했습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.sofa.linkiving.domain.chat.dto.internal;

import java.util.List;

import com.sofa.linkiving.domain.chat.entity.Message;
import com.sofa.linkiving.domain.link.dto.internal.LinkDto;

public record MessageDto(Message message, List<LinkDto> linkDtos) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.sofa.linkiving.domain.chat.dto.internal;

import java.util.List;

public record MessagesDto(
List<MessageDto> messageDtos,
boolean hasNext
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.sofa.linkiving.domain.chat.dto.response;

import java.time.LocalDateTime;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.sofa.linkiving.domain.chat.dto.internal.MessageDto;
import com.sofa.linkiving.domain.chat.entity.Message;
import com.sofa.linkiving.domain.chat.enums.Sentiment;
import com.sofa.linkiving.domain.chat.enums.Type;
import com.sofa.linkiving.domain.link.dto.response.LinkCardRes;

import io.swagger.v3.oas.annotations.media.Schema;

public record MessageRes(
@Schema(description = "메시지 ID")
Long id,

@Schema(description = "메시지 내용")
String content,

@Schema(description = "발신자 타입 (USER / AI)")
Type type,

@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "피드백 상태 (AI 메시지인 경우만 포함: LIKE, DISLIKE, NONE)")
Sentiment feedback,

@Schema(description = "메시지 생성 시간", example = "2024-12-31 14:30:00")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
LocalDateTime time,

@Schema(description = "첨부된 링크 목록")
List<LinkCardRes> links
) {
public static MessageRes from(MessageDto messageDto) {
Message message = messageDto.message();

return new MessageRes(
message.getId(),
message.getContent(),
message.getType(),
message.getSentimentOrDefault(),
message.getCreatedAt(),
messageDto.linkDtos().stream()
.map(LinkCardRes::from)
.toList()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.sofa.linkiving.domain.chat.dto.response;

import java.util.Collections;
import java.util.List;

import com.sofa.linkiving.domain.chat.dto.internal.MessageDto;

import io.swagger.v3.oas.annotations.media.Schema;

public record MessagesRes(
@Schema(description = "메시지 목록")
List<MessageRes> messages,

@Schema(description = "다음 페이지 존재 여부")
boolean hasNext,

@Schema(description = "마지막 메시지 ID (다음 요청 커서용)")
Long lastId
) {
public static MessagesRes of(List<MessageDto> messageDtos, boolean hasNext) {
if (messageDtos.isEmpty()) {
return new MessagesRes(Collections.emptyList(), hasNext, null);
}

List<MessageRes> responses = messageDtos.stream()
.map(MessageRes::from)
.toList();

Long lastId = messageDtos.get(messageDtos.size() - 1).message().getId();

return new MessagesRes(responses, hasNext, lastId);
}

}
26 changes: 20 additions & 6 deletions src/main/java/com/sofa/linkiving/domain/chat/entity/Message.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
import java.util.ArrayList;
import java.util.List;

import com.sofa.linkiving.domain.chat.enums.Sentiment;
import com.sofa.linkiving.domain.chat.enums.Type;
import com.sofa.linkiving.domain.link.entity.Link;
import com.sofa.linkiving.global.common.BaseEntity;
import com.sofa.linkiving.global.converter.LongListToStringConverter;

import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
Expand All @@ -36,17 +38,29 @@ public class Message extends BaseEntity {
@Column(columnDefinition = "text", nullable = false)
private String content;

@Convert(converter = LongListToStringConverter.class)
private List<Long> linkIds = new ArrayList<>();
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "message_link",
joinColumns = @JoinColumn(name = "message_id"),
inverseJoinColumns = @JoinColumn(name = "link_id")
)
private List<Link> links = new ArrayList<>();

@OneToOne(mappedBy = "message")
private Feedback feedback;

@Builder
public Message(Chat chat, Type type, String content, List<Long> linkIds) {
public Message(Chat chat, Type type, String content, List<Link> links) {
this.chat = chat;
this.type = type;
this.content = content;
this.linkIds = linkIds;
this.links = (links != null) ? links : new ArrayList<>();
}

public Sentiment getSentimentOrDefault() {
if (this.type != Type.AI) {
return null;
}
return (this.feedback != null) ? this.feedback.getSentiment() : Sentiment.NONE;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
@Getter
@RequiredArgsConstructor
public enum Sentiment implements CodeEnum<Integer> {
LIKE(0), DISLIKE(1);
LIKE(0), DISLIKE(1), NONE(2);
private final Integer code;

@Converter(autoApply = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import org.springframework.transaction.annotation.Transactional;

import com.sofa.linkiving.domain.chat.ai.AiTitleClient;
import com.sofa.linkiving.domain.chat.dto.internal.MessagesDto;
import com.sofa.linkiving.domain.chat.dto.response.ChatsRes;
import com.sofa.linkiving.domain.chat.dto.response.CreateChatRes;
import com.sofa.linkiving.domain.chat.dto.response.MessagesRes;
import com.sofa.linkiving.domain.chat.entity.Chat;
import com.sofa.linkiving.domain.chat.service.ChatService;
import com.sofa.linkiving.domain.chat.service.FeedbackService;
Expand All @@ -25,6 +27,12 @@ public class ChatFacade {
private final FeedbackService feedbackService;
private final AiTitleClient aiTitleClient;

public MessagesRes getMessages(Member member, Long chatId, Long lastId, int size) {
Chat chat = chatService.getChat(chatId, member);
MessagesDto result = messageService.getMessages(chat, lastId, size);
return MessagesRes.of(result.messageDtos(), result.hasNext());
}

@Transactional
public CreateChatRes createChat(String firstChat, Member member) {
String title = aiTitleClient.generateSummary(firstChat);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import java.util.List;

import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import com.sofa.linkiving.domain.chat.entity.Chat;
Expand All @@ -16,6 +18,18 @@ public interface MessageRepository extends JpaRepository<Message, Long> {
@Query("DELETE FROM Message m WHERE m.chat = :chat")
void deleteAllByChat(Chat chat);

@Query("SELECT m FROM Message m LEFT JOIN FETCH m.feedback WHERE m.chat = :chat")
@Query("""
SELECT m FROM Message m
LEFT JOIN FETCH m.feedback
WHERE m.chat = :chat
AND (:lastId IS NULL OR m.id < :lastId)
ORDER BY m.id DESC
""")
List<Message> findAllByChatAndCursor(
@Param("chat") Chat chat,
@Param("lastId") Long lastId,
Pageable pageable
);

List<Message> findAllByChat(Chat chat);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import java.util.List;

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.SliceImpl;
import org.springframework.stereotype.Service;

import com.sofa.linkiving.domain.chat.entity.Chat;
Expand All @@ -15,7 +18,16 @@
public class MessageQueryService {
private final MessageRepository messageRepository;

public List<Message> findAllByChat(Chat chat) {
return messageRepository.findAllByChat(chat);
public Slice<Message> findAllByChatAndCursor(Chat chat, Long lastId, int size) {
PageRequest pageRequest = PageRequest.of(0, size + 1);
List<Message> messages = messageRepository.findAllByChatAndCursor(chat, lastId, pageRequest);

boolean hasNext = false;
if (size < messages.size()) {
hasNext = true;
messages.remove(size);
}

return new SliceImpl<>(messages, pageRequest, hasNext);
}
}
Loading
Loading