Skip to content

Commit 5c0a69d

Browse files
authored
Merge branch 'dev' into Feat/285
2 parents b1c7c85 + 8f8bc24 commit 5c0a69d

20 files changed

+444
-896
lines changed

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,31 @@ public ResponseEntity<RsData<Void>> removeRoomPassword(
417417
.body(RsData.success("방 비밀번호 제거 완료", null));
418418
}
419419

420+
@PostMapping("/{roomId}/password")
421+
@Operation(
422+
summary = "방 비밀번호 설정",
423+
description = "비밀번호가 없는 방에 비밀번호를 설정합니다. 이미 비밀번호가 있는 경우 비밀번호 변경 API(PUT)를 사용. 방장만 실행 가능합니다."
424+
)
425+
@ApiResponses({
426+
@ApiResponse(responseCode = "200", description = "비밀번호 설정 성공"),
427+
@ApiResponse(responseCode = "400", description = "이미 비밀번호가 설정되어 있음"),
428+
@ApiResponse(responseCode = "403", description = "방장 권한 없음"),
429+
@ApiResponse(responseCode = "404", description = "존재하지 않는 방"),
430+
@ApiResponse(responseCode = "401", description = "인증 실패")
431+
})
432+
public ResponseEntity<RsData<Void>> setRoomPassword(
433+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
434+
@Valid @RequestBody SetRoomPasswordRequest request) {
435+
436+
Long currentUserId = currentUser.getUserId();
437+
438+
roomService.setRoomPassword(roomId, request.getNewPassword(), currentUserId);
439+
440+
return ResponseEntity
441+
.status(HttpStatus.OK)
442+
.body(RsData.success("방 비밀번호 설정 완료", null));
443+
}
444+
420445
@DeleteMapping("/{roomId}")
421446
@Operation(
422447
summary = "방 종료",

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ public class RoomInvitePublicController {
3636
@SecurityRequirement(name = "Bearer Authentication")
3737
@Operation(
3838
summary = "초대 코드로 방 입장",
39-
description = "초대 코드를 사용하여 방에 입장합니다. " +
39+
description = "초대 코드를 사용하여 방 입장 권한을 획득합니다. " +
4040
"비밀번호가 걸린 방도 초대 코드로 입장 가능합니다. " +
41+
"실제 온라인 등록은 WebSocket 연결 시 자동으로 처리됩니다. " +
4142
"비로그인 사용자는 401 응답을 받습니다 (프론트에서 로그인 페이지로 이동)."
4243
)
4344
@ApiResponses({
44-
@ApiResponse(responseCode = "200", description = "입장 성공"),
45+
@ApiResponse(responseCode = "200", description = "입장 권한 획득 성공 (WebSocket 연결 필요)"),
4546
@ApiResponse(responseCode = "400", description = "만료되었거나 유효하지 않은 코드"),
4647
@ApiResponse(responseCode = "404", description = "존재하지 않는 초대 코드"),
4748
@ApiResponse(responseCode = "401", description = "인증 필요 (비로그인)")
@@ -56,12 +57,12 @@ public ResponseEntity<RsData<JoinRoomResponse>> joinByInviteCode(
5657
// 초대 코드 검증 및 방 조회
5758
Room room = inviteService.getRoomByInviteCode(inviteCode);
5859

59-
// 방 입장 (비밀번호 무시)
60-
RoomMember member = roomService.joinRoom(room.getId(), null, userId);
60+
// 방 입장 권한 체크 (비밀번호 무시, Redis 등록 건너뜀)
61+
RoomMember member = roomService.joinRoom(room.getId(), null, userId, false);
6162
JoinRoomResponse response = JoinRoomResponse.from(member);
6263

6364
return ResponseEntity
6465
.status(HttpStatus.OK)
65-
.body(RsData.success("초대 코드로 입장 완료", response));
66+
.body(RsData.success("초대 코드로 입장 권한 획득 완료. WebSocket 연결 후 실제 입장됩니다.", response));
6667
}
6768
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ public class MyRoomResponse {
1414
private Long roomId;
1515
private String title;
1616
private String description;
17-
private Boolean isPrivate; // 비공개 방 여부 (UI에서 🔒 아이콘 표시용)
17+
private Boolean isPrivate;
18+
private String thumbnailUrl;
1819
private int currentParticipants;
1920
private int maxParticipants;
2021
private RoomStatus status;
@@ -27,6 +28,7 @@ public static MyRoomResponse of(Room room, long currentParticipants, RoomRole my
2728
.title(room.getTitle())
2829
.description(room.getDescription() != null ? room.getDescription() : "")
2930
.isPrivate(room.isPrivate()) // 비공개 방 여부
31+
.thumbnailUrl(room.getThumbnailUrl()) // 썸네일 URL
3032
.currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값
3133
.maxParticipants(room.getMaxParticipants())
3234
.status(room.getStatus())

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public class RoomDetailResponse {
1515
private String title;
1616
private String description;
1717
private boolean isPrivate;
18+
private String thumbnailUrl; // 썸네일 이미지 URL
1819
private int maxParticipants;
1920
private int currentParticipants;
2021
private RoomStatus status;
@@ -31,6 +32,7 @@ public static RoomDetailResponse of(Room room, long currentParticipants, List<Ro
3132
.title(room.getTitle())
3233
.description(room.getDescription() != null ? room.getDescription() : "")
3334
.isPrivate(room.isPrivate())
35+
.thumbnailUrl(room.getThumbnailUrl()) // 썸네일 URL
3436
.maxParticipants(room.getMaxParticipants())
3537
.currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값
3638
.status(room.getStatus())
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 io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
@Getter
11+
@NoArgsConstructor
12+
@AllArgsConstructor
13+
@Schema(description = "방 비밀번호 설정 요청 (비밀번호가 없는 방에 비밀번호 추가)")
14+
public class SetRoomPasswordRequest {
15+
16+
@NotBlank(message = "새 비밀번호는 필수입니다.")
17+
@Size(min = 4, max = 20, message = "비밀번호는 4~20자 사이여야 합니다.")
18+
@Schema(description = "설정할 비밀번호", example = "1234", required = true)
19+
private String newPassword;
20+
}

src/main/java/com/back/domain/studyroom/entity/Room.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,12 @@ public void updateWebRTCSettings(boolean allowCamera, boolean allowAudio, boolea
224224
/**
225225
* 방 비밀번호 변경 메서드
226226
방장이 방의 비밀번호를 변경할 때
227-
별도 메서드로 분리한 이유: 비밀번호는 보안상 별도로 관리되어야 하기 때문 (ai의 추천)
227+
별도 메서드로 분리한 이유: 비밀번호는 보안상 별도로 관리되어야 하기 때문
228228
*/
229229
public void updatePassword(String newPassword) {
230230
this.password = newPassword;
231+
232+
// 비밀번호 유무에 따라 isPrivate 자동 설정
233+
this.isPrivate = (newPassword != null && !newPassword.trim().isEmpty());
231234
}
232235
}

src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,23 @@ public Page<Room> findJoinablePublicRooms(Pageable pageable) {
7474
}
7575

7676
/**
77-
* 사용자가 참여 중인 방 조회
77+
* 사용자가 참여 중인 방 조회 (MEMBER 이상만)
7878
* 조회 조건:
79-
* - 특정 사용자가 멤버로 등록된 방 (DB에 저장된 멤버십)
80-
* TODO: Redis에서 온라인 상태 확인하도록 변경
79+
* - 특정 사용자가 MEMBER 이상으로 등록된 방 (VISITOR 제외)
80+
* - DB에 저장된 멤버십만 조회
8181
* @param userId 사용자 ID
82-
* @return 참여 중인 방 목록
82+
* @return 참여 중인 방 목록 (VISITOR 제외)
8383
*/
8484
@Override
8585
public List<Room> findRoomsByUserId(Long userId) {
8686
return queryFactory
8787
.selectFrom(room)
8888
.leftJoin(room.createdBy, user).fetchJoin() // N+1 방지
8989
.join(room.roomMembers, roomMember) // 멤버 조인
90-
.where(roomMember.user.id.eq(userId))
90+
.where(
91+
roomMember.user.id.eq(userId),
92+
roomMember.role.ne(com.back.domain.studyroom.entity.RoomRole.VISITOR) // VISITOR 제외
93+
)
9194
.fetch();
9295
}
9396

src/main/java/com/back/domain/studyroom/service/RoomService.java

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public Room createRoom(String title, String description, boolean isPrivate,
100100
}
101101

102102
/**
103-
* 방 입장 메서드
103+
* 방 입장 메서드 (WebSocket 연결과 함께 사용)
104104
*
105105
* 입장 검증 과정:
106106
* 1. 방 존재 확인 (비관적 락으로 동시성 제어)
@@ -117,17 +117,33 @@ public Room createRoom(String title, String description, boolean isPrivate,
117117
*/
118118
@Transactional
119119
public RoomMember joinRoom(Long roomId, String password, Long userId) {
120+
return joinRoom(roomId, password, userId, true);
121+
}
122+
123+
/**
124+
* 방 입장 메서드 (오버로드 - WebSocket 등록 여부 선택 가능)
125+
*
126+
* @param roomId 방 ID
127+
* @param password 비밀번호 (비공개 방인 경우)
128+
* @param userId 사용자 ID
129+
* @param registerOnline WebSocket 세션 등록 여부 (true: Redis 등록, false: 권한 체크만)
130+
* @return RoomMember (메모리상 또는 DB 저장된 객체)
131+
*/
132+
@Transactional
133+
public RoomMember joinRoom(Long roomId, String password, Long userId, boolean registerOnline) {
120134

121135
// 1. 비관적 락으로 방 조회 - 동시 입장 시 정원 초과 방지
122136
Room room = roomRepository.findByIdWithLock(roomId)
123137
.orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND));
124138

125-
// 2. Redis에서 현재 온라인 사용자 수 조회
126-
long currentOnlineCount = roomParticipantService.getParticipantCount(roomId);
139+
// 2. Redis에서 현재 온라인 사용자 수 조회 (WebSocket 등록하는 경우만)
140+
if (registerOnline) {
141+
long currentOnlineCount = roomParticipantService.getParticipantCount(roomId);
127142

128-
// 3. 정원 확인 (Redis 기반)
129-
if (currentOnlineCount >= room.getMaxParticipants()) {
130-
throw new CustomException(ErrorCode.ROOM_FULL);
143+
// 3. 정원 확인 (Redis 기반)
144+
if (currentOnlineCount >= room.getMaxParticipants()) {
145+
throw new CustomException(ErrorCode.ROOM_FULL);
146+
}
131147
}
132148

133149
// 4. 방 입장 가능 여부 확인 (활성화 + 입장 가능한 상태)
@@ -140,8 +156,8 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) {
140156
throw new CustomException(ErrorCode.ROOM_NOT_JOINABLE);
141157
}
142158

143-
// 5. 비밀번호 확인
144-
if (room.needsPassword() && !room.getPassword().equals(password)) {
159+
// 5. 비밀번호 확인 (초대 코드 입장 시에는 password가 null일 수 있음)
160+
if (room.needsPassword() && password != null && !room.getPassword().equals(password)) {
145161
throw new CustomException(ErrorCode.ROOM_PASSWORD_INCORRECT);
146162
}
147163

@@ -156,23 +172,31 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) {
156172
// 기존 멤버 재입장: DB에 있는 역할 그대로 사용
157173
RoomMember member = existingMember.get();
158174

159-
// Redis에 온라인 등록 (아바타 포함)
160-
roomParticipantService.enterRoom(userId, roomId, avatarId);
161-
162-
log.info("기존 멤버 재입장 - RoomId: {}, UserId: {}, Role: {}, AvatarId: {}",
163-
roomId, userId, member.getRole(), avatarId);
175+
// WebSocket 연결과 함께 입장하는 경우에만 Redis 등록
176+
if (registerOnline) {
177+
roomParticipantService.enterRoom(userId, roomId, avatarId);
178+
log.info("기존 멤버 재입장 (Redis 등록) - RoomId: {}, UserId: {}, Role: {}, AvatarId: {}",
179+
roomId, userId, member.getRole(), avatarId);
180+
} else {
181+
log.info("기존 멤버 권한 확인 (Redis 등록 건너뜀) - RoomId: {}, UserId: {}, Role: {}",
182+
roomId, userId, member.getRole());
183+
}
164184

165185
return member;
166186
}
167187

168188
// 신규 입장자: VISITOR로 입장 (DB 저장 안함!)
169189
RoomMember visitorMember = RoomMember.createVisitor(room, user);
170190

171-
// Redis에만 온라인 등록 (아바타 포함)
172-
roomParticipantService.enterRoom(userId, roomId, avatarId);
173-
174-
log.info("신규 입장 (VISITOR) - RoomId: {}, UserId: {}, DB 저장 안함, AvatarId: {}",
175-
roomId, userId, avatarId);
191+
// WebSocket 연결과 함께 입장하는 경우에만 Redis 등록
192+
if (registerOnline) {
193+
roomParticipantService.enterRoom(userId, roomId, avatarId);
194+
log.info("신규 입장 (VISITOR, Redis 등록) - RoomId: {}, UserId: {}, AvatarId: {}",
195+
roomId, userId, avatarId);
196+
} else {
197+
log.info("신규 입장 권한 확인 (Redis 등록 건너뜀) - RoomId: {}, UserId: {}",
198+
roomId, userId);
199+
}
176200

177201
// 메모리상 객체 반환 (DB에 저장되지 않음)
178202
return visitorMember;
@@ -360,6 +384,35 @@ public void removeRoomPassword(Long roomId, Long userId) {
360384
log.info("방 비밀번호 제거 완료 - RoomId: {}, UserId: {}", roomId, userId);
361385
}
362386

387+
/**
388+
* 방 비밀번호 설정 (비밀번호가 없는 방에 비밀번호 추가)
389+
* - 방장만 설정 가능
390+
* - 기존 비밀번호가 없는 경우에만 사용
391+
* @param roomId 방 ID
392+
* @param newPassword 새 비밀번호
393+
* @param userId 요청자 ID (방장)
394+
*/
395+
@Transactional
396+
public void setRoomPassword(Long roomId, String newPassword, Long userId) {
397+
Room room = roomRepository.findById(roomId)
398+
.orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND));
399+
400+
// 방장 권한 확인
401+
if (!room.isOwner(userId)) {
402+
throw new CustomException(ErrorCode.NOT_ROOM_HOST);
403+
}
404+
405+
// 이미 비밀번호가 있는 경우 에러
406+
if (room.getPassword() != null && !room.getPassword().isEmpty()) {
407+
throw new CustomException(ErrorCode.ROOM_PASSWORD_ALREADY_EXISTS);
408+
}
409+
410+
// 새 비밀번호 설정
411+
room.updatePassword(newPassword);
412+
413+
log.info("방 비밀번호 설정 완료 - RoomId: {}, UserId: {}", roomId, userId);
414+
}
415+
363416
@Transactional
364417
public void terminateRoom(Long roomId, Long userId) {
365418

src/main/java/com/back/global/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public enum ErrorCode {
3939
CHAT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ROOM_016", "채팅 삭제 중 오류가 발생했습니다."),
4040
ROOM_PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "ROOM_017", "현재 비밀번호가 일치하지 않습니다."),
4141
NOT_ROOM_HOST(HttpStatus.FORBIDDEN, "ROOM_018", "방장 권한이 필요합니다."),
42+
ROOM_PASSWORD_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "ROOM_019", "이미 비밀번호가 설정되어 있습니다. 비밀번호 변경 API를 사용하세요."),
4243

4344
// ======================== 초대 코드 관련 ========================
4445
INVALID_INVITE_CODE(HttpStatus.NOT_FOUND, "INVITE_001", "유효하지 않은 초대 코드입니다."),

src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.back.global.websocket.controller;
22

3+
import com.back.domain.studyroom.service.AvatarService;
34
import com.back.global.exception.CustomException;
45
import com.back.global.security.user.CustomUserDetails;
6+
import com.back.global.websocket.service.RoomParticipantService;
57
import com.back.global.websocket.service.WebSocketSessionManager;
68
import com.back.global.websocket.util.WebSocketAuthHelper;
79
import com.back.global.websocket.util.WebSocketErrorHelper;
@@ -25,9 +27,12 @@ public class WebSocketMessageController {
2527

2628
private final WebSocketSessionManager sessionManager;
2729
private final WebSocketErrorHelper errorHelper;
30+
private final RoomParticipantService roomParticipantService;
31+
private final AvatarService avatarService;
2832

2933
// WebSocket 방 입장 확인 메시지
30-
// 클라이언트가 REST API로 입장 후 WebSocket 세션 동기화 대기를 위해 전송
34+
// 클라이언트가 REST API로 입장 후 WebSocket 세션 동기화를 위해 전송
35+
// 초대 코드로 입장한 경우 Redis 등록이 안 되어 있으므로 여기서 처리
3136
@MessageMapping("/rooms/{roomId}/join")
3237
public void handleWebSocketJoinRoom(@DestinationVariable Long roomId,
3338
@Payload Map<String, Object> payload,
@@ -44,8 +49,23 @@ public void handleWebSocketJoinRoom(@DestinationVariable Long roomId,
4449
// 활동 시간 업데이트
4550
sessionManager.updateLastActivity(userId);
4651

47-
// 실제 방 입장 로직은 REST API에서 이미 처리했으므로
48-
// 여기서는 단순히 WebSocket 세션이 준비되었음을 확인하는 용도
52+
// Redis에 이미 등록되어 있는지 확인
53+
Long currentRoomId = roomParticipantService.getCurrentRoomId(userId);
54+
55+
if (currentRoomId == null || !currentRoomId.equals(roomId)) {
56+
// Redis에 등록되지 않은 경우 (초대 코드 입장 등)
57+
// 아바타 로드/생성
58+
Long avatarId = avatarService.loadOrCreateAvatar(roomId, userId);
59+
60+
// Redis에 온라인 등록
61+
roomParticipantService.enterRoom(userId, roomId, avatarId);
62+
63+
log.info("📥 [WebSocket] Redis 등록 완료 - roomId: {}, userId: {}, avatarId: {}",
64+
roomId, userId, avatarId);
65+
} else {
66+
log.info("📥 [WebSocket] 이미 Redis에 등록된 사용자 - roomId: {}, userId: {}",
67+
roomId, userId);
68+
}
4969
}
5070

5171
// Heartbeat 처리

0 commit comments

Comments
 (0)