Skip to content

Commit fe1d26a

Browse files
loseminhonamgigun
andauthored
Feat: 스터디룸 공지사항 및 즐겨찾기 기능 구현 (#287) (#300)
* refactor: 스더티룸 권한에 대한 로직 개선 * fix: ci에서 통과 못한 테스트코드 수정 * fix:rest api와 웹소켓 중간 경로 통합 * fix:rest api와 웹소켓 중간 경로 통합 * fix: 에러 확인을 위한 통합테스트 추가, Room.create()메서드 수정 * refactor, feat : 조회 분할 * refactor: redis 로직 최적화 및 중복 검증 로직 제거 * fix: 에러 번호 수정 * feat: 스터디룸 방 비밀번호 변경 및 삭제 기능 구현 * fix:app-dev 제거 * feat: 웹소켓 기반 소극적 하트비트 * feat: 스터디룸 썸네일 기능 추가 및 webrtc 설정 변경에서 주석처리 * fix:소극적 하트비트 사용 주석처리 * Feat: 스터디 룸 내에 고양이 아바타 시스템과 프로필 이미지 url 연동 * fix: 기존 작성되어있던 test 코드 수정 * test: 아바타 테스트 코드 완료 * refactor: 프론트엔드 요청 사항에 따른 스터디룸 조회 마스킹 제거 * feat: 스터디룸 방 초대 코드 시스템 * Infra: main branch 로컬 환경과 운영 환경 동기화 * Infra: docker-compose 파일 수정 - Redis 버전 업그레이드 기존: 6.2 -> 변경: 7.0 * Fix: 백엔드 CD 파일 수정 - 자동화 시, 잘못된 도메인으로 호스트 ID 검증하는 오류 해결 * Infra: EC2 환경변수 수정 - 잘못 표기한 도메인 네임 변경 * Chore: CD 파일 수정 - Github Actions commandLine 인식 문제로 인해 set -Eeuo pipefail 줄바꿈 * Chore: 백엔드 CD 파일 수정 - 인스턴스 ID 체크 삭제 * Infra: 백엔드 CD 파일 수정 - .env 파일 추가시, $DOT_ENV_PROD -> $DOT_ENV 로 변경 * Infra: 도커 컴포즈 수정 - mysql 사용자 정보 변경 * Infra: 운영환경 설정 - application-prod.yml 과 application.yml 동기화 * Fix: SecurityConfig 수정 - H2 DB 허용 X * test,fix: 방 초대에 대한 테스트 코드 작성 및 에러 수정 * fix: 스터디룸 파일 업로드 맵핑 형식으로 변환 * fix: 병합충돌 제어 수정 * fix: 병합충돌 제어 * fix: 스터디 룸 내 프론트엔드 요구 사항 및 오류사항 수정 * feat: 방 즐겨찾기, 방 공지사항 구현 * fix: mockbean 수정 * fix: 테스트에서 빠진 비로그인 사용자 추가 --------- Co-authored-by: namgigun <[email protected]>
1 parent 08ea0b1 commit fe1d26a

22 files changed

+1402
-41
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.back.domain.studyroom.controller;
2+
3+
import com.back.domain.studyroom.dto.CreateAnnouncementRequest;
4+
import com.back.domain.studyroom.dto.RoomAnnouncementResponse;
5+
import com.back.domain.studyroom.dto.UpdateAnnouncementRequest;
6+
import com.back.domain.studyroom.service.RoomAnnouncementService;
7+
import com.back.global.common.dto.RsData;
8+
import com.back.global.security.user.CurrentUser;
9+
import io.swagger.v3.oas.annotations.Operation;
10+
import io.swagger.v3.oas.annotations.Parameter;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
12+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
13+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
14+
import io.swagger.v3.oas.annotations.tags.Tag;
15+
import jakarta.validation.Valid;
16+
import lombok.RequiredArgsConstructor;
17+
import org.springframework.http.HttpStatus;
18+
import org.springframework.http.ResponseEntity;
19+
import org.springframework.web.bind.annotation.*;
20+
21+
import java.util.List;
22+
23+
/**
24+
* 방 공지사항 API 컨트롤러
25+
*/
26+
@RestController
27+
@RequestMapping("/api/rooms/{roomId}/announcements")
28+
@RequiredArgsConstructor
29+
@Tag(name = "Room Announcement API", description = "방 공지사항 관련 API")
30+
@SecurityRequirement(name = "Bearer Authentication")
31+
public class RoomAnnouncementController {
32+
33+
private final RoomAnnouncementService announcementService;
34+
private final CurrentUser currentUser;
35+
36+
@PostMapping
37+
@Operation(
38+
summary = "공지사항 생성",
39+
description = "새로운 공지사항을 생성합니다. 방장만 생성 가능합니다."
40+
)
41+
@ApiResponses({
42+
@ApiResponse(responseCode = "201", description = "공지사항 생성 성공"),
43+
@ApiResponse(responseCode = "403", description = "방장 권한 없음"),
44+
@ApiResponse(responseCode = "404", description = "존재하지 않는 방"),
45+
@ApiResponse(responseCode = "401", description = "인증 실패")
46+
})
47+
public ResponseEntity<RsData<RoomAnnouncementResponse>> createAnnouncement(
48+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
49+
@Valid @RequestBody CreateAnnouncementRequest request) {
50+
51+
Long userId = currentUser.getUserId();
52+
RoomAnnouncementResponse response = announcementService.createAnnouncement(
53+
roomId, request.getTitle(), request.getContent(), userId);
54+
55+
return ResponseEntity
56+
.status(HttpStatus.CREATED)
57+
.body(RsData.success("공지사항 생성 완료", response));
58+
}
59+
60+
@GetMapping
61+
@Operation(
62+
summary = "공지사항 목록 조회",
63+
description = "방의 모든 공지사항을 조회합니다. 핀 고정된 공지가 먼저 표시되고, 그 다음 최신순으로 정렬됩니다."
64+
)
65+
@ApiResponses({
66+
@ApiResponse(responseCode = "200", description = "조회 성공"),
67+
@ApiResponse(responseCode = "404", description = "존재하지 않는 방")
68+
})
69+
public ResponseEntity<RsData<List<RoomAnnouncementResponse>>> getAnnouncements(
70+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId) {
71+
72+
List<RoomAnnouncementResponse> announcements = announcementService.getAnnouncements(roomId);
73+
74+
return ResponseEntity
75+
.status(HttpStatus.OK)
76+
.body(RsData.success("공지사항 목록 조회 완료", announcements));
77+
}
78+
79+
@GetMapping("/{announcementId}")
80+
@Operation(
81+
summary = "공지사항 단건 조회",
82+
description = "특정 공지사항의 상세 정보를 조회합니다."
83+
)
84+
@ApiResponses({
85+
@ApiResponse(responseCode = "200", description = "조회 성공"),
86+
@ApiResponse(responseCode = "404", description = "존재하지 않는 공지사항")
87+
})
88+
public ResponseEntity<RsData<RoomAnnouncementResponse>> getAnnouncement(
89+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
90+
@Parameter(description = "공지사항 ID", required = true) @PathVariable Long announcementId) {
91+
92+
RoomAnnouncementResponse announcement = announcementService.getAnnouncement(announcementId);
93+
94+
return ResponseEntity
95+
.status(HttpStatus.OK)
96+
.body(RsData.success("공지사항 조회 완료", announcement));
97+
}
98+
99+
@PutMapping("/{announcementId}")
100+
@Operation(
101+
summary = "공지사항 수정",
102+
description = "공지사항의 제목과 내용을 수정합니다. 방장만 수정 가능합니다."
103+
)
104+
@ApiResponses({
105+
@ApiResponse(responseCode = "200", description = "수정 성공"),
106+
@ApiResponse(responseCode = "403", description = "방장 권한 없음"),
107+
@ApiResponse(responseCode = "404", description = "존재하지 않는 공지사항"),
108+
@ApiResponse(responseCode = "401", description = "인증 실패")
109+
})
110+
public ResponseEntity<RsData<RoomAnnouncementResponse>> updateAnnouncement(
111+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
112+
@Parameter(description = "공지사항 ID", required = true) @PathVariable Long announcementId,
113+
@Valid @RequestBody UpdateAnnouncementRequest request) {
114+
115+
Long userId = currentUser.getUserId();
116+
RoomAnnouncementResponse response = announcementService.updateAnnouncement(
117+
announcementId, request.getTitle(), request.getContent(), userId);
118+
119+
return ResponseEntity
120+
.status(HttpStatus.OK)
121+
.body(RsData.success("공지사항 수정 완료", response));
122+
}
123+
124+
@DeleteMapping("/{announcementId}")
125+
@Operation(
126+
summary = "공지사항 삭제",
127+
description = "공지사항을 삭제합니다. 방장만 삭제 가능합니다."
128+
)
129+
@ApiResponses({
130+
@ApiResponse(responseCode = "200", description = "삭제 성공"),
131+
@ApiResponse(responseCode = "403", description = "방장 권한 없음"),
132+
@ApiResponse(responseCode = "404", description = "존재하지 않는 공지사항"),
133+
@ApiResponse(responseCode = "401", description = "인증 실패")
134+
})
135+
public ResponseEntity<RsData<Void>> deleteAnnouncement(
136+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
137+
@Parameter(description = "공지사항 ID", required = true) @PathVariable Long announcementId) {
138+
139+
Long userId = currentUser.getUserId();
140+
announcementService.deleteAnnouncement(announcementId, userId);
141+
142+
return ResponseEntity
143+
.status(HttpStatus.OK)
144+
.body(RsData.success("공지사항 삭제 완료", null));
145+
}
146+
147+
@PutMapping("/{announcementId}/pin")
148+
@Operation(
149+
summary = "공지사항 핀 고정/해제",
150+
description = "공지사항을 상단에 고정하거나 고정을 해제합니다. 방장만 실행 가능합니다."
151+
)
152+
@ApiResponses({
153+
@ApiResponse(responseCode = "200", description = "핀 토글 성공"),
154+
@ApiResponse(responseCode = "403", description = "방장 권한 없음"),
155+
@ApiResponse(responseCode = "404", description = "존재하지 않는 공지사항"),
156+
@ApiResponse(responseCode = "401", description = "인증 실패")
157+
})
158+
public ResponseEntity<RsData<RoomAnnouncementResponse>> togglePin(
159+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
160+
@Parameter(description = "공지사항 ID", required = true) @PathVariable Long announcementId) {
161+
162+
Long userId = currentUser.getUserId();
163+
RoomAnnouncementResponse response = announcementService.togglePin(announcementId, userId);
164+
165+
String message = response.getIsPinned() ? "공지사항 핀 고정 완료" : "공지사항 핀 고정 해제 완료";
166+
167+
return ResponseEntity
168+
.status(HttpStatus.OK)
169+
.body(RsData.success(message, response));
170+
}
171+
}

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,9 @@ public ResponseEntity<RsData<Map<String, Object>>> getAllRooms(
141141
Pageable pageable = PageRequest.of(page, size);
142142
Page<Room> rooms = roomService.getAllRooms(pageable);
143143

144-
// 모든 정보 공개
145-
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent());
144+
// 비로그인 사용자도 조회 가능 (userId = null이면 isFavorite = false)
145+
Long userId = currentUser.getUserIdOrNull();
146+
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent(), userId);
146147

147148
Map<String, Object> response = new HashMap<>();
148149
response.put("rooms", roomList);
@@ -173,7 +174,9 @@ public ResponseEntity<RsData<Map<String, Object>>> getPublicRooms(
173174
Pageable pageable = PageRequest.of(page, size);
174175
Page<Room> rooms = roomService.getPublicRooms(includeInactive, pageable);
175176

176-
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent());
177+
// 비로그인 사용자도 조회 가능
178+
Long userId = currentUser.getUserIdOrNull();
179+
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent(), userId);
177180

178181
Map<String, Object> response = new HashMap<>();
179182
response.put("rooms", roomList);
@@ -207,7 +210,7 @@ public ResponseEntity<RsData<Map<String, Object>>> getMyPrivateRooms(
207210
Pageable pageable = PageRequest.of(page, size);
208211
Page<Room> rooms = roomService.getMyPrivateRooms(currentUserId, includeInactive, pageable);
209212

210-
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent());
213+
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent(), currentUserId);
211214

212215
Map<String, Object> response = new HashMap<>();
213216
response.put("rooms", roomList);
@@ -240,7 +243,7 @@ public ResponseEntity<RsData<Map<String, Object>>> getMyHostingRooms(
240243
Pageable pageable = PageRequest.of(page, size);
241244
Page<Room> rooms = roomService.getMyHostingRooms(currentUserId, pageable);
242245

243-
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent());
246+
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent(), currentUserId);
244247

