diff --git a/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java b/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java new file mode 100644 index 0000000..673495a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java @@ -0,0 +1,80 @@ +package inha.gdgoc.domain.guestbook.controller; + +import inha.gdgoc.domain.guestbook.controller.message.GuestbookMessage; +import inha.gdgoc.domain.guestbook.dto.request.GuestbookCreateRequest; +import inha.gdgoc.domain.guestbook.dto.request.LuckyDrawRequest; +import inha.gdgoc.domain.guestbook.dto.response.GuestbookEntryResponse; +import inha.gdgoc.domain.guestbook.dto.response.LuckyDrawWinnerResponse; +import inha.gdgoc.domain.guestbook.service.GuestbookService; +import inha.gdgoc.global.dto.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/guestbook") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ORGANIZER','ADMIN')") +public class GuestbookController { + + private final GuestbookService service; + + /* ===== helpers ===== */ + private static ResponseEntity, Void>> okUpdated(String msg, long updated) { + return ResponseEntity.ok(ApiResponse.ok(msg, Map.of("updated", updated))); + } + + /* ===== 방명록: 등록(자동 응모) ===== */ + // 운영진 PC에서 손목밴드 번호 + 이름 입력 → 저장 + 자동 응모 상태로 들어감 + @PostMapping("/entries") + public ResponseEntity> createEntry(@Valid @RequestBody GuestbookCreateRequest req) { + GuestbookEntryResponse saved = service.register(req.wristbandSerial(), req.name()); + return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.ENTRY_CREATED_SUCCESS, saved)); + } + + /* ===== 방명록: 목록 ===== */ + @GetMapping("/entries") + public ResponseEntity, Void>> listEntries() { + var result = service.listEntries(); + return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.ENTRY_LIST_RETRIEVED_SUCCESS, result)); + } + + /* ===== 방명록: 단건 조회(필요하면) ===== */ + @GetMapping("/entries/{entryId}") + public ResponseEntity> getEntry(@PathVariable Long entryId) { + return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.ENTRY_RETRIEVED_SUCCESS, service.get(entryId))); + } + + /* ===== 방명록: 삭제(운영 중 실수 입력 정정용) ===== */ + @DeleteMapping("/entries/{entryId}") + public ResponseEntity, Void>> deleteEntry(@PathVariable Long entryId) { + service.delete(entryId); + return okUpdated(GuestbookMessage.ENTRY_DELETED_SUCCESS, 1L); + } + + /* ===== 럭키드로우: 추첨 ===== */ + // 요청 예시: { "count": 3, "excludeWinnerIds": [1,2] } 같은 확장도 가능 + @PostMapping("/lucky-draw") + public ResponseEntity, Void>> drawWinners(@Valid @RequestBody LuckyDrawRequest req) { + List winners = service.draw(req); + return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.LUCKY_DRAW_SUCCESS, winners)); + } + + /* ===== 럭키드로우: 당첨자 목록 ===== */ + @GetMapping("/lucky-draw/winners") + public ResponseEntity, Void>> listWinners() { + return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.WINNER_LIST_RETRIEVED_SUCCESS, service.listWinners())); + } + + /* ===== 럭키드로우: 리셋(테스트/리허설용) ===== */ + @PostMapping("/lucky-draw/reset") + public ResponseEntity, Void>> resetWinners() { + long updated = service.resetWinners(); + return okUpdated(GuestbookMessage.WINNER_RESET_SUCCESS, updated); + } +} diff --git a/src/main/java/inha/gdgoc/domain/guestbook/controller/message/GuestbookMessage.java b/src/main/java/inha/gdgoc/domain/guestbook/controller/message/GuestbookMessage.java new file mode 100644 index 0000000..2bdffcd --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/guestbook/controller/message/GuestbookMessage.java @@ -0,0 +1,13 @@ +package inha.gdgoc.domain.guestbook.controller.message; + +public class GuestbookMessage { + + public static final String ENTRY_CREATED_SUCCESS = "방명록 작성이 완료되었습니다."; + public static final String ENTRY_RETRIEVED_SUCCESS = "방명록 단건 조회에 성공했습니다."; + public static final String ENTRY_LIST_RETRIEVED_SUCCESS = "방명록 목록 조회에 성공했습니다."; + public static final String ENTRY_DELETED_SUCCESS = "방명록 삭제에 성공했습니다."; + + public static final String LUCKY_DRAW_SUCCESS = "럭키드로우 추첨이 완료되었습니다."; + public static final String WINNER_LIST_RETRIEVED_SUCCESS = "당첨자 목록 조회에 성공했습니다."; + public static final String WINNER_RESET_SUCCESS = "당첨자 초기화가 완료되었습니다."; +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/guestbook/dto/request/GuestbookCreateRequest.java b/src/main/java/inha/gdgoc/domain/guestbook/dto/request/GuestbookCreateRequest.java new file mode 100644 index 0000000..8e6da28 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/guestbook/dto/request/GuestbookCreateRequest.java @@ -0,0 +1,9 @@ +package inha.gdgoc.domain.guestbook.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record GuestbookCreateRequest(@NotBlank @Size(max = 32) String wristbandSerial, + @NotBlank @Size(max = 50) String name) { + +} diff --git a/src/main/java/inha/gdgoc/domain/guestbook/dto/request/LuckyDrawRequest.java b/src/main/java/inha/gdgoc/domain/guestbook/dto/request/LuckyDrawRequest.java new file mode 100644 index 0000000..c1a8518 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/guestbook/dto/request/LuckyDrawRequest.java @@ -0,0 +1,14 @@ +package inha.gdgoc.domain.guestbook.dto.request; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public record LuckyDrawRequest(@Min(1) @Max(50) int count) { + + @JsonCreator + public LuckyDrawRequest(@JsonProperty("count") Integer count) { + this((count == null) ? 1 : count); + } +} diff --git a/src/main/java/inha/gdgoc/domain/guestbook/dto/response/GuestbookEntryResponse.java b/src/main/java/inha/gdgoc/domain/guestbook/dto/response/GuestbookEntryResponse.java new file mode 100644 index 0000000..94e545a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/guestbook/dto/response/GuestbookEntryResponse.java @@ -0,0 +1,13 @@ +package inha.gdgoc.domain.guestbook.dto.response; + +import inha.gdgoc.domain.guestbook.entity.GuestbookEntry; + +import java.time.LocalDateTime; + +public record GuestbookEntryResponse(Long id, String wristbandSerial, String name, LocalDateTime createdAt, + LocalDateTime wonAt) { + + public static GuestbookEntryResponse from(GuestbookEntry e) { + return new GuestbookEntryResponse(e.getId(), e.getWristbandSerial(), e.getName(), e.getCreatedAt(), e.getWonAt()); + } +} diff --git a/src/main/java/inha/gdgoc/domain/guestbook/dto/response/LuckyDrawWinnerResponse.java b/src/main/java/inha/gdgoc/domain/guestbook/dto/response/LuckyDrawWinnerResponse.java new file mode 100644 index 0000000..6243716 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/guestbook/dto/response/LuckyDrawWinnerResponse.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.guestbook.dto.response; + +import inha.gdgoc.domain.guestbook.entity.GuestbookEntry; + +import java.time.LocalDateTime; + +public record LuckyDrawWinnerResponse(Long id, String wristbandSerial, String name, LocalDateTime wonAt) { + + public static LuckyDrawWinnerResponse from(GuestbookEntry e) { + return new LuckyDrawWinnerResponse(e.getId(), e.getWristbandSerial(), e.getName(), e.getWonAt()); + } +} diff --git a/src/main/java/inha/gdgoc/domain/guestbook/entity/GuestbookEntry.java b/src/main/java/inha/gdgoc/domain/guestbook/entity/GuestbookEntry.java new file mode 100644 index 0000000..3cfa335 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/guestbook/entity/GuestbookEntry.java @@ -0,0 +1,38 @@ +package inha.gdgoc.domain.guestbook.entity; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "guestbook_entry", indexes = {@Index(name = "idx_guestbook_created_at", columnList = "createdAt")}) +public class GuestbookEntry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "wristband_serial", nullable = false, unique = true, length = 32) + private String wristbandSerial; // 손목밴드 일련번호(키) + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, updatable = false) + private final LocalDateTime createdAt = LocalDateTime.now(); + + private LocalDateTime wonAt; // 당첨 시각 (null이면 미당첨) + + protected GuestbookEntry() {} + + public GuestbookEntry(String wristbandSerial, String name) { + this.wristbandSerial = wristbandSerial; + this.name = name; + } + + public boolean isWon() {return wonAt != null;} + + public void markWon() {this.wonAt = LocalDateTime.now();} +} diff --git a/src/main/java/inha/gdgoc/domain/guestbook/repository/GuestbookRepository.java b/src/main/java/inha/gdgoc/domain/guestbook/repository/GuestbookRepository.java new file mode 100644 index 0000000..758ae12 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/guestbook/repository/GuestbookRepository.java @@ -0,0 +1,31 @@ +package inha.gdgoc.domain.guestbook.repository; + +import inha.gdgoc.domain.guestbook.entity.GuestbookEntry; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface GuestbookRepository extends JpaRepository { + + boolean existsByWristbandSerial(String wristbandSerial); + + // 목록 + List findAllByOrderByCreatedAtDesc(); + + // 당첨 전 후보(락) + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select g from GuestbookEntry g where g.wonAt is null") + List findAllByWonAtIsNullForUpdate(); + + // 당첨자 목록 + List findAllByWonAtIsNotNullOrderByWonAtAsc(); + + // 리셋 (wonAt = null) + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update GuestbookEntry g set g.wonAt = null where g.wonAt is not null") + long clearAllWinners(); +} diff --git a/src/main/java/inha/gdgoc/domain/guestbook/service/GuestbookService.java b/src/main/java/inha/gdgoc/domain/guestbook/service/GuestbookService.java new file mode 100644 index 0000000..adefde5 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/guestbook/service/GuestbookService.java @@ -0,0 +1,99 @@ +package inha.gdgoc.domain.guestbook.service; + +import inha.gdgoc.domain.guestbook.dto.request.LuckyDrawRequest; +import inha.gdgoc.domain.guestbook.dto.response.GuestbookEntryResponse; +import inha.gdgoc.domain.guestbook.dto.response.LuckyDrawWinnerResponse; +import inha.gdgoc.domain.guestbook.entity.GuestbookEntry; +import inha.gdgoc.domain.guestbook.repository.GuestbookRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class GuestbookService { + + private final GuestbookRepository repo; + + /* ===== 등록 ===== */ + public GuestbookEntryResponse register(String wristbandSerial, String name) { + wristbandSerial = wristbandSerial == null ? "" : wristbandSerial.trim(); + name = name == null ? "" : name.trim(); + + if (wristbandSerial.isBlank() || name.isBlank()) { + throw new IllegalArgumentException("wristbandSerial and name are required"); + } + if (repo.existsByWristbandSerial(wristbandSerial)) { + throw new DuplicateWristbandSerialException(); + } + + GuestbookEntry saved = repo.save(new GuestbookEntry(wristbandSerial, name)); + return GuestbookEntryResponse.from(saved); + } + + /* ===== 목록 ===== */ + @Transactional(readOnly = true) + public List listEntries() { + return repo.findAllByOrderByCreatedAtDesc().stream().map(GuestbookEntryResponse::from).toList(); + } + + /* ===== 단건 ===== */ + @Transactional(readOnly = true) + public GuestbookEntryResponse get(Long id) { + return repo.findById(id).map(GuestbookEntryResponse::from).orElseThrow(NotFoundException::new); + } + + /* ===== 삭제 ===== */ + public void delete(Long id) { + repo.deleteById(id); + } + + /* ===== 추첨 ===== */ + public List draw(LuckyDrawRequest req) { + int count = req.count(); + + List pool = repo.findAllByWonAtIsNullForUpdate(); + if (pool.size() < count) { + throw new NoCandidatesException(); + } + + Collections.shuffle(pool); + + List winners = new ArrayList<>(); + for (int i = 0; i < count; i++) { + GuestbookEntry e = pool.get(i); + e.markWon(); + winners.add(LuckyDrawWinnerResponse.from(e)); + } + return winners; + } + + /* ===== 당첨자 목록 ===== */ + @Transactional(readOnly = true) + public List listWinners() { + return repo.findAllByWonAtIsNotNullOrderByWonAtAsc().stream().map(LuckyDrawWinnerResponse::from).toList(); + } + + /* ===== 리셋 ===== */ + public long resetWinners() { + return repo.clearAllWinners(); + } + + /* ===== exceptions ===== */ + public static class DuplicateWristbandSerialException extends RuntimeException { + + } + + public static class NoCandidatesException extends RuntimeException { + + } + + public static class NotFoundException extends RuntimeException { + + } +}