diff --git a/docs/api-specs/class-api.md b/docs/api-specs/class-api.md index b5168630..6305cf2f 100644 --- a/docs/api-specs/class-api.md +++ b/docs/api-specs/class-api.md @@ -191,7 +191,8 @@ "deadline": "2026-12-31T23:59:59", "commentRequired": true, "ownerParticipates": true, - "ownerDisplayName": "김선생" + "ownerDisplayName": "김선생", + "requestKey": "3f2b8c1e-7d4a-4f6b-9a0e-2c5d8e1f4a7b" } ``` @@ -204,11 +205,16 @@ | `commentRequired` | O | 의견 작성 필수 여부. 참여 완료 판정에 쓴다 | | `ownerParticipates` | O | 운영자도 참여자로 들어갈지. true면 운영자가 1석을 차지하고 투표·의견을 쓸 수 있다 | | `ownerDisplayName` | X | 운영자가 참여할 때 클래스 안에서 쓸 이름 (1~20자). 비우면 닉네임을 쓴다 | +| `requestKey` | X (권장) | 중복 생성 방지 키 (1~64자, 영문·숫자·`-`·`_`). 생성 화면을 열 때 UUID를 하나 만들고, 재전송할 때도 같은 값을 보낸다 | - 정원은 이용권 정원을 그대로 쓴다. - 초대코드 6자는 서버가 만든다. - 커스텀 배틀의 썸네일은 환경변수 `CLASS_CUSTOM_BATTLE_THUMBNAIL_URL`의 고정 이미지 1종이다. - 응답: `ClassDetailResponse` (2.3 참고) +- **중복 요청 방지**: 버튼 연타·네트워크 재전송 대비. 같은 운영자의 생성 요청은 이용권 행 잠금으로 하나씩 처리한다. + - `requestKey`를 보내면: 같은 키·같은 내용이면 새로 만들지 않고 그 클래스를 200으로 돌려준다. 같은 키·다른 내용이면 409 `CLASS_409_REQUEST_KEY`다. 키가 다르면 내용이 같아도 새로 만든다. + - `requestKey`가 없으면: 10초 안에 같은 이름·같은 배틀(또는 주제 버전)로 다시 보낸 요청을 중복으로 보고 직전 클래스를 200으로 돌려준다. + - 이름·운영자 표시 이름의 앞뒤 공백은 같은 내용으로 본다. | 상황 | 응답 | |---|---| @@ -219,6 +225,9 @@ | 없는 주제 버전이거나 본인 초안의 버전이 아님 | 404 `CLASS_404_TOPIC_VERSION` | | 같은 초안으로 이미 클래스를 만듦 (초안 1개당 클래스 1개) | 409 `CLASS_409_TOPIC_DRAFT_USED` | | 마감일이 지금 이전이거나 이용권 종료일 이후 | 400 `CLASS_400_DEADLINE` | +| 같은 `requestKey`로 다른 내용을 보냄 | 409 `CLASS_409_REQUEST_KEY` | +| `requestKey`로 만든 클래스가 이미 삭제됨 | 409 `CLASS_409_DELETED` | +| `requestKey` 형식 오류 | 400 `COMMON_400` | | 초대코드 생성 실패 (드묾) | 500 `CLASS_500_CODE` | ### 2.2 내 클래스 목록 @@ -1236,6 +1245,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 | `CLASS_409_TICKET_ENDED` | 409 | 이용권 이용이 종료된 클래스입니다. | | `CLASS_409_FULL` | 409 | 정원이 가득 찬 클래스입니다. | | `CLASS_409_DELETED` | 409 | 삭제되어 더 이상 이용할 수 없는 클래스입니다. | +| `CLASS_409_REQUEST_KEY` | 409 | 같은 요청 키로 다른 내용의 클래스를 만들 수 없습니다. 새 요청 키로 다시 시도해 주세요. | | `CLASS_409_JOINED` | 409 | 이미 참여 중인 클래스입니다. | | `CLASS_409_NAME` | 409 | 이미 사용 중인 이름입니다. 다른 이름을 입력해 주세요. | | `CLASS_409_OWNER` | 409 | 내가 운영하는 클래스에는 참여자로 들어갈 수 없습니다. | diff --git a/docs/erd/class.puml b/docs/erd/class.puml index 872478c7..22656a88 100644 --- a/docs/erd/class.puml +++ b/docs/erd/class.puml @@ -140,6 +140,8 @@ entity "classes" as classes { status : VARCHAR(20) deleted_at : TIMESTAMP <> purged_at : TIMESTAMP <> + create_request_key : VARCHAR(64) <> + create_request_hash : VARCHAR(64) <> created_at : TIMESTAMP updated_at : TIMESTAMP } diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassCreateRequest.java b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassCreateRequest.java index 00e72ca1..86e29db0 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassCreateRequest.java +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/request/ClassCreateRequest.java @@ -4,6 +4,7 @@ import com.swyp.picke.domain.classroom.entity.Classroom; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; @@ -35,6 +36,13 @@ public record ClassCreateRequest( @Schema(description = "운영자가 참여할 때 클래스 안에서 쓸 이름 (1~20자). 비우면 닉네임 사용", example = "김선생", nullable = true) @Size(max = ClassMember.DISPLAY_NAME_MAX_LENGTH) - String ownerDisplayName + String ownerDisplayName, + + @Schema(description = "중복 생성 방지용 요청 키 (1~64자, 영문·숫자·-·_). 생성 화면을 열 때 UUID 하나를 만들어 재전송에도 같은 값을 보낸다. " + + "같은 키로 같은 내용을 다시 보내면 기존 클래스를 돌려주고, 다른 내용이면 409", + example = "3f2b8c1e-7d4a-4f6b-9a0e-2c5d8e1f4a7b", nullable = true) + @Size(max = Classroom.CREATE_REQUEST_KEY_MAX_LENGTH) + @Pattern(regexp = "^[A-Za-z0-9_-]+$") + String requestKey ) { } diff --git a/src/main/java/com/swyp/picke/domain/classroom/entity/Classroom.java b/src/main/java/com/swyp/picke/domain/classroom/entity/Classroom.java index 3d1b35cc..3b5da25c 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/entity/Classroom.java +++ b/src/main/java/com/swyp/picke/domain/classroom/entity/Classroom.java @@ -37,13 +37,15 @@ name = "classes", uniqueConstraints = { @UniqueConstraint(name = "uk_classes_invite_code", columnNames = "invite_code"), - @UniqueConstraint(name = "uk_classes_topic_draft", columnNames = "topic_draft_id") + @UniqueConstraint(name = "uk_classes_topic_draft", columnNames = "topic_draft_id"), + @UniqueConstraint(name = "uk_classes_owner_request_key", columnNames = {"owner_id", "create_request_key"}) } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Classroom extends BaseEntity { public static final int NAME_MAX_LENGTH = 20; + public static final int CREATE_REQUEST_KEY_MAX_LENGTH = 64; public static final int INVITE_CODE_LENGTH = 6; @Column(nullable = false, length = NAME_MAX_LENGTH) @@ -100,10 +102,18 @@ public class Classroom extends BaseEntity { @Column(name = "purged_at") private LocalDateTime purgedAt; + /** 생성 요청 키(클라이언트 발급). 같은 운영자 안에서 유일하며, 재전송 시 기존 클래스를 찾는 데 쓴다. null 이면 키 없이 만든 클래스 */ + @Column(name = "create_request_key", length = CREATE_REQUEST_KEY_MAX_LENGTH) + private String createRequestKey; + + /** 생성 요청 본문의 SHA-256. 같은 키로 다른 내용을 보냈는지 가려낸다 */ + @Column(name = "create_request_hash", length = 64) + private String createRequestHash; + @Builder private Classroom(String name, String inviteCode, User owner, ClassTicket ticket, Battle battle, ClassBattleType battleType, ClassTopicVersion topicVersion, int capacity, LocalDateTime deadline, - boolean commentRequired, boolean ownerParticipates) { + boolean commentRequired, boolean ownerParticipates, String createRequestKey, String createRequestHash) { this.name = name; this.inviteCode = inviteCode; this.owner = owner; @@ -116,6 +126,8 @@ private Classroom(String name, String inviteCode, User owner, ClassTicket ticket this.deadline = deadline; this.commentRequired = commentRequired; this.ownerParticipates = ownerParticipates; + this.createRequestKey = createRequestKey; + this.createRequestHash = createRequestHash; this.status = ClassStatus.ACTIVE; } 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 6f8d8259..23224117 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 @@ -26,6 +26,13 @@ public interface ClassroomRepository extends JpaRepository { Optional findByIdAndStatus(Long id, ClassStatus status); + /** 중복 생성 판정용: 같은 운영자가 같은 요청 키로 만든 클래스 */ + Optional findByOwnerIdAndCreateRequestKey(Long ownerId, String createRequestKey); + + /** 중복 생성 판정용(요청 키가 없을 때): 같은 운영자가 같은 이름으로 최근에 만든 클래스 */ + List findByOwnerIdAndNameAndStatusAndCreatedAtAfterOrderByIdDesc( + Long ownerId, String name, ClassStatus status, LocalDateTime createdAfter); + /** * 참여용: 정원 검사와 멤버 생성이 한 트랜잭션에서 직렬화되도록 클래스 행을 잠근다(SELECT ... FOR UPDATE). * 마지막 1석을 두 사람이 동시에 신청하면 한 명만 들어간다 (PRD F06). diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/ClassroomServiceImpl.java b/src/main/java/com/swyp/picke/domain/classroom/service/ClassroomServiceImpl.java index b3c47edf..3e2501f6 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/service/ClassroomServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/classroom/service/ClassroomServiceImpl.java @@ -28,9 +28,13 @@ import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.HashMap; +import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Objects; @@ -57,6 +61,8 @@ public class ClassroomServiceImpl implements ClassroomService { private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final int INVITE_CODE_MAX_ATTEMPTS = 10; + /** 같은 생성 요청으로 보는 시간 범위. 연속 탭·네트워크 재전송을 잡을 만큼만 짧게 둔다. */ + private static final long DUPLICATE_WINDOW_SECONDS = 10; private final ClassroomRepository classroomRepository; private final ClassMemberRepository classMemberRepository; @@ -77,6 +83,17 @@ public ClassDetailResponse create(Long userId, ClassCreateRequest request) { if ((request.battleId() == null) == (request.topicVersionId() == null)) { throw new CustomException(ErrorCode.CLASS_BATTLE_SOURCE_INVALID); } + String requestKey = request.requestKey() == null || request.requestKey().isBlank() ? null : request.requestKey(); + String requestHash = requestKey == null ? null : requestHashOf(request); + // 같은 이용권의 생성 요청은 행 잠금으로 한 번에 하나씩 처리한 뒤, 이미 만든 같은 요청의 클래스가 있으면 그것을 돌려준다 + ticket = classTicketRepository.findByIdForUpdate(ticket.getId()).orElse(ticket); + Classroom duplicate = requestKey != null + ? findByRequestKey(userId, requestKey, requestHash) + : findRecentDuplicate(userId, request); + if (duplicate != null) { + log.info("클래스 중복 생성 요청 - 기존 클래스 반환 classId: {}, ownerId: {}", duplicate.getId(), userId); + return ClassroomConverter.toDetail(duplicate, ClassMemberRole.OWNER, seatCountOf(duplicate), now); + } ClassTopicVersion topicVersion = request.topicVersionId() == null ? null : findOwnTopicVersion(userId, request.topicVersionId()); Battle tts = topicVersion == null ? findClassBattle(request.battleId()) : null; validateDeadlineRange(request.deadline(), ticket, now); @@ -94,6 +111,8 @@ public ClassDetailResponse create(Long userId, ClassCreateRequest request) { .deadline(request.deadline()) .commentRequired(request.commentRequired()) .ownerParticipates(request.ownerParticipates()) + .createRequestKey(requestKey) + .createRequestHash(requestHash) .build()); long memberCount = 0; @@ -213,6 +232,66 @@ private Map seatCountsOf(List classrooms) { // ===== 생성 검증 ===== + /** + * 요청 키로 이미 만든 클래스를 찾는다. 키가 같은데 내용이 다르면 클라이언트가 키를 재사용한 것이라 409. + * 키는 운영자 단위로 유일해서 다른 운영자의 키와는 겹쳐도 상관없다. + */ + private Classroom findByRequestKey(Long userId, String requestKey, String requestHash) { + Classroom existing = classroomRepository.findByOwnerIdAndCreateRequestKey(userId, requestKey).orElse(null); + if (existing == null) { + return null; + } + if (!requestHash.equals(existing.getCreateRequestHash())) { + throw new CustomException(ErrorCode.CLASS_REQUEST_KEY_CONFLICT); + } + if (existing.getStatus() != ClassStatus.ACTIVE) { + throw new CustomException(ErrorCode.CLASS_DELETED); + } + return existing; + } + + /** 같은 요청인지 비교할 본문 해시. 서버가 정규화하는 값(이름·운영자 표시 이름 앞뒤 공백)은 정규화한 뒤 비교한다 */ + static String requestHashOf(ClassCreateRequest request) { + String canonical = String.join("\n", + request.name().strip(), + String.valueOf(request.battleId()), + String.valueOf(request.topicVersionId()), + String.valueOf(request.deadline()), + String.valueOf(request.commentRequired()), + String.valueOf(request.ownerParticipates()), + request.ownerDisplayName() == null ? "" : request.ownerDisplayName().strip()); + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + /** + * 요청 키 없이 연속 탭·재전송으로 들어온 같은 생성 요청인지 판정한다. + * 같은 운영자·같은 이름·같은 배틀(또는 주제 버전)으로 {@link #DUPLICATE_WINDOW_SECONDS}초 안에 만든 클래스가 있으면 그 클래스. + * 이용권 기간 안에서 클래스 생성 수는 무제한이라, 이름이나 배틀이 다르면 새로 만든다. + * createdAt 은 JPA Auditing 이 서버 기본 시간대로 채우므로, 비교 기준도 KST 가 아닌 서버 기본 시간대로 잡는다. + */ + private Classroom findRecentDuplicate(Long userId, ClassCreateRequest request) { + String name = request.name().strip(); + LocalDateTime now = LocalDateTime.now(); + return classroomRepository.findByOwnerIdAndNameAndStatusAndCreatedAtAfterOrderByIdDesc( + userId, name, ClassStatus.ACTIVE, now.minusSeconds(DUPLICATE_WINDOW_SECONDS)) + .stream() + .filter(c -> isSameBattleSource(c, request)) + .findFirst() + .orElse(null); + } + + private static boolean isSameBattleSource(Classroom classroom, ClassCreateRequest request) { + if (request.topicVersionId() != null) { + return classroom.getTopicVersion() != null && request.topicVersionId().equals(classroom.getTopicVersion().getId()); + } + return classroom.getTopicVersion() == null && request.battleId().equals(classroom.getBattle().getId()); + } + /** * 사용 중(ACTIVE) 이용권이 없거나 기간이 끝났으면 생성 불가. * 기간이 끝난 ACTIVE 를 EXPIRED 로 내리는 건 이용권 조회/등록(ClassTicketService) 몫이다. diff --git a/src/main/java/com/swyp/picke/domain/ticket/repository/ClassTicketRepository.java b/src/main/java/com/swyp/picke/domain/ticket/repository/ClassTicketRepository.java index ccb8cb79..749f02b6 100644 --- a/src/main/java/com/swyp/picke/domain/ticket/repository/ClassTicketRepository.java +++ b/src/main/java/com/swyp/picke/domain/ticket/repository/ClassTicketRepository.java @@ -36,6 +36,11 @@ public interface ClassTicketRepository extends JpaRepository Optional findByRegisteredUserIdAndStatus(Long userId, TicketStatus status); + /** 클래스 생성용: 같은 이용권으로 동시에 들어온 생성 요청을 직렬화한다 (연속 탭 중복 생성 방지) */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select t from ClassTicket t where t.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + /** 내 이용권 조회용: ACTIVE 가 있으면 그것, 없으면 가장 최근에 만료된 것 */ Optional findTopByRegisteredUserIdAndStatusInOrderByActivatedAtDesc( Long userId, Collection statuses); 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 a465b36d..addc1627 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 @@ -158,6 +158,7 @@ public enum ErrorCode { CLASS_DEADLINE_NOT_EXTENDED(HttpStatus.BAD_REQUEST, "CLASS_400_DEADLINE_EXT", "마감일은 기존 마감일보다 뒤로 연장만 할 수 있습니다."), CLASS_CLOSED(HttpStatus.CONFLICT, "CLASS_409_CLOSED", "마감된 클래스입니다."), CLASS_TICKET_ENDED(HttpStatus.CONFLICT, "CLASS_409_TICKET_ENDED", "이용권 이용이 종료된 클래스입니다."), + CLASS_REQUEST_KEY_CONFLICT(HttpStatus.CONFLICT, "CLASS_409_REQUEST_KEY", "같은 요청 키로 다른 내용의 클래스를 만들 수 없습니다. 새 요청 키로 다시 시도해 주세요."), CLASS_INVITE_CODE_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CLASS_500_CODE", "초대코드 생성에 실패했습니다. 다시 시도해 주세요."), // Classroom member (클래스 참여·멤버) diff --git a/src/main/resources/db/migration/V20261028_02__classes_create_request_key.sql b/src/main/resources/db/migration/V20261028_02__classes_create_request_key.sql new file mode 100644 index 00000000..9b954bff --- /dev/null +++ b/src/main/resources/db/migration/V20261028_02__classes_create_request_key.sql @@ -0,0 +1,13 @@ +-- #576 클래스 생성 중복 요청 방지: 클라이언트가 보낸 요청 키와 본문 해시를 저장한다. +-- 같은 운영자 안에서 키가 유일하다. 키 없이 만든 클래스(NULL)는 UNIQUE 에서 서로 다른 값으로 취급된다. +-- 멱등하게 작성되어 재실행해도 안전하다. + +ALTER TABLE classes ADD COLUMN IF NOT EXISTS create_request_key VARCHAR(64); +ALTER TABLE classes ADD COLUMN IF NOT EXISTS create_request_hash VARCHAR(64); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uk_classes_owner_request_key') THEN + ALTER TABLE classes ADD CONSTRAINT uk_classes_owner_request_key UNIQUE (owner_id, create_request_key); + END IF; +END $$; diff --git a/src/main/resources/db/migration/V20261028_rollback__classes_create_request_key.sql b/src/main/resources/db/migration/V20261028_rollback__classes_create_request_key.sql new file mode 100644 index 00000000..95c53ba0 --- /dev/null +++ b/src/main/resources/db/migration/V20261028_rollback__classes_create_request_key.sql @@ -0,0 +1,5 @@ +-- Rollback for V20261028_02__classes_create_request_key.sql +-- 애플리케이션 코드를 #576 이전으로 되돌린 뒤 실행할 것. +ALTER TABLE classes DROP CONSTRAINT IF EXISTS uk_classes_owner_request_key; +ALTER TABLE classes DROP COLUMN IF EXISTS create_request_hash; +ALTER TABLE classes DROP COLUMN IF EXISTS create_request_key; diff --git a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassroomApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassroomApiIntegrationTest.java index 3200305b..f24a4276 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassroomApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassroomApiIntegrationTest.java @@ -33,6 +33,13 @@ import java.util.UUID; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -149,6 +156,122 @@ void create_thenList_thenDetail() throws Exception { assertThat(classMemberRepository.countByClassroomIdAndSeatHeldTrue(classId)).isEqualTo(1); } + @Test + @DisplayName("같은 생성 요청을 연속으로 보내면 클래스는 하나만 생기고 같은 클래스를 돌려준다") + void create_duplicateRequest_returnsSameClass() throws Exception { + String body = objectMapper.writeValueAsString(createBody("3학년 2반", battle.getId(), null, true)); + long first = dataOf(mockMvc.perform(post("/api/v1/classes") + .header("Authorization", "Bearer " + ownerToken) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andReturn()).get("classId").asLong(); + long second = dataOf(mockMvc.perform(post("/api/v1/classes") + .header("Authorization", "Bearer " + ownerToken) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberCount").value(1)) + .andReturn()).get("classId").asLong(); + + assertThat(second).isEqualTo(first); + assertThat(classroomRepository.count()).isEqualTo(1); + assertThat(classMemberRepository.count()).isEqualTo(1); + + // 이름이 다르면 별개 요청이라 새로 만든다 + mockMvc.perform(post("/api/v1/classes") + .header("Authorization", "Bearer " + ownerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createBody("3학년 3반", battle.getId(), null, true)))) + .andExpect(status().isOk()); + assertThat(classroomRepository.count()).isEqualTo(2); + } + + @Test + @DisplayName("요청 키: 같은 키·같은 내용은 기존 클래스, 같은 키·다른 내용은 409, 다른 키는 새 클래스") + void create_withRequestKey() throws Exception { + Map body = createBody("3학년 2반", battle.getId(), null, true); + body.put("requestKey", "key-1"); + long first = dataOf(createClass(body).andExpect(status().isOk()).andReturn()).get("classId").asLong(); + + // 재전송: 앞뒤 공백은 서버가 정규화하므로 같은 내용이다 + body.put("name", " 3학년 2반 "); + long retried = dataOf(createClass(body).andExpect(status().isOk()).andReturn()).get("classId").asLong(); + assertThat(retried).isEqualTo(first); + assertThat(classroomRepository.count()).isEqualTo(1); + + body.put("name", "3학년 3반"); + createClass(body) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_REQUEST_KEY")); + + // 키가 다르면 같은 내용이어도 운영자가 의도한 새 클래스다 (10초 판정은 키가 없을 때만) + body.put("name", "3학년 2반"); + body.put("requestKey", "key-2"); + long other = dataOf(createClass(body).andExpect(status().isOk()).andReturn()).get("classId").asLong(); + assertThat(other).isNotEqualTo(first); + assertThat(classroomRepository.count()).isEqualTo(2); + + body.put("requestKey", "한글키"); + createClass(body).andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("같은 요청 키로 동시에 들어와도 클래스는 하나만 생긴다") + void create_concurrentSameRequestKey_createOnlyOne() throws Exception { + Map body = createBody("3학년 2반", battle.getId(), null, true); + body.put("requestKey", "concurrent-key"); + String json = objectMapper.writeValueAsString(body); + int threads = 4; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> results = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + results.add(pool.submit(() -> { + start.await(); + return dataOf(mockMvc.perform(post("/api/v1/classes") + .header("Authorization", "Bearer " + ownerToken) + .contentType(MediaType.APPLICATION_JSON).content(json)) + .andExpect(status().isOk()).andReturn()).get("classId").asLong(); + })); + } + start.countDown(); + Set classIds = new HashSet<>(); + for (Future result : results) { + classIds.add(result.get(30, TimeUnit.SECONDS)); + } + pool.shutdown(); + + assertThat(classIds).hasSize(1); + assertThat(classroomRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("같은 생성 요청이 동시에 들어와도 클래스는 하나만 생긴다") + void create_concurrentDuplicateRequests_createOnlyOne() throws Exception { + String body = objectMapper.writeValueAsString(createBody("3학년 2반", battle.getId(), null, true)); + int threads = 4; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + List> results = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + results.add(pool.submit(() -> { + start.await(); + return dataOf(mockMvc.perform(post("/api/v1/classes") + .header("Authorization", "Bearer " + ownerToken) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andReturn()).get("classId").asLong(); + })); + } + start.countDown(); + Set classIds = new HashSet<>(); + for (Future result : results) { + classIds.add(result.get(30, TimeUnit.SECONDS)); + } + pool.shutdown(); + + assertThat(classIds).hasSize(1); + assertThat(classroomRepository.count()).isEqualTo(1); + assertThat(classMemberRepository.count()).isEqualTo(1); + } + @Test @DisplayName("운영자가 참여하지 않으면 멤버 행 없이 만들어지고 목록에는 운영자로 뜬다") void create_withoutOwnerParticipating() throws Exception { @@ -319,6 +442,13 @@ private static Map createBody(String name, Long battleId, LocalD return body; } + private org.springframework.test.web.servlet.ResultActions createClass(Map body) throws Exception { + return mockMvc.perform(post("/api/v1/classes") + .header("Authorization", "Bearer " + ownerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))); + } + private JsonNode dataOf(MvcResult result) throws Exception { return objectMapper.readTree(result.getResponse().getContentAsString()).get("data"); } diff --git a/src/test/java/com/swyp/picke/domain/classroom/service/ClassroomServiceImplTest.java b/src/test/java/com/swyp/picke/domain/classroom/service/ClassroomServiceImplTest.java index 2d7374e0..fc7c0bb3 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/service/ClassroomServiceImplTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/service/ClassroomServiceImplTest.java @@ -30,6 +30,7 @@ import com.swyp.picke.global.common.exception.ErrorCode; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -45,6 +46,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -93,7 +95,7 @@ void create_success_withOwnerParticipating() { when(classroomRepository.save(any(Classroom.class))).thenAnswer(inv -> withId(inv.getArgument(0), CLASS_ID)); ClassDetailResponse response = service.create(OWNER_ID, - new ClassCreateRequest(" 3학년 2반 ", BATTLE_ID, null, null, true, true, null)); + new ClassCreateRequest(" 3학년 2반 ", BATTLE_ID, null, null, true, true, null, null)); ArgumentCaptor captor = ArgumentCaptor.forClass(Classroom.class); verify(classroomRepository).save(captor.capture()); @@ -118,6 +120,29 @@ void create_success_withOwnerParticipating() { assertThat(response.closed()).isFalse(); } + @Test + @DisplayName("직전에 같은 이름·배틀로 만든 클래스가 있으면 새로 만들지 않고 그 클래스를 돌려준다") + void create_duplicateRequest_returnsExisting() { + when(userRepository.findById(OWNER_ID)).thenReturn(Optional.of(owner)); + when(classTicketRepository.findByRegisteredUserIdAndStatus(OWNER_ID, TicketStatus.ACTIVE)) + .thenReturn(Optional.of(ticket)); + Classroom existing = classroom(null); + ReflectionTestUtils.setField(existing, "name", "3학년 2반"); + when(classroomRepository.findByOwnerIdAndNameAndStatusAndCreatedAtAfterOrderByIdDesc( + eq(OWNER_ID), eq("3학년 2반"), eq(ClassStatus.ACTIVE), any(LocalDateTime.class))) + .thenReturn(List.of(existing)); + when(classMemberRepository.countByClassroomIdAndSeatHeldTrue(CLASS_ID)).thenReturn(1L); + + ClassDetailResponse response = service.create(OWNER_ID, + new ClassCreateRequest(" 3학년 2반 ", BATTLE_ID, null, null, true, true, null, null)); + + assertThat(response.classId()).isEqualTo(CLASS_ID); + assertThat(response.memberCount()).isEqualTo(1); + verify(classTicketRepository).findByIdForUpdate(ticket.getId()); + verify(classroomRepository, never()).save(any(Classroom.class)); + verify(classMemberRepository, never()).save(any(ClassMember.class)); + } + @Test @DisplayName("운영자가 참여하지 않으면 멤버 행을 만들지 않는다") void create_withoutOwnerParticipating_noMemberRow() { @@ -126,7 +151,7 @@ void create_withoutOwnerParticipating_noMemberRow() { when(classroomRepository.save(any(Classroom.class))).thenAnswer(inv -> withId(inv.getArgument(0), CLASS_ID)); ClassDetailResponse response = service.create(OWNER_ID, - new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null)); + new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null, null)); verify(classMemberRepository, never()).save(any()); assertThat(response.memberCount()).isZero(); @@ -140,7 +165,7 @@ void create_usesRequestedOwnerDisplayName() { when(classroomRepository.existsByInviteCode(anyString())).thenReturn(false); when(classroomRepository.save(any(Classroom.class))).thenAnswer(inv -> withId(inv.getArgument(0), CLASS_ID)); - service.create(OWNER_ID, new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, true, " 김선생 ")); + service.create(OWNER_ID, new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, true, " 김선생 ", null)); ArgumentCaptor captor = ArgumentCaptor.forClass(ClassMember.class); verify(classMemberRepository).save(captor.capture()); @@ -154,7 +179,7 @@ void create_retriesInviteCodeOnCollision() { when(classroomRepository.existsByInviteCode(anyString())).thenReturn(true, true, false); when(classroomRepository.save(any(Classroom.class))).thenAnswer(inv -> withId(inv.getArgument(0), CLASS_ID)); - service.create(OWNER_ID, new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null)); + service.create(OWNER_ID, new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null, null)); verify(classroomRepository, times(3)).existsByInviteCode(anyString()); } @@ -167,7 +192,7 @@ void create_rejectsWithoutActiveTicket() { .thenReturn(Optional.empty()); assertThatThrownBy(() -> service.create(OWNER_ID, - new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null))) + new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null, null))) .isInstanceOf(CustomException.class) .extracting(ClassroomServiceImplTest::errorCodeOf) .isEqualTo(ErrorCode.CLASS_TICKET_REQUIRED); @@ -183,7 +208,7 @@ void create_rejectsStaleTicketWithoutTouchingIt() { .thenReturn(Optional.of(stale)); assertThatThrownBy(() -> service.create(OWNER_ID, - new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null))) + new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null, null))) .isInstanceOf(CustomException.class) .extracting(ClassroomServiceImplTest::errorCodeOf) .isEqualTo(ErrorCode.CLASS_TICKET_REQUIRED); @@ -199,7 +224,7 @@ void create_rejectsUnpublishedBattle() { when(battleRepository.findById(BATTLE_ID)).thenReturn(Optional.of(battle(BattleStatus.PENDING))); assertThatThrownBy(() -> service.create(OWNER_ID, - new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null))) + new ClassCreateRequest("클래스", BATTLE_ID, null, null, false, false, null, null))) .isInstanceOf(CustomException.class) .extracting(ClassroomServiceImplTest::errorCodeOf) .isEqualTo(ErrorCode.CLASS_BATTLE_NOT_AVAILABLE); @@ -214,7 +239,7 @@ void create_rejectsDeadlineOutOfRange() { for (LocalDateTime bad : new LocalDateTime[]{afterTicket, past}) { assertThatThrownBy(() -> service.create(OWNER_ID, - new ClassCreateRequest("클래스", BATTLE_ID, null, bad, false, false, null))) + new ClassCreateRequest("클래스", BATTLE_ID, null, bad, false, false, null, null))) .isInstanceOf(CustomException.class) .extracting(ClassroomServiceImplTest::errorCodeOf) .isEqualTo(ErrorCode.CLASS_INVALID_DEADLINE); @@ -231,7 +256,7 @@ void create_withDeadline_closeAtIsDeadline() { LocalDateTime deadline = LocalDateTime.now().plusDays(30); ClassDetailResponse response = service.create(OWNER_ID, - new ClassCreateRequest("클래스", BATTLE_ID, null, deadline, false, false, null)); + new ClassCreateRequest("클래스", BATTLE_ID, null, deadline, false, false, null, null)); assertThat(response.deadline()).isEqualTo(deadline); assertThat(response.closeAt()).isEqualTo(deadline);