245248
Map<String, Object> response = new HashMap<>();
246249
response.put("rooms", roomList);
@@ -270,7 +273,9 @@ public ResponseEntity<RsData<Map<String, Object>>> getRooms(
270273
Pageable pageable = PageRequest.of(page, size);
271274
Page<Room> rooms = roomService.getJoinableRooms(pageable);
272275

273-
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent());
276+
// 비로그인 사용자도 조회 가능
277+
Long userId = currentUser.getUserIdOrNull();
278+
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent(), userId);
274279

275280
Map<String, Object> response = new HashMap<>();
276281
response.put("rooms", roomList);
@@ -303,7 +308,7 @@ public ResponseEntity<RsData<RoomDetailResponse>> getRoomDetail(
303308
Room room = roomService.getRoomDetail(roomId, currentUserId);
304309
List<RoomMember> members = roomService.getRoomMembers(roomId, currentUserId);
305310

306-
RoomDetailResponse response = roomService.toRoomDetailResponse(room, members);
311+
RoomDetailResponse response = roomService.toRoomDetailResponse(room, members, currentUserId);
307312

308313
return ResponseEntity
309314
.status(HttpStatus.OK)
@@ -554,7 +559,9 @@ public ResponseEntity<RsData<Map<String, Object>>> getPopularRooms(
554559
Pageable pageable = PageRequest.of(page, size);
555560
Page<Room> rooms = roomService.getPopularRooms(pageable);
556561

557-
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent());
562+
// 비로그인 사용자도 조회 가능
563+
Long userId = currentUser.getUserIdOrNull();
564+
List<RoomResponse> roomList = roomService.toRoomResponseList(rooms.getContent(), userId);
558565

559566
Map<String, Object> response = new HashMap<>();
560567
response.put("rooms", roomList);
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.back.domain.studyroom.controller;
2+
3+
import com.back.domain.studyroom.dto.RoomFavoriteResponse;
4+
import com.back.domain.studyroom.service.RoomFavoriteService;
5+
import com.back.global.common.dto.RsData;
6+
import com.back.global.security.user.CurrentUser;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.Parameter;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
11+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
12+
import io.swagger.v3.oas.annotations.tags.Tag;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.http.ResponseEntity;
16+
import org.springframework.web.bind.annotation.*;
17+
18+
import java.util.List;
19+
20+
/**
21+
* 방 즐겨찾기 API 컨트롤러
22+
*/
23+
@RestController
24+
@RequestMapping("/api/rooms")
25+
@RequiredArgsConstructor
26+
@Tag(name = "Room Favorite API", description = "방 즐겨찾기 관련 API")
27+
@SecurityRequirement(name = "Bearer Authentication")
28+
public class RoomFavoriteController {
29+
30+
private final RoomFavoriteService favoriteService;
31+
private final CurrentUser currentUser;
32+
33+
@PostMapping("/{roomId}/favorite")
34+
@Operation(
35+
summary = "즐겨찾기 추가",
36+
description = "특정 방을 즐겨찾기에 추가합니다. 이미 추가된 경우 무시됩니다."
37+
)
38+
@ApiResponses({
39+
@ApiResponse(responseCode = "200", description = "즐겨찾기 추가 성공"),
40+
@ApiResponse(responseCode = "404", description = "존재하지 않는 방"),
41+
@ApiResponse(responseCode = "401", description = "인증 실패")
42+
})
43+
public ResponseEntity<RsData<Void>> addFavorite(
44+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId) {
45+
46+
Long userId = currentUser.getUserId();
47+
favoriteService.addFavorite(roomId, userId);
48+
49+
return ResponseEntity
50+
.status(HttpStatus.OK)
51+
.body(RsData.success("즐겨찾기 추가 완료", null));
52+
}
53+
54+
@DeleteMapping("/{roomId}/favorite")
55+
@Operation(
56+
summary = "즐겨찾기 제거",
57+
description = "특정 방을 즐겨찾기에서 제거합니다."
58+
)
59+
@ApiResponses({
60+
@ApiResponse(responseCode = "200", description = "즐겨찾기 제거 성공"),
61+
@ApiResponse(responseCode = "404", description = "즐겨찾기되지 않은 방"),
62+
@ApiResponse(responseCode = "401", description = "인증 실패")
63+
})
64+
public ResponseEntity<RsData<Void>> removeFavorite(
65+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId) {
66+
67+
Long userId = currentUser.getUserId();
68+
favoriteService.removeFavorite(roomId, userId);
69+
70+
return ResponseEntity
71+
.status(HttpStatus.OK)
72+
.body(RsData.success("즐겨찾기 제거 완료", null));
73+
}
74+
75+
@GetMapping("/favorites")
76+
@Operation(
77+
summary = "내 즐겨찾기 목록 조회",
78+
description = "내가 즐겨찾기한 모든 방 목록을 최신 즐겨찾기 순으로 조회합니다."
79+
)
80+
@ApiResponses({
81+
@ApiResponse(responseCode = "200", description = "조회 성공"),
82+
@ApiResponse(responseCode = "401", description = "인증 실패")
83+
})
84+
public ResponseEntity<RsData<List<RoomFavoriteResponse>>> getMyFavorites() {
85+
86+
Long userId = currentUser.getUserId();
87+
List<RoomFavoriteResponse> favorites = favoriteService.getMyFavorites(userId);
88+
89+
return ResponseEntity
90+
.status(HttpStatus.OK)
91+
.body(RsData.success("즐겨찾기 목록 조회 완료", favorites));
92+
}
93+
94+
@GetMapping("/{roomId}/favorite")
95+
@Operation(
96+
summary = "즐겨찾기 여부 확인",
97+
description = "특정 방이 즐겨찾기되어 있는지 확인합니다."
98+
)
99+
@ApiResponses({
100+
@ApiResponse(responseCode = "200", description = "조회 성공"),
101+
@ApiResponse(responseCode = "401", description = "인증 실패")
102+
})
103+
public ResponseEntity<RsData<Boolean>> isFavorite(
104+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId) {
105+
106+
Long userId = currentUser.getUserId();
107+
boolean isFavorite = favoriteService.isFavorite(roomId, userId);
108+
109+
return ResponseEntity
110+
.status(HttpStatus.OK)
111+
.body(RsData.success("즐겨찾기 여부 조회 완료", isFavorite));
112+
}
113+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.back.domain.studyroom.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Size;
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 CreateAnnouncementRequest {
16+
17+
@NotBlank(message = "공지사항 제목은 필수입니다")
18+
@Size(max = 100, message = "제목은 100자 이내여야 합니다")
19+
private String title;
20+
21+
@NotBlank(message = "공지사항 내용은 필수입니다")
22+
@Size(max = 5000, message = "내용은 5000자 이내여야 합니다")
23+
private String content;
24+
}

0 commit comments

Comments
 (0)