Skip to content

Commit 35e9689

Browse files
authored
feat : 스터디 룸 초대코드 생성 (#257) (#266)
* 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: 스터디룸 방 초대 코드 시스템 * test,fix: 방 초대에 대한 테스트 코드 작성 및 에러 수정
1 parent f73dd35 commit 35e9689

File tree

11 files changed

+2030
-0
lines changed

11 files changed

+2030
-0
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.back.domain.studyroom.controller;
2+
3+
import com.back.domain.studyroom.dto.InviteCodeResponse;
4+
import com.back.domain.studyroom.entity.RoomInviteCode;
5+
import com.back.domain.studyroom.service.RoomInviteService;
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 lombok.RequiredArgsConstructor;
15+
import org.springframework.http.HttpStatus;
16+
import org.springframework.http.ResponseEntity;
17+
import org.springframework.web.bind.annotation.*;
18+
19+
/**
20+
* 방 초대 코드 API 컨트롤러
21+
* - VISITOR 포함 모든 사용자가 초대 코드 발급 가능
22+
* - 사용자당 1개의 고유 코드 보유
23+
* - 3시간 유효, 만료 후 재생성 가능
24+
*/
25+
@RestController
26+
@RequestMapping("/api/rooms/{roomId}/invite")
27+
@RequiredArgsConstructor
28+
@Tag(name = "Room Invite API", description = "방 초대 코드 관련 API")
29+
@SecurityRequirement(name = "Bearer Authentication")
30+
public class RoomInviteController {
31+
32+
private final RoomInviteService inviteService;
33+
private final CurrentUser currentUser;
34+
35+
@GetMapping("/me")
36+
@Operation(
37+
summary = "내 초대 코드 조회/생성",
38+
description = "내 초대 코드를 조회합니다. 없으면 자동으로 생성됩니다. " +
39+
"유효기간은 3시간이며, 만료 전까지는 같은 코드가 유지됩니다. " +
40+
"만료된 경우 새로 생성할 수 있습니다."
41+
)
42+
@ApiResponses({
43+
@ApiResponse(responseCode = "200", description = "조회/생성 성공"),
44+
@ApiResponse(responseCode = "404", description = "존재하지 않는 방"),
45+
@ApiResponse(responseCode = "401", description = "인증 실패")
46+
})
47+
public ResponseEntity<RsData<InviteCodeResponse>> getMyInviteCode(
48+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId) {
49+
50+
Long userId = currentUser.getUserId();
51+
52+
RoomInviteCode code = inviteService.getOrCreateMyInviteCode(roomId, userId);
53+
InviteCodeResponse response = InviteCodeResponse.from(code);
54+
55+
return ResponseEntity
56+
.status(HttpStatus.OK)
57+
.body(RsData.success("초대 코드 조회 완료", response));
58+
}
59+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.back.domain.studyroom.controller;
2+
3+
import com.back.domain.studyroom.dto.JoinRoomResponse;
4+
import com.back.domain.studyroom.entity.Room;
5+
import com.back.domain.studyroom.entity.RoomMember;
6+
import com.back.domain.studyroom.service.RoomInviteService;
7+
import com.back.domain.studyroom.service.RoomService;
8+
import com.back.global.common.dto.RsData;
9+
import com.back.global.security.user.CurrentUser;
10+
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.Parameter;
12+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
13+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
14+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
15+
import io.swagger.v3.oas.annotations.tags.Tag;
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+
/**
22+
* 초대 코드 입장 API
23+
* - 비로그인 시 401 반환 (프론트에서 로그인 페이지로 리다이렉트)
24+
*/
25+
@RestController
26+
@RequestMapping("/api/invite")
27+
@RequiredArgsConstructor
28+
@Tag(name = "Room Invite API", description = "초대 코드로 방 입장 API")
29+
public class RoomInvitePublicController {
30+
31+
private final RoomInviteService inviteService;
32+
private final RoomService roomService;
33+
private final CurrentUser currentUser;
34+
35+
@PostMapping("/{inviteCode}")
36+
@SecurityRequirement(name = "Bearer Authentication")
37+
@Operation(
38+
summary = "초대 코드로 방 입장",
39+
description = "초대 코드를 사용하여 방에 입장합니다. " +
40+
"비밀번호가 걸린 방도 초대 코드로 입장 가능합니다. " +
41+
"비로그인 사용자는 401 응답을 받습니다 (프론트에서 로그인 페이지로 이동)."
42+
)
43+
@ApiResponses({
44+
@ApiResponse(responseCode = "200", description = "입장 성공"),
45+
@ApiResponse(responseCode = "400", description = "만료되었거나 유효하지 않은 코드"),
46+
@ApiResponse(responseCode = "404", description = "존재하지 않는 초대 코드"),
47+
@ApiResponse(responseCode = "401", description = "인증 필요 (비로그인)")
48+
})
49+
public ResponseEntity<RsData<JoinRoomResponse>> joinByInviteCode(
50+
@Parameter(description = "초대 코드", required = true, example = "A3B9C2D1")
51+
@PathVariable String inviteCode) {
52+
53+
// 로그인 체크는 Spring Security에서 자동 처리 (비로그인 시 401)
54+
Long userId = currentUser.getUserId();
55+
56+
// 초대 코드 검증 및 방 조회
57+
Room room = inviteService.getRoomByInviteCode(inviteCode);
58+
59+
// 방 입장 (비밀번호 무시)
60+
RoomMember member = roomService.joinRoom(room.getId(), null, userId);
61+
JoinRoomResponse response = JoinRoomResponse.from(member);
62+
63+
return ResponseEntity
64+
.status(HttpStatus.OK)
65+
.body(RsData.success("초대 코드로 입장 완료", response));
66+
}
67+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.back.domain.studyroom.dto;
2+
3+
import com.back.domain.studyroom.entity.RoomInviteCode;
4+
import com.fasterxml.jackson.annotation.JsonFormat;
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Builder;
8+
import lombok.Getter;
9+
10+
import java.time.LocalDateTime;
11+
12+
@Getter
13+
@Builder
14+
@AllArgsConstructor
15+
@Schema(description = "초대 코드 응답")
16+
public class InviteCodeResponse {
17+
18+
@Schema(description = "초대 코드", example = "A3B9C2D1")
19+
private String inviteCode;
20+
21+
@Schema(description = "초대 링크", example = "https://catfe.com/invite/A3B9C2D1")
22+
private String inviteLink;
23+
24+
@Schema(description = "방 ID", example = "1")
25+
private Long roomId;
26+
27+
@Schema(description = "방 제목", example = "스터디 모임")
28+
private String roomTitle;
29+
30+
@Schema(description = "생성자 닉네임", example = "홍길동")
31+
private String createdByNickname;
32+
33+
@Schema(description = "만료 시간")
34+
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
35+
private LocalDateTime expiresAt;
36+
37+
@Schema(description = "활성 여부", example = "true")
38+
private boolean isActive;
39+
40+
@Schema(description = "유효 여부 (만료되지 않았는지)", example = "true")
41+
private boolean isValid;
42+
43+
public static InviteCodeResponse from(RoomInviteCode code) {
44+
return InviteCodeResponse.builder()
45+
.inviteCode(code.getInviteCode())
46+
.inviteLink("https://catfe.com/invite/" + code.getInviteCode())
47+
.roomId(code.getRoom().getId())
48+
.roomTitle(code.getRoom().getTitle())
49+
.createdByNickname(code.getCreatedBy().getNickname())
50+
.expiresAt(code.getExpiresAt())
51+
.isActive(code.isActive())
52+
.isValid(code.isValid()) // 만료 여부 체크
53+
.build();
54+
}
55+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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.AccessLevel;
7+
import lombok.Builder;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
11+
import java.time.LocalDateTime;
12+
13+
/**
14+
* 방 초대 코드 엔티티
15+
* - 모든 참여자가 생성 가능
16+
* - 3시간 유효
17+
* - 사용 횟수 무제한
18+
*/
19+
@Entity
20+
@Table(
21+
name = "room_invite_codes",
22+
indexes = {
23+
@Index(name = "idx_invite_code", columnList = "invite_code"),
24+
@Index(name = "idx_room_id", columnList = "room_id"),
25+
@Index(name = "idx_expires_at", columnList = "expires_at")
26+
}
27+
)
28+
@Getter
29+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
30+
public class RoomInviteCode extends BaseEntity {
31+
32+
@Column(name = "invite_code", nullable = false, unique = true, length = 8)
33+
private String inviteCode;
34+
35+
@ManyToOne(fetch = FetchType.LAZY)
36+
@JoinColumn(name = "room_id", nullable = false)
37+
private Room room;
38+
39+
@ManyToOne(fetch = FetchType.LAZY)
40+
@JoinColumn(name = "created_by", nullable = false)
41+
private User createdBy;
42+
43+
@Column(name = "expires_at", nullable = false)
44+
private LocalDateTime expiresAt;
45+
46+
@Column(name = "is_active", nullable = false)
47+
private boolean isActive = true;
48+
49+
@Builder
50+
private RoomInviteCode(String inviteCode, Room room, User createdBy, LocalDateTime expiresAt) {
51+
this.inviteCode = inviteCode;
52+
this.room = room;
53+
this.createdBy = createdBy;
54+
this.expiresAt = expiresAt;
55+
this.isActive = true;
56+
}
57+
58+
/**
59+
* 초대 코드 생성
60+
* @param inviteCode 8자리 랜덤 코드
61+
* @param room 방
62+
* @param createdBy 생성자
63+
*/
64+
public static RoomInviteCode create(String inviteCode, Room room, User createdBy) {
65+
LocalDateTime expiresAt = LocalDateTime.now().plusHours(3);
66+
67+
return RoomInviteCode.builder()
68+
.inviteCode(inviteCode)
69+
.room(room)
70+
.createdBy(createdBy)
71+
.expiresAt(expiresAt)
72+
.build();
73+
}
74+
75+
/**
76+
* 유효성 검증
77+
*/
78+
public boolean isValid() {
79+
if (!isActive) return false;
80+
if (LocalDateTime.now().isAfter(expiresAt)) return false;
81+
return true;
82+
}
83+
84+
/**
85+
* 비활성화
86+
*/
87+
public void deactivate() {
88+
this.isActive = false;
89+
}
90+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.back.domain.studyroom.repository;
2+
3+
import com.back.domain.studyroom.entity.RoomInviteCode;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Modifying;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
8+
9+
import java.time.LocalDateTime;
10+
import java.util.Optional;
11+
12+
public interface RoomInviteCodeRepository extends JpaRepository<RoomInviteCode, Long> {
13+
14+
/**
15+
* 초대 코드로 조회
16+
*/
17+
@Query("SELECT ric FROM RoomInviteCode ric " +
18+
"JOIN FETCH ric.room " +
19+
"JOIN FETCH ric.createdBy " +
20+
"WHERE ric.inviteCode = :inviteCode")
21+
Optional<RoomInviteCode> findByInviteCode(@Param("inviteCode") String inviteCode);
22+
23+
/**
24+
* 초대 코드 중복 확인
25+
*/
26+
boolean existsByInviteCode(String inviteCode);
27+
28+
/**
29+
* 사용자가 특정 방에서 생성한 활성 초대 코드 조회
30+
*/
31+
@Query("SELECT ric FROM RoomInviteCode ric " +
32+
"JOIN FETCH ric.room " +
33+
"JOIN FETCH ric.createdBy " +
34+
"WHERE ric.room.id = :roomId " +
35+
"AND ric.createdBy.id = :userId " +
36+
"AND ric.isActive = true")
37+
Optional<RoomInviteCode> findByRoomIdAndCreatedByIdAndIsActiveTrue(
38+
@Param("roomId") Long roomId,
39+
@Param("userId") Long userId);
40+
41+
/**
42+
* 만료된 초대 코드 자동 비활성화 (배치용)
43+
*/
44+
@Modifying
45+
@Query("UPDATE RoomInviteCode ric " +
46+
"SET ric.isActive = false " +
47+
"WHERE ric.expiresAt < :now " +
48+
"AND ric.isActive = true")
49+
int deactivateExpiredCodes(@Param("now") LocalDateTime now);
50+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.back.domain.studyroom.scheduler;
2+
3+
import com.back.domain.studyroom.repository.RoomInviteCodeRepository;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.scheduling.annotation.Scheduled;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.transaction.annotation.Transactional;
9+
10+
import java.time.LocalDateTime;
11+
12+
/**
13+
* 초대 코드 정리 스케줄러
14+
* - 만료된 초대 코드 자동 비활성화
15+
* - 매시간 실행
16+
*/
17+
@Component
18+
@RequiredArgsConstructor
19+
@Slf4j
20+
public class InviteCodeCleanupScheduler {
21+
22+
private final RoomInviteCodeRepository inviteCodeRepository;
23+
24+
/**
25+
* 만료된 초대 코드 정리
26+
* - 매시간 정각에 실행
27+
*/
28+
@Scheduled(cron = "0 0 * * * *")
29+
@Transactional
30+
public void cleanupExpiredInviteCodes() {
31+
LocalDateTime now = LocalDateTime.now();
32+
33+
int deactivatedCount = inviteCodeRepository.deactivateExpiredCodes(now);
34+
35+
if (deactivatedCount > 0) {
36+
log.info("만료된 초대 코드 정리 완료 - 비활성화된 코드 수: {}", deactivatedCount);
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)