Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -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;
Expand All @@ -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 = "초대코드로 참여, 명단 조회, 내 이름 변경, 나가기, 운영자 내보내기")
Expand All @@ -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<ClassJoinPreviewResponse> 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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public interface ClassroomRepository extends JpaRepository<Classroom, Long> {

boolean existsByInviteCode(String inviteCode);

boolean existsByInviteCodeAndStatus(String inviteCode, ClassStatus status);

/** 참여 코드 확인용(잠금 없음). 삭제된 클래스도 찾아서 "없는 코드"와 구분한다 */
Optional<Classroom> findByInviteCode(String inviteCode);

/** 같은 주제 초안으로 만든 클래스가 있는지 (삭제된 클래스 포함, PRD §16 draft_id 고유) */
boolean existsByTopicDraftId(Long topicDraftId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
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;

public interface ClassMemberService {

ClassDetailResponse join(Long userId, ClassJoinRequest request);

ClassJoinPreviewResponse previewJoin(Long userId, String inviteCode);

List<ClassMemberResponse> getMembers(Long userId, Long classId);

ClassMemberResponse changeMyDisplayName(Long userId, Long classId, ClassMemberNameUpdateRequest request);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,6 +37,7 @@
* <li>정원 판정 = COUNT(seatHeld) &lt; capacity. 나가도 활동 이력이 있으면 좌석은 남는다</li>
* <li>같은 계정이 이미 참여 중이면 거절 대신 기존 상세를 돌려준다 (PRD "재시도하면 멤버 수를 늘리지 않고 기존 상세로")</li>
* <li>LEFT 는 같은 행을 되살려 재참여, REMOVED 는 영구 차단, 운영자는 나가기·내보내기 대상이 아니다</li>
* <li>코드 확인(previewJoin)은 참여와 같은 순서로 거절 사유를 판정하되, 이미 참여 중이면 409 로 알려준다 (Figma 코드 입력 단계 팝업)</li>
* </ul>
*/
@Slf4j
Expand All @@ -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);
}
Expand All @@ -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);
Expand All @@ -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<ClassMember> 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<ClassMemberResponse> getMembers(Long userId, Long classId) {
Classroom classroom = findActiveClass(classId);
Expand Down Expand Up @@ -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<ClassMember> 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<ClassMember> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "내보내진 클래스에는 다시 참여할 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
Expand Down
Loading