Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e508157
refactor: 스더티룸 권한에 대한 로직 개선
loseminho Oct 2, 2025
8b766f3
Merge remote-tracking branch 'origin/dev' into refactor/146
loseminho Oct 2, 2025
ef46ed0
fix: ci에서 통과 못한 테스트코드 수정
loseminho Oct 3, 2025
23e55ea
fix:rest api와 웹소켓 중간 경로 통합
loseminho Oct 4, 2025
2979e6e
fix:rest api와 웹소켓 중간 경로 통합
loseminho Oct 4, 2025
2de8631
fix: 병합 충돌 제어
loseminho Oct 4, 2025
e576231
Merge remote-tracking branch 'origin/dev' into refactor/146
loseminho Oct 5, 2025
98c9e4c
fix: 에러 확인을 위한 통합테스트 추가, Room.create()메서드 수정
loseminho Oct 5, 2025
8cb4561
refactor, feat
loseminho Oct 5, 2025
be970fd
Merge remote-tracking branch 'origin/dev' into refactor/146
loseminho Oct 5, 2025
57c38ae
refactor: redis 로직 최적화 및 중복 검증 로직 제거
loseminho Oct 7, 2025
ad752e7
fix : 병합 충돌 제어
loseminho Oct 7, 2025
1af4d1e
fix: 에러 번호 수정
loseminho Oct 8, 2025
483a0fc
feat: 스터디룸 방 비밀번호 변경 및 삭제 기능 구현
loseminho Oct 9, 2025
89971f2
Merge remote-tracking branch 'origin/dev' into feat-215
loseminho Oct 9, 2025
77ab976
fix:app-dev 제거
loseminho Oct 9, 2025
7a17167
feat: 웹소켓 기반 소극적 하트비트
loseminho Oct 9, 2025
dd229f2
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 10, 2025
cf32230
feat: 스터디룸 썸네일 기능 추가 및 webrtc 설정 변경에서 주석처리
loseminho Oct 10, 2025
be00c11
Merge branch 'feat/217' of https://github.com/prgrms-web-devcourse-fi…
loseminho Oct 10, 2025
da453f6
fix:소극적 하트비트 사용 주석처리
loseminho Oct 10, 2025
70813b6
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 11, 2025
2de5447
Feat: 스터디 룸 내에 고양이 아바타 시스템과 프로필 이미지 url 연동
loseminho Oct 11, 2025
2bc4e95
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 11, 2025
7d4237c
fix: 기존 작성되어있던 test 코드 수정
loseminho Oct 11, 2025
ad988c8
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 12, 2025
030cbc8
Merge remote-tracking branch 'origin/dev' into feat/217
loseminho Oct 12, 2025
08a976f
test: 아바타 테스트 코드 완료
loseminho Oct 12, 2025
8761b03
refactor: 프론트엔드 요청 사항에 따른 스터디룸 조회 마스킹 제거
loseminho Oct 13, 2025
4a770d6
Merge remote-tracking branch 'origin/dev' into refactor/257
loseminho Oct 13, 2025
a096d93
Merge remote-tracking branch 'origin/dev' into refactor/257
loseminho Oct 13, 2025
df98fc7
feat: 스터디룸 방 초대 코드 시스템
loseminho Oct 13, 2025
f68ff22
test,fix: 방 초대에 대한 테스트 코드 작성 및 에러 수정
loseminho Oct 13, 2025
6ca79eb
Merge branch 'dev' into refactor/257
loseminho Oct 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.back.domain.studyroom.controller;

import com.back.domain.studyroom.dto.InviteCodeResponse;
import com.back.domain.studyroom.entity.RoomInviteCode;
import com.back.domain.studyroom.service.RoomInviteService;
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 lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

