Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ public enum ErrorStatus implements BaseErrorCode {
MESSAGE_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "SEARCH403", "이미 삭제된 메시지입니다."),
SEARCH_OPTION_INVALID(HttpStatus.BAD_REQUEST, "SEARCH404", "검색 옵션을 확인해주세요."),
CHAT_TYPE_INVALID(HttpStatus.BAD_REQUEST, "SEARCH405", "채팅 타입을 확인해주세요."),
CHANNEL_LAST_INFO_NOT_FOUND(HttpStatus.NOT_FOUND, "SEARCH406", "채널 마지막 정보를 찾을 수 없습니다.")
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
package com.bbebig.searchserver.domain.history.controller;

import com.bbebig.commonmodule.global.response.code.CommonResponse;
import com.bbebig.commonmodule.passport.annotation.PassportUser;
import com.bbebig.commonmodule.proto.PassportProto;
import com.bbebig.searchserver.domain.history.dto.HistoryResponseDto.*;
import com.bbebig.searchserver.domain.history.service.HistoryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

import static com.bbebig.commonmodule.proto.PassportProto.*;

@Slf4j
@RestController("/history")
@RestController
@RequestMapping("/history")
@RequiredArgsConstructor
public class HistoryController {

Expand All @@ -28,6 +31,7 @@ public class HistoryController {
})
@GetMapping("/server/{serverId}/channel/{channelId}/messages")
public CommonResponse<GetChannelMessageResponseDto> getServerChannelMessageByChannelId(@PathVariable Long serverId, @PathVariable Long channelId,
@Parameter(hidden = true) @PassportUser Passport passport,
@RequestParam(required = false) Long messageId,
@RequestParam(defaultValue = "300") int limit) {
log.info("[Search] ChatMessageServiceController: 채널 ID로 메시지 검색 요청. serverID: {}, channelID: {}", serverId, channelId);
Expand All @@ -41,6 +45,7 @@ public CommonResponse<GetChannelMessageResponseDto> getServerChannelMessageByCha
})
@GetMapping("/dm/{channelId}/messages")
public CommonResponse<GetDmMessageResponseDto> getDmChannelMessageByChannelId(@PathVariable Long channelId,
@Parameter(hidden = true) @PassportUser Passport passport,
@RequestParam(required = false) Long messageId,
@RequestParam(defaultValue = "300") int limit) {
log.info("[Search] ChatMessageServiceController: DM 채널 ID로 메시지 검색 요청. channelID: {}", channelId);
Expand All @@ -52,21 +57,22 @@ public CommonResponse<GetDmMessageResponseDto> getDmChannelMessageByChannelId(@P
@ApiResponse(responseCode = "200", description = "멤버별 모든 서버 안읽은 메시지 수 조회 성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "400", description = "", content = @Content)
})
@GetMapping("/member/{memberId}/unread/server/all")
public CommonResponse<AllServerUnreadCountDto> getMemberAllServerUnreadCount(@PathVariable Long memberId) {
log.info("[Search] ChatMessageServiceController: 멤버별 서버 안읽은 메시지 수 조회 요청. memberId: {}", memberId);
return CommonResponse.onSuccess(historyService.getAllServerUnreadCount(memberId));
@GetMapping("/unread/server/all")
public CommonResponse<AllServerUnreadCountDto> getMemberAllServerUnreadCount(@Parameter(hidden = true) @PassportUser Passport passport) {
log.info("[Search] ChatMessageServiceController: 멤버별 서버 안읽은 메시지 수 조회 요청. memberId: {}", passport.getMemberId());
return CommonResponse.onSuccess(historyService.getAllServerUnreadCount(passport.getMemberId()));
}

@Operation(summary = "멤버별 서버 안읽은 메시지 수 조회", description = "멤버가 속한 서버별로 안읽은 메시지 수 조회")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "멤버별 서버 안읽은 메시지 수 조회 성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "400", description = "", content = @Content)
})
@GetMapping("/member/{memberId}/unread/server/{serverId}")
public CommonResponse<ServerUnreadCountDto> getMemberServerUnreadCount(@PathVariable Long memberId, @PathVariable Long serverId) {
log.info("[Search] ChatMessageServiceController: 멤버별 서버 안읽은 메시지 수 조회 요청. memberId: {}, serverId: {}", memberId, serverId);
return CommonResponse.onSuccess(historyService.getServerUnreadCount(memberId, serverId));
@GetMapping("/unread/server/{serverId}")
public CommonResponse<ServerUnreadCountDto> getMemberServerUnreadCount(@Parameter(hidden = true) @PassportUser Passport passport,
@PathVariable Long serverId) {
log.info("[Search] ChatMessageServiceController: 멤버별 서버 안읽은 메시지 수 조회 요청. memberId: {}, serverId: {}", passport.getMemberId(), serverId);
return CommonResponse.onSuccess(historyService.getServerUnreadCount(passport.getMemberId(), serverId));
}


Expand All @@ -75,9 +81,9 @@ public CommonResponse<ServerUnreadCountDto> getMemberServerUnreadCount(@PathVari
@ApiResponse(responseCode = "200", description = "멤버별 DM 안읽은 메시지 수 조회 성공", useReturnTypeSchema = true),
@ApiResponse(responseCode = "400", description = "", content = @Content)
})
@GetMapping("/member/{memberId}/unread/dm")
public CommonResponse<?> getMemberDmUnreadCount(@PathVariable Long memberId) {
log.info("[Search] ChatMessageServiceController: 멤버별 DM 안읽은 메시지 수 조회 요청. memberId: {}", memberId);
@GetMapping("/unread/dm")
public CommonResponse<?> getMemberDmUnreadCount(@Parameter(hidden = true) @PassportUser Passport passport) {
log.info("[Search] ChatMessageServiceController: 멤버별 DM 안읽은 메시지 수 조회 요청. memberId: {}", passport.getMemberId());
return CommonResponse.onSuccess(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,5 @@ List<ChannelChatMessage> findByChannelIdAndIdLessThan(

List<ChannelChatMessage> findByChannelId(Long channelId, Pageable pageable);

@Query(value = "{ 'channelId': ?0, 'deleted': false }", sort = "{ 'id': -1 }")
Optional<ChannelChatMessage> findTopByChannelIdOrderByIdDesc(Long channelId);
Optional<ChannelChatMessage> findTopByChannelIdAndDeletedFalseOrderByIdDesc(Long channelId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import com.bbebig.commonmodule.global.response.exception.ErrorHandler;
import com.bbebig.commonmodule.kafka.dto.ChatMessageDto;
import com.bbebig.commonmodule.kafka.dto.model.ChatType;
import com.bbebig.commonmodule.redis.domain.ChannelLastInfo;
import com.bbebig.commonmodule.redis.domain.ServerLastInfo;
import com.bbebig.searchserver.domain.history.repository.ChannelChatMessageRepository;
import com.bbebig.searchserver.domain.history.repository.DmChatMessageRepository;
import com.bbebig.searchserver.global.client.ServiceClient;
Expand All @@ -26,10 +28,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.*;

import static com.bbebig.searchserver.domain.history.dto.HistoryResponseDto.*;

Expand Down Expand Up @@ -238,27 +237,29 @@ public AllServerUnreadCountDto getAllServerUnreadCount(Long memberId) {
}

public ServerUnreadCountDto getServerUnreadCount(Long memberId, Long serverId) {
ServerLastInfoResponseDto serverLastInfo = getServerLastInfo(memberId, serverId);
ServerLastInfo memberServerLastInfo = getServerLastInfo(memberId, serverId);

if (serverLastInfo == null) {
if (memberServerLastInfo == null) {
log.error("[Search] ChatMessageService: 서버 마지막 방문 정보 조회 실패. serverId: {}, memberId: {}", serverId, memberId);
throw new ErrorHandler(ErrorStatus.SERVER_LAST_INFO_NOT_FOUND);
}

List<ChannelUnreadCountDto> channelUnreadList = new ArrayList<>();
int serverTotalUnread = 0;

for (ChannelLastInfoResponseDto chDto : serverLastInfo.getChannelInfoList()) {
long lastReadId = (chDto.getLastReadMessageId() == null) ? 0L : chDto.getLastReadMessageId();
for (Long channelId : memberServerLastInfo.getChannelLastInfoMap().keySet()) {
ChannelLastInfo channelLastInfo = memberServerLastInfo.getChannelLastInfoMap().get(channelId);
if (channelLastInfo == null) {
log.error("[Search] ChatMessageService: 채널 마지막 방문 정보 조회 실패. serverId: {}, memberId: {}, channelId: {}", serverId, memberId, channelId);
throw new ErrorHandler(ErrorStatus.CHANNEL_LAST_INFO_NOT_FOUND);
}

List<ChannelChatMessage> cachedMessages = getCachedChannelMessages(chDto.getChannelId());
ServerChannelSequenceResponseDto channelLastSequence = getChannelLastSequence(channelId);

int unread = (int) cachedMessages.stream()
.filter(msg -> msg.getId() != null && msg.getId() > lastReadId)
.count();
int unread = (int)(channelLastSequence.getLastSequence() - channelLastInfo.getLastReadSequence());

channelUnreadList.add(ChannelUnreadCountDto.builder()
.channelId(chDto.getChannelId())
.channelId(channelId)
.unreadCount(unread)
.build());

Expand All @@ -282,9 +283,9 @@ public List<ChannelChatMessage> getCachedChannelMessages(Long channelId) {
public ServerChannelSequenceResponseDto getChannelLastSequence(Long channelId) {
Long lastSequence = serverRedisRepository.getServerChannelSequence(channelId);
if (lastSequence == null) {
Optional<ChannelChatMessage> topByChannelIdOrderByIdDesc = channelChatMessageRepository.findTopByChannelIdOrderByIdDesc(channelId);
Optional<ChannelChatMessage> topByChannelIdOrderByIdDesc = channelChatMessageRepository.findTopByChannelIdAndDeletedFalseOrderByIdDesc(channelId);
if (topByChannelIdOrderByIdDesc.isPresent()) {
lastSequence = topByChannelIdOrderByIdDesc.get().getId();
lastSequence = topByChannelIdOrderByIdDesc.get().getSequence() == null ? 0L : topByChannelIdOrderByIdDesc.get().getSequence();
serverRedisRepository.saveServerChannelSequence(channelId, lastSequence);
} else {
lastSequence = 0L;
Expand All @@ -305,13 +306,36 @@ private void cacheChannelMessage(Long channelId) {
}
}

private ServerLastInfoResponseDto getServerLastInfo(Long memberId, Long serverId) {
ServerLastInfoResponseDto responseDto = null;
try {
responseDto = serviceClient.getServerLastInfo(serverId, memberId);
} catch (FeignException e) {
log.error("[Search] ChatMessageService: Feign으로 서버 정보 조회 중 오류 발생. serverId: {}, memberId: {}", serverId, memberId);
private ServerLastInfo getServerLastInfo(Long memberId, Long serverId) {
ServerLastInfo lastInfo = serverRedisRepository.getServerLastInfo(serverId, memberId);
if (lastInfo == null) {
try {
ServerLastInfoResponseDto responseDto = serviceClient.getServerLastInfo(serverId, memberId);
if (responseDto == null) {
log.error("[Search] ChatMessageService: 서버 마지막 방문 정보 조회 실패. serverId: {}, memberId: {}", serverId, memberId);
throw new ErrorHandler(ErrorStatus.SERVER_LAST_INFO_NOT_FOUND);
}
Map<Long, ChannelLastInfo> channelInfoMap = new HashMap<>();
responseDto.getChannelInfoList().forEach(chDto -> {
channelInfoMap.put(chDto.getChannelId(), ChannelLastInfo.builder()
.channelId(chDto.getChannelId())
.lastReadMessageId(chDto.getLastReadMessageId())
.lastReadSequence(chDto.getLastReadSequence())
.lastAccessAt(chDto.getLastAccessAt())
.build());
});
ServerLastInfo info = ServerLastInfo.builder()
.serverId(serverId)
.channelLastInfoMap(channelInfoMap)
.build();
serverRedisRepository.saveServerLastInfo(memberId, serverId, info);
return info;
} catch (FeignException e) {
log.error("[Search] ChatMessageService: Feign으로 서버 정보 조회 중 오류 발생. serverId: {}, memberId: {}", serverId, memberId);
log.error("[Search] ChatMessageService: FeignException: {}", e.getMessage());
}
}
return responseDto;

return lastInfo;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
import static com.bbebig.searchserver.domain.search.dto.SearchResponseDto.*;

@Slf4j
@RestController("/search")
@RestController
@RequestMapping("/search")
@RequiredArgsConstructor
public class SearchController {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.bbebig.searchserver.global.repository;

import com.bbebig.commonmodule.redis.domain.ServerLastInfo;
import com.bbebig.commonmodule.redis.domain.ServerMemberStatus;
import com.bbebig.commonmodule.redis.repository.ServerRedisRepository;
import com.bbebig.commonmodule.redis.util.MemberRedisKeys;
import com.bbebig.commonmodule.redis.util.MemberRedisTTL;
import com.bbebig.commonmodule.redis.util.ServerRedisKeys;
import com.bbebig.searchserver.domain.history.domain.ChannelChatMessage;
import jakarta.annotation.PostConstruct;
Expand All @@ -15,6 +18,7 @@

import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;

@Slf4j
@Repository
Expand All @@ -33,13 +37,17 @@ public class ServerRedisRepositoryImpl implements ServerRedisRepository {
private final RedisTemplate<String, ChannelChatMessage> redisChannelChatMessageTemplate;
private ListOperations<String, ChannelChatMessage> listOperations;

private final RedisTemplate<String, ServerLastInfo> redisServerLastInfoTemplate;
private HashOperations<String, String, ServerLastInfo> serverLastInfoValueOperations;

private static final int MAX_CACHE_SIZE = 100;

@PostConstruct
public void initRedisOps() {
this.setOperations = redisSetTemplate.opsForSet();
this.hashOperations = redisServerStatusTemplate.opsForHash();
this.listOperations = redisChannelChatMessageTemplate.opsForList();
this.serverLastInfoValueOperations = redisServerLastInfoTemplate.opsForHash();
}

/**
Expand Down Expand Up @@ -199,13 +207,6 @@ public boolean existsChannelMessageList(Long channelId) {
return Boolean.TRUE.equals(redisChannelChatMessageTemplate.hasKey(key));
}

public long getUnreadCount(Long channelId, Long lastReadMessageId) {
List<ChannelChatMessage> cachedMessageList = getChannelMessageList(channelId);
return cachedMessageList.stream()
.filter(msg -> msg.getId() != null && msg.getId() > lastReadMessageId)
.count();
}

/**
* 서버별 채널 시퀀스 정보를 저장
* (serverChannel:{serverId}:channelSequence) => ChannelId, Sequence
Expand All @@ -221,4 +222,20 @@ public Long getServerChannelSequence(Long channelId) {
return redisSetTemplate.opsForValue().get(redisKey);
}

/**
* 개별 유저의 최근 서버 채널 정보를 저장
* ex) member:{memberId}:serverLastInfo => ServerLastInfo
*/
public void saveServerLastInfo(Long memberId, Long serverId, ServerLastInfo lastInfo) {
String key = MemberRedisKeys.getServerLastInfoKey(memberId);
serverLastInfoValueOperations.put(key, serverId.toString(), lastInfo);
serverLastInfoValueOperations.getOperations().expire(key, MemberRedisTTL.SERVER_LAST_INFO_TTL, TimeUnit.SECONDS);
}

// 개별 유저의 최근 서버 채널 정보 조회
public ServerLastInfo getServerLastInfo(Long memberId, Long serverId) {
String key = MemberRedisKeys.getServerLastInfoKey(memberId);
return serverLastInfoValueOperations.get(key, serverId.toString());
}

}
20 changes: 10 additions & 10 deletions src/backend/search-server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ spring:
host: redis

kafka:
bootstrap-servers: kafka:9092
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS}

topic:
channel-chat-event: "channelChatEvent"
Expand All @@ -29,18 +29,18 @@ spring:
member-event: "memberEvent"

producer:
bootstrap-servers: kafka:9092
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS}

consumer:
group-id:
channel-chat-event: "${spring.application.name}-channelChatEventGroup-${INSTANCE_ID}"
dm-chat-event: "${spring.application.name}-dmChatEventGroup-${INSTANCE_ID}"
notification-event: "${spring.application.name}-notificationEventGroup-${INSTANCE_ID}"
server-event: "${spring.application.name}-serverEventGroup-${INSTANCE_ID}"
channel-event: "${spring.application.name}-channelEventGroup-${INSTANCE_ID}"
connection-event: "${spring.application.name}-connectionEventGroup-${INSTANCE_ID}"
presence-event: "${spring.application.name}-presenceEventGroup-${INSTANCE_ID}"
member-event: "${spring.application.name}-memberEventGroup-${INSTANCE_ID}"
channel-chat-event: "${spring.application.name}-channelChatEventGroup"
dm-chat-event: "${spring.application.name}-dmChatEventGroup"
notification-event: "${spring.application.name}-notificationEventGroup"
server-event: "${spring.application.name}-serverEventGroup"
channel-event: "${spring.application.name}-channelEventGroup"
connection-event: "${spring.application.name}-connectionEventGroup"
presence-event: "${spring.application.name}-presenceEventGroup"
member-event: "${spring.application.name}-memberEventGroup"

enable-auto-commit: true
auto-offset-reset: latest
Expand Down