diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomAvatarController.java b/src/main/java/com/back/domain/studyroom/controller/RoomAvatarController.java new file mode 100644 index 00000000..a0fa9f56 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/controller/RoomAvatarController.java @@ -0,0 +1,87 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.AvatarResponse; +import com.back.domain.studyroom.dto.UpdateAvatarRequest; +import com.back.domain.studyroom.service.AvatarService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CurrentUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 방 아바타 관리 API 컨트롤러 + * - JWT 인증 필수 + * - MEMBER 등급 이상만 아바타 변경 가능 + */ +@RestController +@RequestMapping("/api/rooms/{roomId}/avatars") +@RequiredArgsConstructor +@Tag(name = "Room Avatar API", description = "방 아바타 관련 API") +@SecurityRequirement(name = "Bearer Authentication") +public class RoomAvatarController { + + private final AvatarService avatarService; + private final CurrentUser currentUser; + + /** + * 사용 가능한 아바타 목록 조회 + */ + @GetMapping + @Operation( + summary = "아바타 목록 조회", + description = "선택 가능한 아바타 목록을 조회합니다. 고양이, 강아지 등 다양한 아바타를 제공합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getAvatars( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + List avatars = avatarService.getAvailableAvatars(); + + return ResponseEntity.ok( + RsData.success("아바타 목록 조회 완료", avatars) + ); + } + + /** + * 내 아바타 변경 (모든 사용자 가능) + */ + @PutMapping("/me") + @Operation( + summary = "아바타 변경", + description = "방에서 사용할 아바타를 변경합니다.\n\n" + + "- VISITOR: Redis에만 저장 (퇴장 시 삭제, 재입장 시 랜덤 배정)\n" + + "- MEMBER 이상: DB에 저장 (재입장 시에도 유지)" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "변경 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 아바타 ID"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방 또는 아바타"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> updateMyAvatar( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Valid @RequestBody UpdateAvatarRequest request) { + + Long userId = currentUser.getUserId(); + + avatarService.updateRoomAvatar(roomId, userId, request.getAvatarId()); + + return ResponseEntity.ok( + RsData.success("아바타가 변경되었습니다", null) + ); + } +} diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomController.java b/src/main/java/com/back/domain/studyroom/controller/RoomController.java index 39b30bde..99389c7f 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -496,7 +496,7 @@ public ResponseEntity> activateRoom( @GetMapping("/{roomId}/members") @Operation( summary = "방 멤버 목록 조회", - description = "방의 현재 온라인 멤버 목록을 조회합니다. 역할별로 정렬됩니다(방장>부방장>멤버>방문객)." + description = "방의 현재 온라인 멤버 목록을 조회합니다. 프로필 이미지와 아바타 정보를 포함. 역할별로 정렬됩니다(방장>부방장>멤버>방문객)." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공"), @@ -511,9 +511,8 @@ public ResponseEntity>> getRoomMembers( List members = roomService.getRoomMembers(roomId, currentUserId); - List memberList = members.stream() - .map(RoomMemberResponse::from) - .collect(Collectors.toList()); + // 아바타 정보 포함하여 변환 (N+1 방지) + List memberList = roomService.toRoomMemberResponseList(roomId, members); return ResponseEntity .status(HttpStatus.OK) diff --git a/src/main/java/com/back/domain/studyroom/dto/AvatarResponse.java b/src/main/java/com/back/domain/studyroom/dto/AvatarResponse.java new file mode 100644 index 00000000..7a95c5bf --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/AvatarResponse.java @@ -0,0 +1,30 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.Avatar; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 아바타 정보 응답 DTO + */ +@Getter +@AllArgsConstructor +public class AvatarResponse { + private Long id; + private String name; + private String imageUrl; + private String description; + private boolean isDefault; + private String category; + + public static AvatarResponse from(Avatar avatar) { + return new AvatarResponse( + avatar.getId(), + avatar.getName(), + avatar.getImageUrl(), + avatar.getDescription(), + avatar.isDefault(), + avatar.getCategory() + ); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java index a6f0aa21..8da01d09 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java @@ -12,16 +12,39 @@ public class RoomMemberResponse { private Long userId; private String nickname; + private String profileImageUrl; + private Long avatarId; + private String avatarImageUrl; private RoomRole role; private LocalDateTime joinedAt; private LocalDateTime promotedAt; + - // TODO: isOnline은 Redis에서 조회하여 추가 예정 - + /** + * RoomMember만으로 생성 (아바타 정보 없음) + * 기존 호환성과 간단한 조회용 + */ public static RoomMemberResponse from(RoomMember member) { return RoomMemberResponse.builder() .userId(member.getUser().getId()) .nickname(member.getUser().getNickname()) + .profileImageUrl(member.getUser().getProfileImageUrl()) + .role(member.getRole()) + .joinedAt(member.getJoinedAt()) + .promotedAt(member.getPromotedAt()) + .build(); + } + + /** + * 아바타 정보를 포함된 내용으로 생성 + */ + public static RoomMemberResponse of(RoomMember member, Long avatarId, String avatarImageUrl) { + return RoomMemberResponse.builder() + .userId(member.getUser().getId()) + .nickname(member.getUser().getNickname()) + .profileImageUrl(member.getUser().getProfileImageUrl()) + .avatarId(avatarId) + .avatarImageUrl(avatarImageUrl) .role(member.getRole()) .joinedAt(member.getJoinedAt()) .promotedAt(member.getPromotedAt()) diff --git a/src/main/java/com/back/domain/studyroom/dto/UpdateAvatarRequest.java b/src/main/java/com/back/domain/studyroom/dto/UpdateAvatarRequest.java new file mode 100644 index 00000000..f890d8bd --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/UpdateAvatarRequest.java @@ -0,0 +1,20 @@ +package com.back.domain.studyroom.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 아바타 변경 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateAvatarRequest { + + @NotNull(message = "아바타 ID는 필수입니다") + @Positive(message = "올바른 아바타 ID를 입력해주세요") + private Long avatarId; +} diff --git a/src/main/java/com/back/domain/studyroom/entity/Avatar.java b/src/main/java/com/back/domain/studyroom/entity/Avatar.java new file mode 100644 index 00000000..08dc1891 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/entity/Avatar.java @@ -0,0 +1,36 @@ +package com.back.domain.studyroom.entity; + +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** + * 아바타 마스터 테이블 + * - 선택 가능한 모든 아바타 정보를 관리 + * - 고양이, 강아지 등 다양한 아바타로 확장 가능 + * - isDefault=true인 아바타(1,2,3)는 VISITOR 랜덤 배정용 + */ +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "avatars") +public class Avatar extends BaseEntity { + + @Column(nullable = false, length = 50) + private String name; // "검은 고양이", "하얀 고양이", "골든 리트리버" 등등등 + @Column(nullable = false, length = 500) + private String imageUrl; // CDN URL + @Column(length = 200) + private String description; // "귀여운 검은 고양이" + @Column(nullable = false) + private boolean isDefault; // 기본(랜덤) 아바타 여부 + @Column(nullable = false) + private int sortOrder; // 표시 순서 (1, 2, 3...) + @Column(length = 50) + private String category; // "CAT", "DOG", "ETC" 등 (추후 확장 가능을 위해 카테고리를 나눴드아) +} diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomMemberAvatar.java b/src/main/java/com/back/domain/studyroom/entity/RoomMemberAvatar.java new file mode 100644 index 00000000..a28e7f5f --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/entity/RoomMemberAvatar.java @@ -0,0 +1,50 @@ +package com.back.domain.studyroom.entity; + +import com.back.domain.user.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +/** + * 방별 아바타 설정 테이블 + * - MEMBER 등급 이상만 저장됨 (VISITOR는 저장 안함) + * - 사용자가 아바타를 변경하면 이 테이블에 기록 + * - 재입장 시 저장된 아바타 자동 로드 + */ +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "room_member_avatars", + uniqueConstraints = @UniqueConstraint(columnNames = {"room_id", "user_id"})) +public class RoomMemberAvatar extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private Room room; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "avatar_id", nullable = false) + private Avatar selectedAvatar; + + private LocalDateTime updatedAt; + + /** + * 선택한 아바타 변경 + */ + public void setSelectedAvatar(Avatar newAvatar) { + this.selectedAvatar = newAvatar; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/repository/AvatarRepository.java b/src/main/java/com/back/domain/studyroom/repository/AvatarRepository.java new file mode 100644 index 00000000..7a655e10 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/AvatarRepository.java @@ -0,0 +1,26 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.Avatar; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AvatarRepository extends JpaRepository { + + /** + * 정렬 순서대로 모든 아바타 조회 + */ + List findAllByOrderBySortOrderAsc(); + + /** + * 기본 아바타만 조회 (랜덤 배정용) + */ + List findByIsDefaultTrueOrderBySortOrderAsc(); + + /** + * 카테고리별 아바타 조회하도록 하는 (고양이 말고도 다른 귀여운 애들을 대비해서 추후 확장용) + */ + List findByCategoryOrderBySortOrderAsc(String category); +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepository.java new file mode 100644 index 00000000..ecacb7e8 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepository.java @@ -0,0 +1,30 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomMemberAvatar; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface RoomMemberAvatarRepository extends JpaRepository { + + /** + * 특정 방에서 특정 사용자의 아바타 설정 조회 + */ + Optional findByRoomIdAndUserId(Long roomId, Long userId); + + /** + * 특정 방의 모든 아바타 설정 조회 (일괄 조회용) + */ + @Query("SELECT rma FROM RoomMemberAvatar rma " + + "JOIN FETCH rma.selectedAvatar " + + "WHERE rma.room.id = :roomId AND rma.user.id IN :userIds") + List findByRoomIdAndUserIdIn(@Param("roomId") Long roomId, + @Param("userIds") Set userIds); +} diff --git a/src/main/java/com/back/domain/studyroom/service/AvatarService.java b/src/main/java/com/back/domain/studyroom/service/AvatarService.java new file mode 100644 index 00000000..1a84b74d --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/AvatarService.java @@ -0,0 +1,191 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.dto.AvatarResponse; +import com.back.domain.studyroom.entity.Avatar; +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomMemberAvatar; +import com.back.domain.studyroom.entity.RoomRole; +import com.back.domain.studyroom.repository.AvatarRepository; +import com.back.domain.studyroom.repository.RoomMemberAvatarRepository; +import com.back.domain.studyroom.repository.RoomMemberRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 아바타 관리 서비스 + * - VISITOR: 랜덤 아바타 배정 (DB 저장 안함) + * - MEMBER 이상: 아바타 변경 시 DB 저장 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class AvatarService { + + private final AvatarRepository avatarRepository; + private final RoomMemberAvatarRepository roomMemberAvatarRepository; + private final RoomMemberRepository roomMemberRepository; + private final UserRepository userRepository; + private final com.back.global.websocket.service.RoomParticipantService roomParticipantService; // ⭐ 추가 + + // 기본 아바타 ID 캐시 (애플리케이션 시작 시 로드) + private List defaultAvatarIds = null; + + /** + * 방 입장 시 아바타 로드 또는 생성 + * @param roomId 방 ID + * @param userId 사용자 ID + * @return 사용할 아바타 ID + */ + public Long loadOrCreateAvatar(Long roomId, Long userId) { + // 1. MEMBER 이상인지 확인 + Optional memberOpt = + roomMemberRepository.findByRoomIdAndUserId(roomId, userId); + + if (memberOpt.isEmpty()) { + // VISITOR → 랜덤 아바타 배정 + log.debug("VISITOR 입장 - RoomId: {}, UserId: {}, 랜덤 아바타 배정", roomId, userId); + return assignRandomAvatar(); + } + + RoomMember member = memberOpt.get(); + + // 2. MEMBER 이상 → DB에서 저장된 아바타 조회 + Optional savedAvatar = + roomMemberAvatarRepository.findByRoomIdAndUserId(roomId, userId); + + if (savedAvatar.isPresent()) { + // 이전에 설정한 아바타 있음 + Long avatarId = savedAvatar.get().getSelectedAvatar().getId(); + log.debug("MEMBER 재입장 - RoomId: {}, UserId: {}, 저장된 아바타: {}", + roomId, userId, avatarId); + return avatarId; + } + + // 3. MEMBER 이상이지만 아직 아바타 미설정 → 랜덤 배정 (DB 저장 안함) + log.debug("MEMBER 첫 입장 - RoomId: {}, UserId: {}, 랜덤 아바타 배정", roomId, userId); + return assignRandomAvatar(); + } + + /** + * 랜덤 아바타 배정 (기본 아바타 중 랜덤 선택) + * @return 아바타 ID + */ + public Long assignRandomAvatar() { + // 기본 아바타 목록 캐싱 + if (defaultAvatarIds == null || defaultAvatarIds.isEmpty()) { + loadDefaultAvatars(); + } + + if (defaultAvatarIds.isEmpty()) { + // 기본 아바타가 없으면 첫 번째 아바타 반환 + log.warn("기본 아바타가 없습니다. 첫 번째 아바타를 사용합니다."); + Avatar firstAvatar = avatarRepository.findAll().stream() + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.AVATAR_NOT_FOUND)); + return firstAvatar.getId(); + } + + Random random = new Random(); + int index = random.nextInt(defaultAvatarIds.size()); + Long selectedId = defaultAvatarIds.get(index); + + log.debug("랜덤 아바타 선택: {} (총 {}개 중)", selectedId, defaultAvatarIds.size()); + return selectedId; + } + + /** + * 기본 아바타 목록 로드 + */ + private void loadDefaultAvatars() { + List defaultAvatars = avatarRepository.findByIsDefaultTrueOrderBySortOrderAsc(); + defaultAvatarIds = defaultAvatars.stream() + .map(Avatar::getId) + .collect(Collectors.toList()); + + log.info("기본 아바타 로드 완료: {}개", defaultAvatarIds.size()); + } + + /** + * 아바타 변경 + * - VISITOR: Redis에만 저장 (퇴장 시 삭제) + * - MEMBER 이상: Redis + DB 저장 (재입장 시 유지) + * @param roomId 방 ID + * @param userId 사용자 ID + * @param newAvatarId 새 아바타 ID + */ + @Transactional + public void updateRoomAvatar(Long roomId, Long userId, Long newAvatarId) { + // 1. 선택한 아바타 존재 확인 + Avatar newAvatar = avatarRepository.findById(newAvatarId) + .orElseThrow(() -> new CustomException(ErrorCode.AVATAR_NOT_FOUND)); + + // 2. 방 멤버 여부 확인 (VISITOR도 가능하도록 Optional 사용) + Optional memberOpt = roomMemberRepository + .findByRoomIdAndUserId(roomId, userId); + + // 3-1. MEMBER 이상인 경우: DB에 저장 + if (memberOpt.isPresent()) { + RoomMember member = memberOpt.get(); + + // DB에 저장 (최초 또는 업데이트) + RoomMemberAvatar roomAvatar = roomMemberAvatarRepository + .findByRoomIdAndUserId(roomId, userId) + .orElse(RoomMemberAvatar.builder() + .room(member.getRoom()) + .user(member.getUser()) + .build()); + + roomAvatar.setSelectedAvatar(newAvatar); + roomMemberAvatarRepository.save(roomAvatar); + + log.info("아바타 변경 완료 (DB 저장) - RoomId: {}, UserId: {}, Role: {}, AvatarId: {}", + roomId, userId, member.getRole(), newAvatarId); + } + // 3-2. VISITOR인 경우: Redis에만 저장 (DB 저장 안함) + else { + log.info("아바타 변경 완료 (Redis만 저장) - RoomId: {}, UserId: {}, Role: VISITOR, AvatarId: {}", + roomId, userId, newAvatarId); + } + + // 4. Redis에 아바타 업데이트 (모든 사용자 공통) + roomParticipantService.updateUserAvatar(roomId, userId, newAvatarId); + } + + /** + * 사용 가능한 아바타 목록 조회 + */ + public List getAvailableAvatars() { + return avatarRepository.findAllByOrderBySortOrderAsc() + .stream() + .map(AvatarResponse::from) + .collect(Collectors.toList()); + } + + /** + * 특정 아바타 조회 + */ + public Avatar getAvatarById(Long avatarId) { + return avatarRepository.findById(avatarId) + .orElse(null); + } + + /** + * 여러 아바타 일괄 조회 (N+1 방지) + */ + public Map getAvatarsByIds(Set avatarIds) { + return avatarRepository.findAllById(avatarIds) + .stream() + .collect(Collectors.toMap(Avatar::getId, avatar -> avatar)); + } +} diff --git a/src/main/java/com/back/domain/studyroom/service/RoomService.java b/src/main/java/com/back/domain/studyroom/service/RoomService.java index 8aee49b0..0c389550 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -51,6 +51,7 @@ public class RoomService { private final RoomParticipantService roomParticipantService; private final SimpMessagingTemplate messagingTemplate; private final ApplicationEventPublisher eventPublisher; + private final AvatarService avatarService; /** * 방 생성 메서드 @@ -135,16 +136,19 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + // 아바타 로드/생성 + Long avatarId = avatarService.loadOrCreateAvatar(roomId, userId); + Optional existingMember = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); if (existingMember.isPresent()) { // 기존 멤버 재입장: DB에 있는 역할 그대로 사용 RoomMember member = existingMember.get(); - // Redis에 온라인 등록 - roomParticipantService.enterRoom(userId, roomId); + // Redis에 온라인 등록 (아바타 포함) + roomParticipantService.enterRoom(userId, roomId, avatarId); - log.info("기존 멤버 재입장 - RoomId: {}, UserId: {}, Role: {}", - roomId, userId, member.getRole()); + log.info("기존 멤버 재입장 - RoomId: {}, UserId: {}, Role: {}, AvatarId: {}", + roomId, userId, member.getRole(), avatarId); return member; } @@ -152,10 +156,11 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { // 신규 입장자: VISITOR로 입장 (DB 저장 안함!) RoomMember visitorMember = RoomMember.createVisitor(room, user); - // Redis에만 온라인 등록 - roomParticipantService.enterRoom(userId, roomId); + // Redis에만 온라인 등록 (아바타 포함) + roomParticipantService.enterRoom(userId, roomId, avatarId); - log.info("신규 입장 (VISITOR) - RoomId: {}, UserId: {}, DB 저장 안함", roomId, userId); + log.info("신규 입장 (VISITOR) - RoomId: {}, UserId: {}, DB 저장 안함, AvatarId: {}", + roomId, userId, avatarId); // 메모리상 객체 반환 (DB에 저장되지 않음) return visitorMember; @@ -709,4 +714,51 @@ public java.util.List toMyRoomResp }) .collect(java.util.stream.Collectors.toList()); } + + /** + * RoomMemberResponse 리스트 생성 (아바타 정보 포함, N+1 방지) + * @param roomId 방 ID + * @param members 멤버 목록 + * @return 아바타 정보가 포함된 RoomMemberResponse 리스트 + */ + public java.util.List toRoomMemberResponseList( + Long roomId, + java.util.List members) { + + if (members.isEmpty()) { + return java.util.List.of(); + } + + // 1. 모든 사용자 ID 추출 + Set userIds = members.stream() + .map(m -> m.getUser().getId()) + .collect(java.util.stream.Collectors.toSet()); + + // 2. Redis에서 아바타 ID 일괄 조회 + java.util.Map avatarMap = roomParticipantService.getUserAvatars(roomId, userIds); + + // 3. 아바타 ID Set 생성 + Set avatarIds = new java.util.HashSet<>(avatarMap.values()); + + // 4. Avatar 엔티티 일괄 조회 + java.util.Map avatarEntityMap = + avatarService.getAvatarsByIds(avatarIds); + + // 5. RoomMemberResponse 생성 + return members.stream() + .map(member -> { + Long userId = member.getUser().getId(); + Long avatarId = avatarMap.get(userId); + + String avatarImageUrl = null; + if (avatarId != null) { + com.back.domain.studyroom.entity.Avatar avatar = avatarEntityMap.get(avatarId); + avatarImageUrl = avatar != null ? avatar.getImageUrl() : null; + } + + return com.back.domain.studyroom.dto.RoomMemberResponse.of( + member, avatarId, avatarImageUrl); + }) + .collect(java.util.stream.Collectors.toList()); + } } diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 20f543c4..9c6daf1e 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -40,6 +40,9 @@ public enum ErrorCode { ROOM_PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "ROOM_017", "현재 비밀번호가 일치하지 않습니다."), NOT_ROOM_HOST(HttpStatus.FORBIDDEN, "ROOM_018", "방장 권한이 필요합니다."), + // ======================== 아바타 관련 ======================== + AVATAR_NOT_FOUND(HttpStatus.NOT_FOUND, "AVATAR_001", "존재하지 않는 아바타입니다."), + // ======================== 스터디 플래너 관련 ======================== PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 학습 계획입니다."), diff --git a/src/main/java/com/back/global/initData/DevInitData.java b/src/main/java/com/back/global/initData/DevInitData.java index 43e2a411..d4658a03 100644 --- a/src/main/java/com/back/global/initData/DevInitData.java +++ b/src/main/java/com/back/global/initData/DevInitData.java @@ -39,10 +39,11 @@ public class DevInitData { @Bean ApplicationRunner DevInitDataApplicationRunner() { - return args -> initialize(); + return args -> { + initialize(); + }; } - @Transactional public void initialize() { runDataSql(); initUsersAndPostsAndComments(); @@ -102,7 +103,7 @@ public void initUsersAndPostsAndComments() { } } - private void createSamplePosts(User user1, User user2, User user3) { + private void createSamplePosts(User user1, User user2, User user3) { // ⭐ @Transactional 제거 Post post1 = new Post(user1, "[백엔드] 같이 스프링 공부하실 분 구해요!", "매주 토요일 오후 2시에 온라인으로 스터디 진행합니다.\n교재는 '스프링 완전정복'을 사용할 예정입니다.", diff --git a/src/main/java/com/back/global/websocket/service/RoomParticipantService.java b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java index 14804939..b8ef6d64 100644 --- a/src/main/java/com/back/global/websocket/service/RoomParticipantService.java +++ b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java @@ -23,8 +23,8 @@ public class RoomParticipantService { private final RedisSessionStore redisSessionStore; - // 사용자 방 입장 - public void enterRoom(Long userId, Long roomId) { + // 사용자 방 입장 (아바타 정보 포함) + public void enterRoom(Long userId, Long roomId, Long avatarId) { WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); if (sessionInfo == null) { @@ -41,8 +41,16 @@ public void enterRoom(Long userId, Long roomId) { WebSocketSessionInfo updatedSession = sessionInfo.withRoomId(roomId); redisSessionStore.saveUserSession(userId, updatedSession); redisSessionStore.addUserToRoom(roomId, userId); + + // 아바타 정보 저장 + saveUserAvatar(roomId, userId, avatarId); - log.info("방 입장 완료 - 사용자: {}, 방: {}", userId, roomId); + log.info("방 입장 완료 - 사용자: {}, 방: {}, 아바타: {}", userId, roomId, avatarId); + } + + // 기존 메서드 호환성 유지 (아바타 없이 입장) + public void enterRoom(Long userId, Long roomId) { + enterRoom(userId, roomId, null); } // 사용자 방 퇴장 @@ -107,4 +115,94 @@ public void exitAllRooms(Long userId) { public java.util.Map getParticipantCounts(java.util.List roomIds) { return redisSessionStore.getRoomUserCounts(roomIds); } + + // ==================== 아바타 관련 메서드 ==================== + + /** + * 사용자의 아바타 정보 저장 (Redis) + * @param roomId 방 ID + * @param userId 사용자 ID + * @param avatarId 아바타 ID + */ + private void saveUserAvatar(Long roomId, Long userId, Long avatarId) { + if (avatarId == null) { + return; // 아바타 정보가 없으면 저장하지 않음 + } + + String avatarKey = buildAvatarKey(roomId, userId); + redisSessionStore.saveValue(avatarKey, avatarId.toString(), + java.time.Duration.ofMinutes(6)); + + log.debug("아바타 정보 저장 - RoomId: {}, UserId: {}, AvatarId: {}", + roomId, userId, avatarId); + } + + /** + * 사용자의 아바타 ID 조회 + * @param roomId 방 ID + * @param userId 사용자 ID + * @return 아바타 ID (없으면 null) + */ + public Long getUserAvatar(Long roomId, Long userId) { + String avatarKey = buildAvatarKey(roomId, userId); + String avatarIdStr = redisSessionStore.getValue(avatarKey); + + if (avatarIdStr == null) { + return null; + } + + try { + return Long.parseLong(avatarIdStr); + } catch (NumberFormatException e) { + log.warn("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}", + roomId, userId, avatarIdStr); + return null; + } + } + + /** + * 여러 사용자의 아바타 ID 일괄 조회 (N+1 방지) + * @param roomId 방 ID + * @param userIds 사용자 ID 목록 + * @return 사용자 ID → 아바타 ID 맵 + */ + public java.util.Map getUserAvatars(Long roomId, Set userIds) { + java.util.Map result = new java.util.HashMap<>(); + + for (Long userId : userIds) { + Long avatarId = getUserAvatar(roomId, userId); + if (avatarId != null) { + result.put(userId, avatarId); + } + } + + return result; + } + + /** + * 아바타 Redis Key 생성 + */ + private String buildAvatarKey(Long roomId, Long userId) { + return "ws:room:" + roomId + ":user:" + userId + ":avatar"; + } + + /** + * 아바타 정보 업데이트 (외부에서 호출 가능) + * VISITOR가 아바타를 변경할 때 사용 + * @param roomId 방 ID + * @param userId 사용자 ID + * @param avatarId 새 아바타 ID + */ + public void updateUserAvatar(Long roomId, Long userId, Long avatarId) { + if (avatarId == null) { + return; + } + + String avatarKey = buildAvatarKey(roomId, userId); + redisSessionStore.saveValue(avatarKey, avatarId.toString(), + java.time.Duration.ofMinutes(6)); + + log.info("아바타 업데이트 (Redis) - RoomId: {}, UserId: {}, AvatarId: {}", + roomId, userId, avatarId); + } } diff --git a/src/main/java/com/back/global/websocket/store/RedisSessionStore.java b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java index 0f4e34d8..1b37d85f 100644 --- a/src/main/java/com/back/global/websocket/store/RedisSessionStore.java +++ b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java @@ -267,4 +267,50 @@ private Long convertToLong(Object obj) { throw new IllegalArgumentException("Cannot convert " + obj.getClass() + " to Long"); } } + + // ==================== 범용 Key-Value 저장/조회 메서드 ==================== + + /** + * 범용 값 저장 (TTL 포함) + * @param key Redis Key + * @param value 저장할 값 + * @param ttl TTL (Duration) + */ + public void saveValue(String key, String value, java.time.Duration ttl) { + try { + redisTemplate.opsForValue().set(key, value, ttl); + log.debug("값 저장 완료 - Key: {}, TTL: {}분", key, ttl.toMinutes()); + } catch (Exception e) { + log.error("값 저장 실패 - Key: {}", key, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + /** + * 범용 값 조회 + * @param key Redis Key + * @return 저장된 값 (없으면 null) + */ + public String getValue(String key) { + try { + Object value = redisTemplate.opsForValue().get(key); + return value != null ? value.toString() : null; + } catch (Exception e) { + log.error("값 조회 실패 - Key: {}", key, e); + return null; // 에러 시 null 반환 (예외 던지지 않음) + } + } + + /** + * 범용 값 삭제 + * @param key Redis Key + */ + public void deleteValue(String key) { + try { + redisTemplate.delete(key); + log.debug("값 삭제 완료 - Key: {}", key); + } catch (Exception e) { + log.error("값 삭제 실패 - Key: {}", key, e); + } + } } \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 52bf31d4..5cb3e989 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -94,3 +94,12 @@ VALUES ('2~4명', 'GROUP_SIZE', NOW(), NOW()), ('5~10명', 'GROUP_SIZE', NOW(), NOW()), ('10~20명', 'GROUP_SIZE', NOW(), NOW()); + +-- ========================= +-- AVATAR 초기 데이터 (고양이 아바타 3개 - 기본 랜덤하기 위해 배정) +-- ========================= +INSERT INTO avatars (id, name, image_url, description, is_default, sort_order, category, created_at, updated_at) +VALUES + (1, '검은 고양이', '/images/avatars/cat-black.png', '귀여운 검은 고양이', true, 1, 'CAT', NOW(), NOW()), + (2, '하얀 고양이', '/images/avatars/cat-white.png', '우아한 하얀 고양이', true, 2, 'CAT', NOW(), NOW()), + (3, '노란 고양이', '/images/avatars/cat-orange.png', '발랄한 노란 고양이', true, 3, 'CAT', NOW(), NOW()); diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java index 326beb13..26ab1961 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -329,6 +329,10 @@ void getRoomMembers() { given(currentUser.getUserId()).willReturn(1L); given(roomService.getRoomMembers(eq(1L), eq(1L))).willReturn(Arrays.asList(testMember)); + + // toRoomMemberResponseList 호출 추가 + List memberResponses = Arrays.asList(RoomMemberResponse.from(testMember)); + given(roomService.toRoomMemberResponseList(eq(1L), anyList())).willReturn(memberResponses); // when ResponseEntity>> response = roomController.getRoomMembers(1L); @@ -342,6 +346,7 @@ void getRoomMembers() { verify(currentUser, times(1)).getUserId(); verify(roomService, times(1)).getRoomMembers(eq(1L), eq(1L)); + verify(roomService, times(1)).toRoomMemberResponseList(eq(1L), anyList()); } @Test diff --git a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java index 6e81d16f..815ac8e1 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -58,6 +58,9 @@ class RoomServiceTest { @Mock private NotificationService notificationService; + + @Mock + private AvatarService avatarService; @InjectMocks private RoomService roomService; @@ -157,6 +160,7 @@ void joinRoom_Success() { given(userRepository.findById(2L)).willReturn(Optional.of(testUser)); given(roomMemberRepository.findByRoomIdAndUserId(1L, 2L)).willReturn(Optional.empty()); given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); // Redis 카운트 + given(avatarService.loadOrCreateAvatar(1L, 2L)).willReturn(1L); // 아바타 Mock 추가 // when RoomMember joinedMember = roomService.joinRoom(1L, null, 2L); @@ -164,7 +168,8 @@ void joinRoom_Success() { // then assertThat(joinedMember).isNotNull(); assertThat(joinedMember.getRole()).isEqualTo(RoomRole.VISITOR); - verify(roomParticipantService, times(1)).enterRoom(2L, 1L); // Redis 입장 확인 + verify(avatarService, times(1)).loadOrCreateAvatar(1L, 2L); + verify(roomParticipantService, times(1)).enterRoom(eq(2L), eq(1L), any()); // avatarId 파라미터 추가 verify(roomMemberRepository, never()).save(any(RoomMember.class)); // DB 저장 안됨! }