/**
* 방 초대 코드 API 컨트롤러
* - VISITOR 포함 모든 사용자가 초대 코드 발급 가능
* - 사용자당 1개의 고유 코드 보유
* - 3시간 유효, 만료 후 재생성 가능
*/
@RestController
@RequestMapping("/api/rooms/{roomId}/invite")
@RequiredArgsConstructor
@Tag(name = "Room Invite API", description = "방 초대 코드 관련 API")
@SecurityRequirement(name = "Bearer Authentication")
public class RoomInviteController {

private final RoomInviteService inviteService;
private final CurrentUser currentUser;

@GetMapping("/me")
@Operation(
summary = "내 초대 코드 조회/생성",
description = "내 초대 코드를 조회합니다. 없으면 자동으로 생성됩니다. " +
"유효기간은 3시간이며, 만료 전까지는 같은 코드가 유지됩니다. " +
"만료된 경우 새로 생성할 수 있습니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회/생성 성공"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 방"),
@ApiResponse(responseCode = "401", description = "인증 실패")
})
public ResponseEntity<RsData<InviteCodeResponse>> getMyInviteCode(
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId) {

Long userId = currentUser.getUserId();

RoomInviteCode code = inviteService.getOrCreateMyInviteCode(roomId, userId);
InviteCodeResponse response = InviteCodeResponse.from(code);

return ResponseEntity
.status(HttpStatus.OK)
.body(RsData.success("초대 코드 조회 완료", response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.back.domain.studyroom.controller;

import com.back.domain.studyroom.dto.JoinRoomResponse;
import com.back.domain.studyroom.entity.Room;
import com.back.domain.studyroom.entity.RoomMember;
import com.back.domain.studyroom.service.RoomInviteService;
import com.back.domain.studyroom.service.RoomService;
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 lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

/**
* 초대 코드 입장 API
* - 비로그인 시 401 반환 (프론트에서 로그인 페이지로 리다이렉트)
*/
@RestController
@RequestMapping("/api/invite")
@RequiredArgsConstructor
@Tag(name = "Room Invite API", description = "초대 코드로 방 입장 API")
public class RoomInvitePublicController {

private final RoomInviteService inviteService;
private final RoomService roomService;
private final CurrentUser currentUser;

@PostMapping("/{inviteCode}")
@SecurityRequirement(name = "Bearer Authentication")
@Operation(
summary = "초대 코드로 방 입장",
description = "초대 코드를 사용하여 방에 입장합니다. " +
"비밀번호가 걸린 방도 초대 코드로 입장 가능합니다. " +
"비로그인 사용자는 401 응답을 받습니다 (프론트에서 로그인 페이지로 이동)."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "입장 성공"),
@ApiResponse(responseCode = "400", description = "만료되었거나 유효하지 않은 코드"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 초대 코드"),
@ApiResponse(responseCode = "401", description = "인증 필요 (비로그인)")
})
public ResponseEntity<RsData<JoinRoomResponse>> joinByInviteCode(
@Parameter(description = "초대 코드", required = true, example = "A3B9C2D1")
@PathVariable String inviteCode) {

// 로그인 체크는 Spring Security에서 자동 처리 (비로그인 시 401)
Long userId = currentUser.getUserId();

// 초대 코드 검증 및 방 조회
Room room = inviteService.getRoomByInviteCode(inviteCode);

// 방 입장 (비밀번호 무시)
RoomMember member = roomService.joinRoom(room.getId(), null, userId);
JoinRoomResponse response = JoinRoomResponse.from(member);

return ResponseEntity
.status(HttpStatus.OK)
.body(RsData.success("초대 코드로 입장 완료", response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.back.domain.studyroom.dto;

import com.back.domain.studyroom.entity.RoomInviteCode;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@Builder
@AllArgsConstructor
@Schema(description = "초대 코드 응답")
public class InviteCodeResponse {

@Schema(description = "초대 코드", example = "A3B9C2D1")
private String inviteCode;

@Schema(description = "초대 링크", example = "https://catfe.com/invite/A3B9C2D1")
private String inviteLink;

@Schema(description = "방 ID", example = "1")
private Long roomId;

@Schema(description = "방 제목", example = "스터디 모임")
private String roomTitle;

@Schema(description = "생성자 닉네임", example = "홍길동")
private String createdByNickname;

@Schema(description = "만료 시간")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime expiresAt;

@Schema(description = "활성 여부", example = "true")
private boolean isActive;

@Schema(description = "유효 여부 (만료되지 않았는지)", example = "true")
private boolean isValid;

public static InviteCodeResponse from(RoomInviteCode code) {
return InviteCodeResponse.builder()
.inviteCode(code.getInviteCode())
.inviteLink("https://catfe.com/invite/" + code.getInviteCode())
.roomId(code.getRoom().getId())
.roomTitle(code.getRoom().getTitle())
.createdByNickname(code.getCreatedBy().getNickname())
.expiresAt(code.getExpiresAt())
.isActive(code.isActive())
.isValid(code.isValid()) // 만료 여부 체크
.build();
}
}
90 changes: 90 additions & 0 deletions src/main/java/com/back/domain/studyroom/entity/RoomInviteCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.back.domain.studyroom.entity;

import com.back.domain.user.entity.User;
import com.back.global.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
* 방 초대 코드 엔티티
* - 모든 참여자가 생성 가능
* - 3시간 유효
* - 사용 횟수 무제한
*/
@Entity
@Table(
name = "room_invite_codes",
indexes = {
@Index(name = "idx_invite_code", columnList = "invite_code"),
@Index(name = "idx_room_id", columnList = "room_id"),
@Index(name = "idx_expires_at", columnList = "expires_at")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RoomInviteCode extends BaseEntity {

@Column(name = "invite_code", nullable = false, unique = true, length = 8)
private String inviteCode;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "room_id", nullable = false)
private Room room;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by", nullable = false)
private User createdBy;

@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;

@Column(name = "is_active", nullable = false)
private boolean isActive = true;

@Builder
private RoomInviteCode(String inviteCode, Room room, User createdBy, LocalDateTime expiresAt) {
this.inviteCode = inviteCode;
this.room = room;
this.createdBy = createdBy;
this.expiresAt = expiresAt;
this.isActive = true;
}

/**
* 초대 코드 생성
* @param inviteCode 8자리 랜덤 코드
* @param room 방
* @param createdBy 생성자
*/
public static RoomInviteCode create(String inviteCode, Room room, User createdBy) {
LocalDateTime expiresAt = LocalDateTime.now().plusHours(3);

return RoomInviteCode.builder()
.inviteCode(inviteCode)
.room(room)
.createdBy(createdBy)
.expiresAt(expiresAt)
.build();
}

/**
* 유효성 검증
*/
public boolean isValid() {
if (!isActive) return false;
if (LocalDateTime.now().isAfter(expiresAt)) return false;
return true;
}

/**
* 비활성화
*/
public void deactivate() {
this.isActive = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.back.domain.studyroom.repository;

import com.back.domain.studyroom.entity.RoomInviteCode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDateTime;
import java.util.Optional;

public interface RoomInviteCodeRepository extends JpaRepository<RoomInviteCode, Long> {

/**
* 초대 코드로 조회
*/
@Query("SELECT ric FROM RoomInviteCode ric " +
"JOIN FETCH ric.room " +
"JOIN FETCH ric.createdBy " +
"WHERE ric.inviteCode = :inviteCode")
Optional<RoomInviteCode> findByInviteCode(@Param("inviteCode") String inviteCode);

/**
* 초대 코드 중복 확인
*/
boolean existsByInviteCode(String inviteCode);

/**
* 사용자가 특정 방에서 생성한 활성 초대 코드 조회
*/
@Query("SELECT ric FROM RoomInviteCode ric " +
"JOIN FETCH ric.room " +
"JOIN FETCH ric.createdBy " +
"WHERE ric.room.id = :roomId " +
"AND ric.createdBy.id = :userId " +
"AND ric.isActive = true")
Optional<RoomInviteCode> findByRoomIdAndCreatedByIdAndIsActiveTrue(
@Param("roomId") Long roomId,
@Param("userId") Long userId);

/**
* 만료된 초대 코드 자동 비활성화 (배치용)
*/
@Modifying
@Query("UPDATE RoomInviteCode ric " +
"SET ric.isActive = false " +
"WHERE ric.expiresAt < :now " +
"AND ric.isActive = true")
int deactivateExpiredCodes(@Param("now") LocalDateTime now);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.back.domain.studyroom.scheduler;

import com.back.domain.studyroom.repository.RoomInviteCodeRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

/**
* 초대 코드 정리 스케줄러
* - 만료된 초대 코드 자동 비활성화
* - 매시간 실행
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class InviteCodeCleanupScheduler {

private final RoomInviteCodeRepository inviteCodeRepository;

/**
* 만료된 초대 코드 정리
* - 매시간 정각에 실행
*/
@Scheduled(cron = "0 0 * * * *")
@Transactional
public void cleanupExpiredInviteCodes() {
LocalDateTime now = LocalDateTime.now();

int deactivatedCount = inviteCodeRepository.deactivateExpiredCodes(now);

if (deactivatedCount > 0) {
log.info("만료된 초대 코드 정리 완료 - 비활성화된 코드 수: {}", deactivatedCount);
}
}
}
Loading