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
12 changes: 11 additions & 1 deletion docs/api-specs/class-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@
"deadline": "2026-12-31T23:59:59",
"commentRequired": true,
"ownerParticipates": true,
"ownerDisplayName": "김선생"
"ownerDisplayName": "김선생",
"requestKey": "3f2b8c1e-7d4a-4f6b-9a0e-2c5d8e1f4a7b"
}
```

Expand All @@ -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으로 돌려준다.
- 이름·운영자 표시 이름의 앞뒤 공백은 같은 내용으로 본다.

| 상황 | 응답 |
|---|---|
Expand All @@ -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 내 클래스 목록
Expand Down Expand Up @@ -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 | 내가 운영하는 클래스에는 참여자로 들어갈 수 없습니다. |
Expand Down
2 changes: 2 additions & 0 deletions docs/erd/class.puml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ entity "classes" as classes {
status : VARCHAR(20)
deleted_at : TIMESTAMP <<nullable>>
purged_at : TIMESTAMP <<nullable>>
create_request_key : VARCHAR(64) <<nullable, NEW #576, UK(owner_id, create_request_key)>>
create_request_hash : VARCHAR(64) <<nullable, NEW #576, 요청 본문 SHA-256>>
created_at : TIMESTAMP
updated_at : TIMESTAMP
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ public interface ClassroomRepository extends JpaRepository<Classroom, Long> {

Optional<Classroom> findByIdAndStatus(Long id, ClassStatus status);

/** 중복 생성 판정용: 같은 운영자가 같은 요청 키로 만든 클래스 */
Optional<Classroom> findByOwnerIdAndCreateRequestKey(Long ownerId, String createRequestKey);

/** 중복 생성 판정용(요청 키가 없을 때): 같은 운영자가 같은 이름으로 최근에 만든 클래스 */
List<Classroom> findByOwnerIdAndNameAndStatusAndCreatedAtAfterOrderByIdDesc(
Long ownerId, String name, ClassStatus status, LocalDateTime createdAfter);

/**
* 참여용: 정원 검사와 멤버 생성이 한 트랜잭션에서 직렬화되도록 클래스 행을 잠근다(SELECT ... FOR UPDATE).
* 마지막 1석을 두 사람이 동시에 신청하면 한 명만 들어간다 (PRD F06).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -213,6 +232,66 @@ private Map<Long, Long> seatCountsOf(List<Classroom> 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) 몫이다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ public interface ClassTicketRepository extends JpaRepository<ClassTicket, Long>

Optional<ClassTicket> findByRegisteredUserIdAndStatus(Long userId, TicketStatus status);

/** 클래스 생성용: 같은 이용권으로 동시에 들어온 생성 요청을 직렬화한다 (연속 탭 중복 생성 방지) */
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from ClassTicket t where t.id = :id")
Optional<ClassTicket> findByIdForUpdate(@Param("id") Long id);

/** 내 이용권 조회용: ACTIVE 가 있으면 그것, 없으면 가장 최근에 만료된 것 */
Optional<ClassTicket> findTopByRegisteredUserIdAndStatusInOrderByActivatedAtDesc(
Long userId, Collection<TicketStatus> statuses);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (클래스 참여·멤버)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 $$;
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading