Skip to content

Commit 2de5447

Browse files
committed
Feat: 스터디 룸 내에 고양이 아바타 시스템과 프로필 이미지 url 연동
1 parent 70813b6 commit 2de5447

File tree

15 files changed

+716
-16
lines changed

15 files changed

+716
-16
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.back.domain.studyroom.controller;
2+
3+
import com.back.domain.studyroom.dto.AvatarResponse;
4+
import com.back.domain.studyroom.dto.UpdateAvatarRequest;
5+
import com.back.domain.studyroom.service.AvatarService;
6+
import com.back.global.common.dto.RsData;
7+
import com.back.global.security.user.CurrentUser;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.Parameter;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
12+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
13+
import io.swagger.v3.oas.annotations.tags.Tag;
14+
import jakarta.validation.Valid;
15+
import lombok.RequiredArgsConstructor;
16+
import org.springframework.http.HttpStatus;
17+
import org.springframework.http.ResponseEntity;
18+
import org.springframework.web.bind.annotation.*;
19+
20+
import java.util.List;
21+
22+
/**
23+
* 방 아바타 관리 API 컨트롤러
24+
* - JWT 인증 필수
25+
* - MEMBER 등급 이상만 아바타 변경 가능
26+
*/
27+
@RestController
28+
@RequestMapping("/api/rooms/{roomId}/avatars")
29+
@RequiredArgsConstructor
30+
@Tag(name = "Room Avatar API", description = "방 아바타 관련 API")
31+
@SecurityRequirement(name = "Bearer Authentication")
32+
public class RoomAvatarController {
33+
34+
private final AvatarService avatarService;
35+
private final CurrentUser currentUser;
36+
37+
/**
38+
* 사용 가능한 아바타 목록 조회
39+
*/
40+
@GetMapping
41+
@Operation(
42+
summary = "아바타 목록 조회",
43+
description = "선택 가능한 아바타 목록을 조회합니다. 고양이, 강아지 등 다양한 아바타를 제공합니다."
44+
)
45+
@ApiResponses({
46+
@ApiResponse(responseCode = "200", description = "조회 성공"),
47+
@ApiResponse(responseCode = "401", description = "인증 실패")
48+
})
49+
public ResponseEntity<RsData<List<AvatarResponse>>> getAvatars(
50+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId) {
51+
52+
List<AvatarResponse> avatars = avatarService.getAvailableAvatars();
53+
54+
return ResponseEntity.ok(
55+
RsData.success("아바타 목록 조회 완료", avatars)
56+
);
57+
}
58+
59+
/**
60+
* 내 아바타 변경 (모든 사용자 가능)
61+
*/
62+
@PutMapping("/me")
63+
@Operation(
64+
summary = "아바타 변경",
65+
description = "방에서 사용할 아바타를 변경합니다.\n\n" +
66+
"- VISITOR: Redis에만 저장 (퇴장 시 삭제, 재입장 시 랜덤 배정)\n" +
67+
"- MEMBER 이상: DB에 저장 (재입장 시에도 유지)"
68+
)
69+
@ApiResponses({
70+
@ApiResponse(responseCode = "200", description = "변경 성공"),
71+
@ApiResponse(responseCode = "400", description = "잘못된 아바타 ID"),
72+
@ApiResponse(responseCode = "404", description = "존재하지 않는 방 또는 아바타"),
73+
@ApiResponse(responseCode = "401", description = "인증 실패")
74+
})
75+
public ResponseEntity<RsData<Void>> updateMyAvatar(
76+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
77+
@Valid @RequestBody UpdateAvatarRequest request) {
78+
79+
Long userId = currentUser.getUserId();
80+
81+
avatarService.updateRoomAvatar(roomId, userId, request.getAvatarId());
82+
83+
return ResponseEntity.ok(
84+
RsData.success("아바타가 변경되었습니다", null)
85+
);
86+
}
87+
}

src/main/java/com/back/domain/studyroom/controller/RoomController.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ public ResponseEntity<RsData<Void>> activateRoom(
496496
@GetMapping("/{roomId}/members")
497497
@Operation(
498498
summary = "방 멤버 목록 조회",
499-
description = "방의 현재 온라인 멤버 목록을 조회합니다. 역할별로 정렬됩니다(방장>부방장>멤버>방문객)."
499+
description = "방의 현재 온라인 멤버 목록을 조회합니다. 프로필 이미지와 아바타 정보를 포함. 역할별로 정렬됩니다(방장>부방장>멤버>방문객)."
500500
)
501501
@ApiResponses({
502502
@ApiResponse(responseCode = "200", description = "조회 성공"),
@@ -511,9 +511,8 @@ public ResponseEntity<RsData<List<RoomMemberResponse>>> getRoomMembers(
511511

512512
List<RoomMember> members = roomService.getRoomMembers(roomId, currentUserId);
513513

514-
List<RoomMemberResponse> memberList = members.stream()
515-
.map(RoomMemberResponse::from)
516-
.collect(Collectors.toList());
514+
// 아바타 정보 포함하여 변환 (N+1 방지)
515+
List<RoomMemberResponse> memberList = roomService.toRoomMemberResponseList(roomId, members);
517516

518517
return ResponseEntity
519518
.status(HttpStatus.OK)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.back.domain.studyroom.dto;
2+
3+
import com.back.domain.studyroom.entity.Avatar;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
7+
/**
8+
* 아바타 정보 응답 DTO
9+
*/
10+
@Getter
11+
@AllArgsConstructor
12+
public class AvatarResponse {
13+
private Long id;
14+
private String name;
15+
private String imageUrl;
16+
private String description;
17+
private boolean isDefault;
18+
private String category;
19+
20+
public static AvatarResponse from(Avatar avatar) {
21+
return new AvatarResponse(
22+
avatar.getId(),
23+
avatar.getName(),
24+
avatar.getImageUrl(),
25+
avatar.getDescription(),
26+
avatar.isDefault(),
27+
avatar.getCategory()
28+
);
29+
}
30+
}

src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,39 @@
1212
public class RoomMemberResponse {
1313
private Long userId;
1414
private String nickname;
15+
private String profileImageUrl;
16+
private Long avatarId;
17+
private String avatarImageUrl;
1518
private RoomRole role;
1619
private LocalDateTime joinedAt;
1720
private LocalDateTime promotedAt;
21+
1822

19-
// TODO: isOnline은 Redis에서 조회하여 추가 예정
20-
23+
/**
24+
* RoomMember만으로 생성 (아바타 정보 없음)
25+
* 기존 호환성과 간단한 조회용
26+
*/
2127
public static RoomMemberResponse from(RoomMember member) {
2228
return RoomMemberResponse.builder()
2329
.userId(member.getUser().getId())
2430
.nickname(member.getUser().getNickname())
31+
.profileImageUrl(member.getUser().getProfileImageUrl())
32+
.role(member.getRole())
33+
.joinedAt(member.getJoinedAt())
34+
.promotedAt(member.getPromotedAt())
35+
.build();
36+
}
37+
38+
/**
39+
* 아바타 정보를 포함된 내용으로 생성
40+
*/
41+
public static RoomMemberResponse of(RoomMember member, Long avatarId, String avatarImageUrl) {
42+
return RoomMemberResponse.builder()
43+
.userId(member.getUser().getId())
44+
.nickname(member.getUser().getNickname())
45+
.profileImageUrl(member.getUser().getProfileImageUrl())
46+
.avatarId(avatarId)
47+
.avatarImageUrl(avatarImageUrl)
2548
.role(member.getRole())
2649
.joinedAt(member.getJoinedAt())
2750
.promotedAt(member.getPromotedAt())
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.back.domain.studyroom.dto;
2+
3+
import jakarta.validation.constraints.NotNull;
4+
import jakarta.validation.constraints.Positive;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
/**
10+
* 아바타 변경 요청 DTO
11+
*/
12+
@Getter
13+
@NoArgsConstructor
14+
@AllArgsConstructor
15+
public class UpdateAvatarRequest {
16+
17+
@NotNull(message = "아바타 ID는 필수입니다")
18+
@Positive(message = "올바른 아바타 ID를 입력해주세요")
19+
private Long avatarId;
20+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.back.domain.studyroom.entity;
2+
3+
import com.back.global.entity.BaseEntity;
4+
import jakarta.persistence.*;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
import lombok.experimental.SuperBuilder;
9+
10+
/**
11+
* 아바타 마스터 테이블
12+
* - 선택 가능한 모든 아바타 정보를 관리
13+
* - 고양이, 강아지 등 다양한 아바타로 확장 가능
14+
* - isDefault=true인 아바타(1,2,3)는 VISITOR 랜덤 배정용
15+
*/
16+
@Entity
17+
@Getter
18+
@SuperBuilder
19+
@NoArgsConstructor
20+
@AllArgsConstructor
21+
@Table(name = "avatars")
22+
public class Avatar extends BaseEntity {
23+
24+
@Column(nullable = false, length = 50)
25+
private String name; // "검은 고양이", "하얀 고양이", "골든 리트리버" 등등등
26+
@Column(nullable = false, length = 500)
27+
private String imageUrl; // CDN URL
28+
@Column(length = 200)
29+
private String description; // "귀여운 검은 고양이"
30+
@Column(nullable = false)
31+
private boolean isDefault; // 기본(랜덤) 아바타 여부
32+
@Column(nullable = false)
33+
private int sortOrder; // 표시 순서 (1, 2, 3...)
34+
@Column(length = 50)
35+
private String category; // "CAT", "DOG", "ETC" 등 (추후 확장 가능을 위해 카테고리를 나눴드아)
36+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.back.domain.studyroom.entity;
2+
3+
import com.back.domain.user.entity.User;
4+
import com.back.global.entity.BaseEntity;
5+
import jakarta.persistence.*;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Builder;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
import lombok.experimental.SuperBuilder;
11+
12+
import java.time.LocalDateTime;
13+
14+
/**
15+
* 방별 아바타 설정 테이블
16+
* - MEMBER 등급 이상만 저장됨 (VISITOR는 저장 안함)
17+
* - 사용자가 아바타를 변경하면 이 테이블에 기록
18+
* - 재입장 시 저장된 아바타 자동 로드
19+
*/
20+
@Entity
21+
@Getter
22+
@SuperBuilder
23+
@NoArgsConstructor
24+
@AllArgsConstructor
25+
@Table(name = "room_member_avatars",
26+
uniqueConstraints = @UniqueConstraint(columnNames = {"room_id", "user_id"}))
27+
public class RoomMemberAvatar extends BaseEntity {
28+
29+
@ManyToOne(fetch = FetchType.LAZY)
30+
@JoinColumn(name = "room_id", nullable = false)
31+
private Room room;
32+
33+
@ManyToOne(fetch = FetchType.LAZY)
34+
@JoinColumn(name = "user_id", nullable = false)
35+
private User user;
36+
37+
@ManyToOne(fetch = FetchType.LAZY)
38+
@JoinColumn(name = "avatar_id", nullable = false)
39+
private Avatar selectedAvatar;
40+
41+
private LocalDateTime updatedAt;
42+
43+
/**
44+
* 선택한 아바타 변경
45+
*/
46+
public void setSelectedAvatar(Avatar newAvatar) {
47+
this.selectedAvatar = newAvatar;
48+
this.updatedAt = LocalDateTime.now();
49+
}
50+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.back.domain.studyroom.repository;
2+
3+
import com.back.domain.studyroom.entity.Avatar;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
import java.util.List;
8+
9+
@Repository
10+
public interface AvatarRepository extends JpaRepository<Avatar, Long> {
11+
12+
/**
13+
* 정렬 순서대로 모든 아바타 조회
14+
*/
15+
List<Avatar> findAllByOrderBySortOrderAsc();
16+
17+
/**
18+
* 기본 아바타만 조회 (랜덤 배정용)
19+
*/
20+
List<Avatar> findByIsDefaultTrueOrderBySortOrderAsc();
21+
22+
/**
23+
* 카테고리별 아바타 조회하도록 하는 (고양이 말고도 다른 귀여운 애들을 대비해서 추후 확장용)
24+
*/
25+
List<Avatar> findByCategoryOrderBySortOrderAsc(String category);
26+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.back.domain.studyroom.repository;
2+
3+
import com.back.domain.studyroom.entity.RoomMemberAvatar;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Query;
6+
import org.springframework.data.repository.query.Param;
7+
import org.springframework.stereotype.Repository;
8+
9+
import java.util.List;
10+
import java.util.Map;
11+
import java.util.Optional;
12+
import java.util.Set;
13+
14+
@Repository
15+
public interface RoomMemberAvatarRepository extends JpaRepository<RoomMemberAvatar, Long> {
16+
17+
/**
18+
* 특정 방에서 특정 사용자의 아바타 설정 조회
19+
*/
20+
Optional<RoomMemberAvatar> findByRoomIdAndUserId(Long roomId, Long userId);
21+
22+
/**
23+
* 특정 방의 모든 아바타 설정 조회 (일괄 조회용)
24+
*/
25+
@Query("SELECT rma FROM RoomMemberAvatar rma " +
26+
"JOIN FETCH rma.selectedAvatar " +
27+
"WHERE rma.room.id = :roomId AND rma.user.id IN :userIds")
28+
List<RoomMemberAvatar> findByRoomIdAndUserIdIn(@Param("roomId") Long roomId,
29+
@Param("userIds") Set<Long> userIds);
30+
}

0 commit comments

Comments
 (0)