-
Notifications
You must be signed in to change notification settings - Fork 1
feat(guestbook, luckydraw): 방명록 및 방명록 연계 추첨 기능 추가 #262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ApiResponse<Map<String, Object>, Void>> okUpdated(String msg, long updated) { | ||
| return ResponseEntity.ok(ApiResponse.ok(msg, Map.of("updated", updated))); | ||
| } | ||
|
|
||
| /* ===== 방명록: 등록(자동 응모) ===== */ | ||
| // 운영진 PC에서 손목밴드 번호 + 이름 입력 → 저장 + 자동 응모 상태로 들어감 | ||
| @PostMapping("/entries") | ||
| public ResponseEntity<ApiResponse<GuestbookEntryResponse, Void>> 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<ApiResponse<List<GuestbookEntryResponse>, Void>> listEntries() { | ||
| var result = service.listEntries(); | ||
| return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.ENTRY_LIST_RETRIEVED_SUCCESS, result)); | ||
| } | ||
|
|
||
| /* ===== 방명록: 단건 조회(필요하면) ===== */ | ||
| @GetMapping("/entries/{entryId}") | ||
| public ResponseEntity<ApiResponse<GuestbookEntryResponse, Void>> getEntry(@PathVariable Long entryId) { | ||
| return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.ENTRY_RETRIEVED_SUCCESS, service.get(entryId))); | ||
| } | ||
|
|
||
| /* ===== 방명록: 삭제(운영 중 실수 입력 정정용) ===== */ | ||
| @DeleteMapping("/entries/{entryId}") | ||
| public ResponseEntity<ApiResponse<Map<String, Object>, 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<ApiResponse<List<LuckyDrawWinnerResponse>, Void>> drawWinners(@Valid @RequestBody LuckyDrawRequest req) { | ||
| List<LuckyDrawWinnerResponse> winners = service.draw(req); | ||
| return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.LUCKY_DRAW_SUCCESS, winners)); | ||
| } | ||
|
|
||
| /* ===== 럭키드로우: 당첨자 목록 ===== */ | ||
| @GetMapping("/lucky-draw/winners") | ||
| public ResponseEntity<ApiResponse<List<LuckyDrawWinnerResponse>, Void>> listWinners() { | ||
| return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.WINNER_LIST_RETRIEVED_SUCCESS, service.listWinners())); | ||
| } | ||
|
|
||
| /* ===== 럭키드로우: 리셋(테스트/리허설용) ===== */ | ||
| @PostMapping("/lucky-draw/reset") | ||
| public ResponseEntity<ApiResponse<Map<String, Object>, Void>> resetWinners() { | ||
| long updated = service.resetWinners(); | ||
| return okUpdated(GuestbookMessage.WINNER_RESET_SUCCESS, updated); | ||
| } | ||
|
Comment on lines
+75
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 위험한 삭제 작업 보호 필요 당첨자 초기화는 데이터를 삭제하는 위험한 작업입니다. 현재 구현은 확인 절차 없이 즉시 실행됩니다. 다음 중 하나 이상의 보호 장치를 고려하세요:
🔎 확인 매개변수 추가 예시먼저 확인 요청 DTO를 생성: public record ResetConfirmRequest(
@NotNull
Boolean confirm
) {}그 다음 엔드포인트 수정: @PostMapping("/lucky-draw/reset")
-public ResponseEntity<ApiResponse<Map<String, Object>, Void>> resetWinners() {
+public ResponseEntity<ApiResponse<Map<String, Object>, Void>> resetWinners(@Valid @RequestBody ResetConfirmRequest req) {
+ if (!Boolean.TRUE.equals(req.confirm())) {
+ throw new IllegalArgumentException("명시적 확인이 필요합니다");
+ }
long updated = service.resetWinners();
return okUpdated(GuestbookMessage.WINNER_RESET_SUCCESS, updated);
}
🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = "당첨자 초기화가 완료되었습니다."; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) { | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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")}) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 인덱스 컬럼명 수정 필요 인덱스 정의에서 Java 필드명( 🔎 제안하는 수정사항-@Table(name = "guestbook_entry", indexes = {@Index(name = "idx_guestbook_created_at", columnList = "createdAt")})
+@Table(name = "guestbook_entry", indexes = {@Index(name = "idx_guestbook_created_at", columnList = "created_at")})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| 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(); | ||||||
|
Comment on lines
+23
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. createdAt 초기화 방식 수정 필요
🔎 제안하는 수정사항 (BaseEntity 상속)BaseEntity를 상속받는 방식: +import inha.gdgoc.global.entity.BaseEntity;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
@Getter
@Entity
+@EntityListeners(AuditingEntityListener.class)
-@Table(name = "guestbook_entry", indexes = {@Index(name = "idx_guestbook_created_at", columnList = "createdAt")})
-public class GuestbookEntry {
+@Table(name = "guestbook_entry", indexes = {@Index(name = "idx_guestbook_created_at", columnList = "created_at")})
+public class GuestbookEntry extends BaseEntity {
@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이면 미당첨)참고: 🤖 Prompt for AI Agents |
||||||
|
|
||||||
| 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();} | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<GuestbookEntry, Long> { | ||
|
|
||
| boolean existsByWristbandSerial(String wristbandSerial); | ||
|
|
||
| // 목록 | ||
| List<GuestbookEntry> findAllByOrderByCreatedAtDesc(); | ||
|
|
||
| // 당첨 전 후보(락) | ||
| @Lock(LockModeType.PESSIMISTIC_WRITE) | ||
| @Query("select g from GuestbookEntry g where g.wonAt is null") | ||
| List<GuestbookEntry> findAllByWonAtIsNullForUpdate(); | ||
|
|
||
| // 당첨자 목록 | ||
| List<GuestbookEntry> findAllByWonAtIsNotNullOrderByWonAtAsc(); | ||
|
|
||
| // 리셋 (wonAt = null) | ||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | ||
| @Query("update GuestbookEntry g set g.wonAt = null where g.wonAt is not null") | ||
| long clearAllWinners(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<GuestbookEntryResponse> 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<LuckyDrawWinnerResponse> draw(LuckyDrawRequest req) { | ||
| int count = req.count(); | ||
|
|
||
| List<GuestbookEntry> pool = repo.findAllByWonAtIsNullForUpdate(); | ||
| if (pool.size() < count) { | ||
| throw new NoCandidatesException(); | ||
| } | ||
|
|
||
| Collections.shuffle(pool); | ||
|
|
||
| List<LuckyDrawWinnerResponse> 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<LuckyDrawWinnerResponse> 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 { | ||
|
|
||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
경로 변수 검증 누락
entryId경로 변수에 검증 어노테이션이 없어 음수나 0 같은 유효하지 않은 값이 전달될 수 있습니다. 이는 서비스 레이어에서 혼란스러운 오류 메시지를 유발하거나 예상치 못한 동작을 일으킬 수 있습니다.🔎 경로 변수 검증 추가 제안
Also applies to: 54-58
🤖 Prompt for AI Agents