diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassMemberController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassMemberController.java index 40eb0666..7376846c 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassMemberController.java +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassMemberController.java @@ -3,6 +3,7 @@ import com.swyp.picke.domain.classroom.dto.request.ClassJoinRequest; import com.swyp.picke.domain.classroom.dto.request.ClassMemberNameUpdateRequest; import com.swyp.picke.domain.classroom.dto.response.ClassDetailResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassJoinPreviewResponse; import com.swyp.picke.domain.classroom.dto.response.ClassMemberResponse; import com.swyp.picke.domain.classroom.service.ClassMemberService; import com.swyp.picke.global.common.response.ApiResponse; @@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "클래스 멤버 API", description = "초대코드로 참여, 명단 조회, 내 이름 변경, 나가기, 운영자 내보내기") @@ -29,9 +31,22 @@ public class ClassMemberController { private final ClassMemberService classMemberService; + @Operation(summary = "참여 코드 확인", + description = "참여 처리 없이 코드만 확인합니다(코드 입력 단계). 참여할 수 있으면 클래스 요약을, 아니면 사유별 에러를 돌려줍니다. " + + "형식 오류 400, 없는 코드 404 CLASS_404_CODE, 삭제된 클래스 409 CLASS_409_DELETED, 내 클래스 409 CLASS_409_OWNER, " + + "이미 참여 중 409 CLASS_409_JOINED, 내보내진 클래스 403 CLASS_403_REMOVED, 이용권 종료 409 CLASS_409_TICKET_ENDED, " + + "마감일 경과 409 CLASS_409_CLOSED, 정원 초과 409 CLASS_409_FULL. 이름 중복은 참여 API 에서 판정합니다.") + @GetMapping("/join/preview") + public ApiResponse previewJoin( + @AuthenticationPrincipal Long userId, + @RequestParam String inviteCode + ) { + return ApiResponse.onSuccess(classMemberService.previewJoin(userId, inviteCode)); + } + @Operation(summary = "초대코드로 클래스 참여", description = "코드는 공백·하이픈 제거·대문자 변환 후 6자여야 합니다(아니면 400). " - + "없는 코드 404 CLASS_404_CODE, 이용권 종료 409 CLASS_409_TICKET_ENDED, 마감일 경과 409 CLASS_409_CLOSED, 정원 초과 409 CLASS_409_FULL, " + + "없는 코드 404 CLASS_404_CODE, 삭제된 클래스 409 CLASS_409_DELETED, 이용권 종료 409 CLASS_409_TICKET_ENDED, 마감일 경과 409 CLASS_409_CLOSED, 정원 초과 409 CLASS_409_FULL, " + "이름 중복 409 CLASS_409_NAME, 내 클래스 409 CLASS_409_OWNER, 내보내진 클래스 403 CLASS_403_REMOVED. " + "이미 참여 중이면 거절하지 않고 기존 상세를 그대로 돌려줍니다.") @PostMapping("/join") diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassJoinPreviewResponse.java b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassJoinPreviewResponse.java new file mode 100644 index 00000000..45ee73e2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassJoinPreviewResponse.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.classroom.dto.response; + +import com.swyp.picke.domain.classroom.enums.ClassBattleType; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "참여 코드 확인 결과. 참여할 수 있는 코드일 때만 내려간다") +public record ClassJoinPreviewResponse( + @Schema(description = "정리된 초대코드 6자 (참여 API 에 그대로 보낸다)") String inviteCode, + @Schema(description = "클래스 이름") String name, + @Schema(description = "배틀 제목") String battleTitle, + @Schema(description = "배틀 썸네일") String battleThumbnailUrl, + @Schema(description = "배틀 종류 (TTS / CUSTOM)") ClassBattleType battleType, + @Schema(description = "좌석을 차지한 인원") long memberCount, + @Schema(description = "정원") int capacity, + @Schema(description = "실제 마감 시각 = min(마감일, 이용권 종료일)") LocalDateTime closeAt +) { +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/repository/ClassroomRepository.java b/src/main/java/com/swyp/picke/domain/classroom/repository/ClassroomRepository.java index f4f3a401..dedc0f9f 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/repository/ClassroomRepository.java +++ b/src/main/java/com/swyp/picke/domain/classroom/repository/ClassroomRepository.java @@ -15,6 +15,11 @@ public interface ClassroomRepository extends JpaRepository { boolean existsByInviteCode(String inviteCode); + boolean existsByInviteCodeAndStatus(String inviteCode, ClassStatus status); + + /** 참여 코드 확인용(잠금 없음). 삭제된 클래스도 찾아서 "없는 코드"와 구분한다 */ + Optional findByInviteCode(String inviteCode); + /** 같은 주제 초안으로 만든 클래스가 있는지 (삭제된 클래스 포함, PRD §16 draft_id 고유) */ boolean existsByTopicDraftId(Long topicDraftId); diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/ClassMemberService.java b/src/main/java/com/swyp/picke/domain/classroom/service/ClassMemberService.java index d6b5febb..657aca88 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/ClassMemberService.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/ClassMemberService.java @@ -3,6 +3,7 @@ import com.swyp.picke.domain.classroom.dto.request.ClassJoinRequest; import com.swyp.picke.domain.classroom.dto.request.ClassMemberNameUpdateRequest; import com.swyp.picke.domain.classroom.dto.response.ClassDetailResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassJoinPreviewResponse; import com.swyp.picke.domain.classroom.dto.response.ClassMemberResponse; import java.util.List; @@ -10,6 +11,8 @@ public interface ClassMemberService { ClassDetailResponse join(Long userId, ClassJoinRequest request); + ClassJoinPreviewResponse previewJoin(Long userId, String inviteCode); + List getMembers(Long userId, Long classId); ClassMemberResponse changeMyDisplayName(Long userId, Long classId, ClassMemberNameUpdateRequest request); diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/ClassMemberServiceImpl.java b/src/main/java/com/swyp/picke/domain/classroom/service/ClassMemberServiceImpl.java index 544aad30..167b21ae 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/ClassMemberServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/ClassMemberServiceImpl.java @@ -5,6 +5,7 @@ import com.swyp.picke.domain.classroom.dto.request.ClassJoinRequest; import com.swyp.picke.domain.classroom.dto.request.ClassMemberNameUpdateRequest; import com.swyp.picke.domain.classroom.dto.response.ClassDetailResponse; +import com.swyp.picke.domain.classroom.dto.response.ClassJoinPreviewResponse; import com.swyp.picke.domain.classroom.dto.response.ClassMemberResponse; import com.swyp.picke.domain.classroom.entity.ClassMember; import com.swyp.picke.domain.classroom.entity.Classroom; @@ -36,6 +37,7 @@ *
  • 정원 판정 = COUNT(seatHeld) < capacity. 나가도 활동 이력이 있으면 좌석은 남는다
  • *
  • 같은 계정이 이미 참여 중이면 거절 대신 기존 상세를 돌려준다 (PRD "재시도하면 멤버 수를 늘리지 않고 기존 상세로")
  • *
  • LEFT 는 같은 행을 되살려 재참여, REMOVED 는 영구 차단, 운영자는 나가기·내보내기 대상이 아니다
  • + *
  • 코드 확인(previewJoin)은 참여와 같은 순서로 거절 사유를 판정하되, 이미 참여 중이면 409 로 알려준다 (Figma 코드 입력 단계 팝업)
  • * */ @Slf4j @@ -59,7 +61,7 @@ public ClassDetailResponse join(Long userId, ClassJoinRequest request) { String displayName = normalizeDisplayName(request.displayName()); Classroom classroom = classroomRepository.findByInviteCodeAndStatusForUpdate(inviteCode, ClassStatus.ACTIVE) - .orElseThrow(() -> new CustomException(ErrorCode.CLASS_INVITE_CODE_NOT_FOUND)); + .orElseThrow(() -> codeNotUsable(inviteCode)); if (isOwner(classroom, userId)) { throw new CustomException(ErrorCode.CLASS_OWNER_CANNOT_JOIN); } @@ -68,23 +70,9 @@ public ClassDetailResponse join(Long userId, ClassJoinRequest request) { if (existing.isPresent() && existing.get().isActive()) { return ClassroomConverter.toDetail(classroom, ClassMemberRole.MEMBER, seatCountOf(classroom), now); } - if (existing.isPresent() && existing.get().getStatus() == ClassMemberStatus.REMOVED) { - throw new CustomException(ErrorCode.CLASS_MEMBER_REMOVED); - } - // PRD §15: "이용 종료"(이용권 만료·회수) 와 "입장 마감"(마감일 경과) 을 구분해 거절 - if (classroom.isTicketEnded(now)) { - throw new CustomException(ErrorCode.CLASS_TICKET_ENDED); - } - if (classroom.isClosed(now)) { - throw new CustomException(ErrorCode.CLASS_CLOSED); - } + rejectRemovedOrClosed(classroom, existing, now); rejectDuplicateDisplayName(classroom.getId(), displayName); - - // LEFT 상태로 좌석을 쥐고 있던 멤버의 재참여는 좌석을 새로 쓰지 않는다 - boolean needsSeat = existing.map(m -> !m.isSeatHeld()).orElse(true); - if (needsSeat && seatCountOf(classroom) >= classroom.getCapacity()) { - throw new CustomException(ErrorCode.CLASS_FULL); - } + rejectFull(classroom, existing); if (existing.isPresent()) { existing.get().rejoin(displayName, now); @@ -103,6 +91,36 @@ public ClassDetailResponse join(Long userId, ClassJoinRequest request) { return ClassroomConverter.toDetail(classroom, ClassMemberRole.MEMBER, seatCountOf(classroom), now); } + @Override + public ClassJoinPreviewResponse previewJoin(Long userId, String rawInviteCode) { + LocalDateTime now = LocalDateTime.now(KST); + String inviteCode = normalizeInviteCode(rawInviteCode); + Classroom classroom = classroomRepository.findByInviteCode(inviteCode) + .orElseThrow(() -> new CustomException(ErrorCode.CLASS_INVITE_CODE_NOT_FOUND)); + if (classroom.getStatus() != ClassStatus.ACTIVE) { + throw new CustomException(ErrorCode.CLASS_DELETED); + } + if (isOwner(classroom, userId)) { + throw new CustomException(ErrorCode.CLASS_OWNER_CANNOT_JOIN); + } + Optional existing = classMemberRepository.findByClassroomIdAndUserId(classroom.getId(), userId); + if (existing.isPresent() && existing.get().isActive()) { + throw new CustomException(ErrorCode.CLASS_ALREADY_JOINED); + } + rejectRemovedOrClosed(classroom, existing, now); + rejectFull(classroom, existing); + + return new ClassJoinPreviewResponse( + inviteCode, + classroom.getName(), + classroom.getBattle().getTitle(), + classroom.getBattle().getThumbnailUrl(), + classroom.getBattleType(), + seatCountOf(classroom), + classroom.getCapacity(), + classroom.getEffectiveCloseAt()); + } + @Override public List getMembers(Long userId, Long classId) { Classroom classroom = findActiveClass(classId); @@ -181,6 +199,34 @@ static String normalizeDisplayName(String raw) { return name; } + /** 삭제된 클래스의 코드는 "없는 코드"와 구분한다 (Figma 참여 에러 "삭제된 클래스") */ + private CustomException codeNotUsable(String inviteCode) { + return classroomRepository.existsByInviteCodeAndStatus(inviteCode, ClassStatus.DELETED) + ? new CustomException(ErrorCode.CLASS_DELETED) + : new CustomException(ErrorCode.CLASS_INVITE_CODE_NOT_FOUND); + } + + /** 내보내진 멤버, 이용 종료(이용권 만료·회수), 입장 마감(마감일 경과)을 이 순서로 구분해 거절 (PRD §15) */ + private static void rejectRemovedOrClosed(Classroom classroom, Optional existing, LocalDateTime now) { + if (existing.isPresent() && existing.get().getStatus() == ClassMemberStatus.REMOVED) { + throw new CustomException(ErrorCode.CLASS_MEMBER_REMOVED); + } + if (classroom.isTicketEnded(now)) { + throw new CustomException(ErrorCode.CLASS_TICKET_ENDED); + } + if (classroom.isClosed(now)) { + throw new CustomException(ErrorCode.CLASS_CLOSED); + } + } + + /** LEFT 상태로 좌석을 쥐고 있던 멤버의 재참여는 좌석을 새로 쓰지 않는다 */ + private void rejectFull(Classroom classroom, Optional existing) { + boolean needsSeat = existing.map(m -> !m.isSeatHeld()).orElse(true); + if (needsSeat && seatCountOf(classroom) >= classroom.getCapacity()) { + throw new CustomException(ErrorCode.CLASS_FULL); + } + } + private void rejectDuplicateDisplayName(Long classId, String displayName) { if (classMemberRepository.existsByClassroomIdAndDisplayNameAndStatus(classId, displayName, ClassMemberStatus.ACTIVE)) { throw new CustomException(ErrorCode.CLASS_DISPLAY_NAME_DUPLICATED); diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index 5df2d78b..572f8259 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -164,6 +164,8 @@ public enum ErrorCode { CLASS_INVITE_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "CLASS_404_CODE", "존재하지 않는 초대코드입니다."), CLASS_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "CLASS_404_MEMBER", "존재하지 않는 멤버입니다."), CLASS_FULL(HttpStatus.CONFLICT, "CLASS_409_FULL", "정원이 가득 찬 클래스입니다."), + CLASS_DELETED(HttpStatus.CONFLICT, "CLASS_409_DELETED", "삭제되어 더 이상 이용할 수 없는 클래스입니다."), + CLASS_ALREADY_JOINED(HttpStatus.CONFLICT, "CLASS_409_JOINED", "이미 참여 중인 클래스입니다."), CLASS_DISPLAY_NAME_DUPLICATED(HttpStatus.CONFLICT, "CLASS_409_NAME", "이미 사용 중인 이름입니다. 다른 이름을 입력해 주세요."), CLASS_OWNER_CANNOT_JOIN(HttpStatus.CONFLICT, "CLASS_409_OWNER", "내가 운영하는 클래스에는 참여자로 들어갈 수 없습니다."), CLASS_MEMBER_REMOVED(HttpStatus.FORBIDDEN, "CLASS_403_REMOVED", "내보내진 클래스에는 다시 참여할 수 없습니다."), diff --git a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassMemberApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassMemberApiIntegrationTest.java index 832dea55..7e416d8e 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassMemberApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassMemberApiIntegrationTest.java @@ -309,6 +309,85 @@ void removeMember_andRejoinBlocked() throws Exception { .andExpect(jsonPath("$.error.code").value("CLASS_404_MEMBER")); } + @Test + @DisplayName("참여 코드 확인: 참여 가능하면 클래스 요약(멤버는 만들지 않음), 이미 참여 중·내 클래스·정원 초과는 사유별 409") + void previewJoin_summaryAndRejections() throws Exception { + ClassInfo cls = createClass(true); // 5인권, 운영자 1석 + + preview(tokenOf(newUser("x")), " " + cls.inviteCode.toLowerCase() + " ") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.inviteCode").value(cls.inviteCode)) + .andExpect(jsonPath("$.data.name").value("클래스")) + .andExpect(jsonPath("$.data.battleTitle").value(battle.getTitle())) + .andExpect(jsonPath("$.data.battleType").value("TTS")) + .andExpect(jsonPath("$.data.memberCount").value(1)) + .andExpect(jsonPath("$.data.capacity").value(5)); + assertThat(classMemberRepository.countByClassroomIdAndSeatHeldTrue(cls.classId)).isEqualTo(1); + + preview(tokenOf(newUser("x")), "ABC") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("COMMON_400")); + preview(tokenOf(newUser("x")), "ZZZZZZ") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("CLASS_404_CODE")); + preview(ownerToken, cls.inviteCode) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_OWNER")); + + String joinedToken = tokenOf(newUser("a")); + join(joinedToken, cls.inviteCode, "하나").andExpect(status().isOk()); + preview(joinedToken, cls.inviteCode) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_JOINED")); + + join(tokenOf(newUser("b")), cls.inviteCode, "둘").andExpect(status().isOk()); + join(tokenOf(newUser("c")), cls.inviteCode, "셋").andExpect(status().isOk()); + join(tokenOf(newUser("d")), cls.inviteCode, "넷").andExpect(status().isOk()); + preview(tokenOf(newUser("e")), cls.inviteCode) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_FULL")); + } + + @Test + @DisplayName("참여 코드 확인: 내보내진 클래스 403, 마감 409, 이용권 종료 409, 삭제된 클래스는 확인·참여 모두 409 CLASS_409_DELETED") + void previewJoin_removedClosedDeleted() throws Exception { + ClassInfo cls = createClass(true); + User student = newUser("학생"); + String studentToken = tokenOf(student); + join(studentToken, cls.inviteCode, "학생").andExpect(status().isOk()); + mockMvc.perform(delete("/api/v1/classes/" + cls.classId + "/members/" + memberIdOf(cls.classId, student)) + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isOk()); + preview(studentToken, cls.inviteCode) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error.code").value("CLASS_403_REMOVED")); + + Classroom classroom = classroomRepository.findById(cls.classId).orElseThrow(); + org.springframework.test.util.ReflectionTestUtils.setField(classroom, "deadline", LocalDateTime.now().minusMinutes(1)); + classroomRepository.save(classroom); + preview(tokenOf(newUser("g")), cls.inviteCode) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_CLOSED")); + + var ticket = classTicketRepository.findById(classroom.getTicket().getId()).orElseThrow(); + ticket.revoke(LocalDateTime.now().minusMinutes(1)); + classTicketRepository.save(ticket); + preview(tokenOf(newUser("h")), cls.inviteCode) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_TICKET_ENDED")); + + mockMvc.perform(delete("/api/v1/classes/" + cls.classId) + .header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isOk()); + String outsider = tokenOf(newUser("i")); + preview(outsider, cls.inviteCode) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_DELETED")); + join(outsider, cls.inviteCode, "아홉") + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_DELETED")); + } + @Test @DisplayName("마지막 1석을 두 사람이 동시에 신청하면 한 명만 들어간다 (클래스 행 잠금)") void join_lastSeat_concurrent_onlyOneSucceeds() throws Exception { @@ -377,6 +456,12 @@ private org.springframework.test.web.servlet.ResultActions join(String token, St .content(json(Map.of("inviteCode", code, "displayName", name)))); } + private org.springframework.test.web.servlet.ResultActions preview(String token, String code) throws Exception { + return mockMvc.perform(get("/api/v1/classes/join/preview") + .header("Authorization", "Bearer " + token) + .param("inviteCode", code)); + } + private long memberIdOf(long classId, User user) { return classMemberRepository.findByClassroomIdAndUserId(classId, user.getId()).orElseThrow().getId(); }