Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions src/main/java/umc/cockple/demo/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableJpaAuditing
@EnableCaching
@EnableScheduling
public class Application {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package umc.cockple.demo.domain.chat.events;

public record ChatRoomRedisCleanupEvent(
Long chatRoomId
) {
public static ChatRoomRedisCleanupEvent of(Long chatRoomId) {
return new ChatRoomRedisCleanupEvent(chatRoomId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package umc.cockple.demo.domain.chat.events;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import umc.cockple.demo.domain.chat.service.websocket.ChatListSubscriptionService;
import umc.cockple.demo.domain.chat.service.websocket.ChatRoomListCacheService;
import umc.cockple.demo.domain.chat.service.websocket.RedisSubscriptionService;

@Component
@RequiredArgsConstructor
@Slf4j
public class ChatRoomRedisCleanupListener {

private final ChatRoomListCacheService chatRoomListCacheService;
private final RedisSubscriptionService redisSubscriptionService;
private final ChatListSubscriptionService chatListSubscriptionService;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handleChatRoomRedisCleanup(ChatRoomRedisCleanupEvent event) {
Long chatRoomId = event.chatRoomId();
log.info("[채팅방 Redis 정리 시작] - chatRoomId: {}", chatRoomId);

try {
chatRoomListCacheService.evictLastMessage(chatRoomId);
} catch (Exception e) {
log.warn("[채팅방 Redis 정리] 마지막 메시지 캐시 best-effort 삭제 실패 - chatRoomId: {}", chatRoomId, e);
}

redisSubscriptionService.tryClearRoomSubscribers(chatRoomId);
chatListSubscriptionService.tryClearChatListSubscribers(chatRoomId);

log.info("[채팅방 Redis 정리 완료] - chatRoomId: {}", chatRoomId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package umc.cockple.demo.domain.chat.events;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import umc.cockple.demo.domain.chat.service.ChatRoomService;
import umc.cockple.demo.domain.party.events.PartyDeletedEvent;

@Component
@RequiredArgsConstructor
@Slf4j
public class PartyDeletedChatCleanupListener {

private final ChatRoomService chatRoomService;

@EventListener
public void handlePartyDeleted(PartyDeletedEvent event) {
log.info("모임 삭제 이벤트 처리 - partyId: {}, deletedBy: {}", event.partyId(), event.deletedByMemberId());
chatRoomService.deletePartyChatRoom(event.partyId());
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
package umc.cockple.demo.domain.chat.repository;

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 umc.cockple.demo.domain.chat.domain.ChatMessageFile;

import java.util.List;

public interface ChatFileRepository extends JpaRepository<ChatMessageFile, Long> {

@Query("""
SELECT cmf.fileKey FROM ChatMessageFile cmf
WHERE cmf.chatMessage.chatRoom.id = :chatRoomId
""")
List<String> findObjectKeysByChatRoomId(@Param("chatRoomId") Long chatRoomId);

@Modifying
@Query("""
DELETE FROM ChatMessageFile cmf
WHERE cmf.chatMessage.chatRoom.id = :chatRoomId
""")
int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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 umc.cockple.demo.domain.chat.domain.ChatMessage;
Expand All @@ -10,6 +11,13 @@

public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {

@Modifying
@Query("""
DELETE FROM ChatMessage cm
WHERE cm.chatRoom.id = :chatRoomId
""")
int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId);

ChatMessage findTop1ByChatRoom_IdOrderByCreatedAtDesc(Long chatRoomId);

@Query("""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@

public interface ChatRoomMemberRepository extends JpaRepository<ChatRoomMember, Long> {

@Modifying
@Query("""
DELETE FROM ChatRoomMember crm
WHERE crm.chatRoom.id = :chatRoomId
""")
int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId);

// 채팅방 내 참여자 수
@Query("SELECT COUNT(c) FROM ChatRoomMember c WHERE c.chatRoom.id = :chatRoomId")
int countByChatRoomId(@Param("chatRoomId") Long chatRoomId);
Expand Down Expand Up @@ -84,4 +91,3 @@ AND counterPart.chatRoom.id IN (
""")
List<ChatRoomMember> findDirectChatCounterParts(@Param("memberId") Long memberId);
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
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 umc.cockple.demo.domain.chat.domain.ChatRoom;
Expand All @@ -11,6 +12,13 @@

public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {

@Modifying
@Query("""
DELETE FROM ChatRoom cr
WHERE cr.id = :chatRoomId
""")
int deleteRoomById(@Param("chatRoomId") Long chatRoomId);

@Query("""
SELECT cr FROM ChatRoom cr
JOIN cr.chatRoomMembers crm
Expand Down Expand Up @@ -103,4 +111,4 @@ Slice<ChatRoom> searchDirectChatRoomsByName(
WHERE cr.id = :roomId
""")
Optional<ChatRoom> findChatRoomWithPartyById(@Param("roomId") Long roomId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@

public interface MessageReadStatusRepository extends JpaRepository<MessageReadStatus, Long> {

@Modifying
@Query("""
DELETE FROM MessageReadStatus mrs
WHERE mrs.chatRoomId = :chatRoomId
""")
int deleteByChatRoomId(@Param("chatRoomId") Long chatRoomId);

@Modifying
@Query("""
UPDATE MessageReadStatus mrs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,39 @@

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import umc.cockple.demo.domain.chat.domain.ChatRoom;
import umc.cockple.demo.domain.chat.domain.ChatRoomMember;
import umc.cockple.demo.domain.chat.events.ChatRoomRedisCleanupEvent;
import umc.cockple.demo.domain.chat.exception.ChatErrorCode;
import umc.cockple.demo.domain.chat.exception.ChatException;
import umc.cockple.demo.domain.chat.repository.ChatFileRepository;
import umc.cockple.demo.domain.chat.repository.ChatMessageRepository;
import umc.cockple.demo.domain.chat.repository.ChatRoomMemberRepository;
import umc.cockple.demo.domain.chat.repository.ChatRoomRepository;
import umc.cockple.demo.domain.chat.repository.MessageReadStatusRepository;
import umc.cockple.demo.domain.file.service.ObjectStorageDeleteOutboxService;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.party.domain.Party;

import java.util.List;
import java.util.Optional;

@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class ChatRoomService {

private final ChatRoomRepository chatRoomRepository;
private final ChatFileRepository chatFileRepository;
private final ChatMessageRepository chatMessageRepository;
private final ChatRoomMemberRepository chatRoomMemberRepository;
private final MessageReadStatusRepository messageReadStatusRepository;
private final ObjectStorageDeleteOutboxService objectStorageDeleteOutboxService;
private final ApplicationEventPublisher applicationEventPublisher;

public void createPartyChatRoom(Party party, Member owner) {
log.info("[모임 채팅방 생성 시작] - partyId: {}", party.getId());
Expand Down Expand Up @@ -51,6 +65,32 @@ public void leavePartyChatRoom(Long partyId, Long memberId) {
log.info("[모임 채팅방 퇴장 완료] - chatRoomId: {}", chatRoom.getId());
}

public void deletePartyChatRoom(Long partyId) {
log.info("[모임 채팅방 삭제 시작] - partyId: {}", partyId);

Optional<ChatRoom> chatRoomOptional = chatRoomRepository.findByPartyId(partyId);

if (chatRoomOptional.isEmpty()) {
log.warn("[모임 채팅방 삭제 스킵] - partyId: {}, 채팅방이 존재하지 않습니다.", partyId);
return;
}

ChatRoom chatRoom = chatRoomOptional.get();
Comment thread
Dimo-2562 marked this conversation as resolved.
Long chatRoomId = chatRoom.getId();
List<String> objectKeys = chatFileRepository.findObjectKeysByChatRoomId(chatRoomId);

objectStorageDeleteOutboxService.enqueuePartyChatFiles(chatRoomId, objectKeys);

messageReadStatusRepository.deleteByChatRoomId(chatRoomId);
chatFileRepository.deleteByChatRoomId(chatRoomId);
chatMessageRepository.deleteByChatRoomId(chatRoomId);
chatRoomMemberRepository.deleteByChatRoomId(chatRoomId);
chatRoomRepository.deleteRoomById(chatRoomId);
applicationEventPublisher.publishEvent(ChatRoomRedisCleanupEvent.of(chatRoomId));

log.info("[모임 채팅방 삭제 완료] - partyId: {}, chatRoomId: {}", partyId, chatRoomId);
}

private ChatRoom findChatRoomByPartyIdOrThrow(Long partyId) {
return chatRoomRepository.findByPartyId(partyId)
.orElseThrow(() -> new ChatException(ChatErrorCode.CHAT_ROOM_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,13 @@ public Set<Long> getChatListSubscribers(Long chatRoomId) {
return Set.of();
}
}

public void tryClearChatListSubscribers(Long chatRoomId) {
try {
stringRedisTemplate.delete(CHAT_LIST_SUBSCRIBERS + chatRoomId);
log.info("채팅방 목록 구독 키 best-effort 삭제 완료 - 채팅방: {}", chatRoomId);
} catch (Exception e) {
log.warn("채팅방 목록 구독 키 best-effort 삭제 실패 - 채팅방: {}, TTL 만료를 기다립니다.", chatRoomId, e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,13 @@ public Set<Long> getSubscribers(Long chatRoomId) {
return Set.of();
}
}

public void tryClearRoomSubscribers(Long chatRoomId) {
try {
stringRedisTemplate.delete(CHAT_ROOM_SUBSCRIBERS + chatRoomId);
log.info("Redis 구독 키 best-effort 삭제 완료 - 채팅방: {}", chatRoomId);
} catch (Exception e) {
log.warn("Redis 구독 키 best-effort 삭제 실패 - 채팅방: {}, TTL 만료를 기다립니다.", chatRoomId, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package umc.cockple.demo.domain.file.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteSourceType;
import umc.cockple.demo.domain.file.enums.ObjectStorageDeleteStatus;
import umc.cockple.demo.global.common.BaseEntity;

import java.time.LocalDateTime;

@Entity
@Table(name = "object_storage_delete_outbox")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class ObjectStorageDeleteOutbox extends BaseEntity {

private static final int LAST_ERROR_MAX_LENGTH = 2000;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "object_key", nullable = false, length = 512)
private String objectKey;

@Enumerated(EnumType.STRING)
@Column(name = "source_type", nullable = false, length = 50)
private ObjectStorageDeleteSourceType sourceType;

@Column(name = "source_id")
private Long sourceId;

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ObjectStorageDeleteStatus status;

@Column(name = "retry_count", nullable = false)
private int retryCount;

@Column(name = "last_error", length = LAST_ERROR_MAX_LENGTH)
private String lastError;

@Column(name = "last_attempted_at")
private LocalDateTime lastAttemptedAt;

@Column(name = "claim_token", length = 36)
private String claimToken;

public static ObjectStorageDeleteOutbox pending(String objectKey, ObjectStorageDeleteSourceType sourceType, Long sourceId) {
return ObjectStorageDeleteOutbox.builder()
.objectKey(objectKey)
.sourceType(sourceType)
.sourceId(sourceId)
.status(ObjectStorageDeleteStatus.PENDING)
.retryCount(0)
.build();
}

public void markDone() {
this.status = ObjectStorageDeleteStatus.DONE;
this.lastError = null;
this.lastAttemptedAt = LocalDateTime.now();
this.claimToken = null;
}

public void markFailed(String errorMessage) {
this.status = ObjectStorageDeleteStatus.FAILED;
this.retryCount++;
this.lastError = truncate(errorMessage);
this.lastAttemptedAt = LocalDateTime.now();
this.claimToken = null;
}

private String truncate(String value) {
if (value == null || value.length() <= LAST_ERROR_MAX_LENGTH) {
return value;
}
return value.substring(0, LAST_ERROR_MAX_LENGTH);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package umc.cockple.demo.domain.file.enums;

public enum ObjectStorageDeleteSourceType {
PARTY_CHAT_ROOM
}
Loading