Skip to content

Commit 78fddbd

Browse files
committed
feat(guestbook, luckydraw): 방명록 및 방명록 연계 추첨 기능 추가
1 parent ea45ed7 commit 78fddbd

File tree

9 files changed

+309
-0
lines changed

9 files changed

+309
-0
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package inha.gdgoc.domain.guestbook.controller;
2+
3+
import inha.gdgoc.domain.guestbook.controller.message.GuestbookMessage;
4+
import inha.gdgoc.domain.guestbook.dto.request.GuestbookCreateRequest;
5+
import inha.gdgoc.domain.guestbook.dto.request.LuckyDrawRequest;
6+
import inha.gdgoc.domain.guestbook.dto.response.GuestbookEntryResponse;
7+
import inha.gdgoc.domain.guestbook.dto.response.LuckyDrawWinnerResponse;
8+
import inha.gdgoc.domain.guestbook.service.GuestbookService;
9+
import inha.gdgoc.global.dto.response.ApiResponse;
10+
import jakarta.validation.Valid;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.access.prepost.PreAuthorize;
14+
import org.springframework.web.bind.annotation.*;
15+
16+
import java.util.List;
17+
import java.util.Map;
18+
19+
@RestController
20+
@RequestMapping("/api/v1/guestbook")
21+
@RequiredArgsConstructor
22+
@PreAuthorize("hasAnyRole('ORGANIZER','ADMIN')")
23+
public class GuestbookController {
24+
25+
private final GuestbookService service;
26+
27+
/* ===== helpers ===== */
28+
private static ResponseEntity<ApiResponse<Map<String, Object>, Void>> okUpdated(String msg, long updated) {
29+
return ResponseEntity.ok(ApiResponse.ok(msg, Map.of("updated", updated)));
30+
}
31+
32+
/* ===== 방명록: 등록(자동 응모) ===== */
33+
// 운영진 PC에서 손목밴드 번호 + 이름 입력 → 저장 + 자동 응모 상태로 들어감
34+
@PostMapping("/entries")
35+
public ResponseEntity<ApiResponse<GuestbookEntryResponse, Void>> createEntry(@Valid @RequestBody GuestbookCreateRequest req) {
36+
GuestbookEntryResponse saved = service.register(req.wristbandSerial(), req.name());
37+
return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.ENTRY_CREATED_SUCCESS, saved));
38+
}
39+
40+
/* ===== 방명록: 목록 ===== */
41+
@GetMapping("/entries")
42+
public ResponseEntity<ApiResponse<List<GuestbookEntryResponse>, Void>> listEntries() {
43+
var result = service.listEntries();
44+
return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.ENTRY_LIST_RETRIEVED_SUCCESS, result));
45+
}
46+
47+
/* ===== 방명록: 단건 조회(필요하면) ===== */
48+
@GetMapping("/entries/{entryId}")
49+
public ResponseEntity<ApiResponse<GuestbookEntryResponse, Void>> getEntry(@PathVariable Long entryId) {
50+
return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.ENTRY_RETRIEVED_SUCCESS, service.get(entryId)));
51+
}
52+
53+
/* ===== 방명록: 삭제(운영 중 실수 입력 정정용) ===== */
54+
@DeleteMapping("/entries/{entryId}")
55+
public ResponseEntity<ApiResponse<Map<String, Object>, Void>> deleteEntry(@PathVariable Long entryId) {
56+
service.delete(entryId);
57+
return okUpdated(GuestbookMessage.ENTRY_DELETED_SUCCESS, 1L);
58+
}
59+
60+
/* ===== 럭키드로우: 추첨 ===== */
61+
// 요청 예시: { "count": 3, "excludeWinnerIds": [1,2] } 같은 확장도 가능
62+
@PostMapping("/lucky-draw")
63+
public ResponseEntity<ApiResponse<List<LuckyDrawWinnerResponse>, Void>> drawWinners(@Valid @RequestBody LuckyDrawRequest req) {
64+
List<LuckyDrawWinnerResponse> winners = service.draw(req);
65+
return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.LUCKY_DRAW_SUCCESS, winners));
66+
}
67+
68+
/* ===== 럭키드로우: 당첨자 목록 ===== */
69+
@GetMapping("/lucky-draw/winners")
70+
public ResponseEntity<ApiResponse<List<LuckyDrawWinnerResponse>, Void>> listWinners() {
71+
return ResponseEntity.ok(ApiResponse.ok(GuestbookMessage.WINNER_LIST_RETRIEVED_SUCCESS, service.listWinners()));
72+
}
73+
74+
/* ===== 럭키드로우: 리셋(테스트/리허설용) ===== */
75+
@PostMapping("/lucky-draw/reset")
76+
public ResponseEntity<ApiResponse<Map<String, Object>, Void>> resetWinners() {
77+
long updated = service.resetWinners();
78+
return okUpdated(GuestbookMessage.WINNER_RESET_SUCCESS, updated);
79+
}
80+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package inha.gdgoc.domain.guestbook.controller.message;
2+
3+
public class GuestbookMessage {
4+
5+
public static final String ENTRY_CREATED_SUCCESS = "방명록 작성이 완료되었습니다.";
6+
public static final String ENTRY_RETRIEVED_SUCCESS = "방명록 단건 조회에 성공했습니다.";
7+
public static final String ENTRY_LIST_RETRIEVED_SUCCESS = "방명록 목록 조회에 성공했습니다.";
8+
public static final String ENTRY_DELETED_SUCCESS = "방명록 삭제에 성공했습니다.";
9+
10+
public static final String LUCKY_DRAW_SUCCESS = "럭키드로우 추첨이 완료되었습니다.";
11+
public static final String WINNER_LIST_RETRIEVED_SUCCESS = "당첨자 목록 조회에 성공했습니다.";
12+
public static final String WINNER_RESET_SUCCESS = "당첨자 초기화가 완료되었습니다.";
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package inha.gdgoc.domain.guestbook.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Size;
5+
6+
public record GuestbookCreateRequest(@NotBlank @Size(max = 32) String wristbandSerial,
7+
@NotBlank @Size(max = 50) String name) {
8+
9+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package inha.gdgoc.domain.guestbook.dto.request;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import jakarta.validation.constraints.Max;
6+
import jakarta.validation.constraints.Min;
7+
8+
public record LuckyDrawRequest(@Min(1) @Max(50) int count) {
9+
10+
@JsonCreator
11+
public LuckyDrawRequest(@JsonProperty("count") Integer count) {
12+
this((count == null) ? 1 : count);
13+
}
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package inha.gdgoc.domain.guestbook.dto.response;
2+
3+
import inha.gdgoc.domain.guestbook.entity.GuestbookEntry;
4+
5+
import java.time.LocalDateTime;
6+
7+
public record GuestbookEntryResponse(Long id, String wristbandSerial, String name, LocalDateTime createdAt,
8+
LocalDateTime wonAt) {
9+
10+
public static GuestbookEntryResponse from(GuestbookEntry e) {
11+
return new GuestbookEntryResponse(e.getId(), e.getWristbandSerial(), e.getName(), e.getCreatedAt(), e.getWonAt());
12+
}
13+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package inha.gdgoc.domain.guestbook.dto.response;
2+
3+
import inha.gdgoc.domain.guestbook.entity.GuestbookEntry;
4+
5+
import java.time.LocalDateTime;
6+
7+
public record LuckyDrawWinnerResponse(Long id, String wristbandSerial, String name, LocalDateTime wonAt) {
8+
9+
public static LuckyDrawWinnerResponse from(GuestbookEntry e) {
10+
return new LuckyDrawWinnerResponse(e.getId(), e.getWristbandSerial(), e.getName(), e.getWonAt());
11+
}
12+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package inha.gdgoc.domain.guestbook.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.Getter;
5+
6+
import java.time.LocalDateTime;
7+
8+
@Getter
9+
@Entity
10+
@Table(name = "guestbook_entry", indexes = {@Index(name = "idx_guestbook_created_at", columnList = "createdAt")})
11+
public class GuestbookEntry {
12+
13+
@Id
14+
@GeneratedValue(strategy = GenerationType.IDENTITY)
15+
private Long id;
16+
17+
@Column(name = "wristband_serial", nullable = false, unique = true, length = 32)
18+
private String wristbandSerial; // 손목밴드 일련번호(키)
19+
20+
@Column(nullable = false, length = 50)
21+
private String name;
22+
23+
@Column(nullable = false, updatable = false)
24+
private final LocalDateTime createdAt = LocalDateTime.now();
25+
26+
private LocalDateTime wonAt; // 당첨 시각 (null이면 미당첨)
27+
28+
protected GuestbookEntry() {}
29+
30+
public GuestbookEntry(String wristbandSerial, String name) {
31+
this.wristbandSerial = wristbandSerial;
32+
this.name = name;
33+
}
34+
35+
public boolean isWon() {return wonAt != null;}
36+
37+
public void markWon() {this.wonAt = LocalDateTime.now();}
38+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package inha.gdgoc.domain.guestbook.repository;
2+
3+
import inha.gdgoc.domain.guestbook.entity.GuestbookEntry;
4+
import jakarta.persistence.LockModeType;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Lock;
7+
import org.springframework.data.jpa.repository.Modifying;
8+
import org.springframework.data.jpa.repository.Query;
9+
10+
import java.util.List;
11+
12+
public interface GuestbookRepository extends JpaRepository<GuestbookEntry, Long> {
13+
14+
boolean existsByWristbandSerial(String wristbandSerial);
15+
16+
// 목록
17+
List<GuestbookEntry> findAllByOrderByCreatedAtDesc();
18+
19+
// 당첨 전 후보(락)
20+
@Lock(LockModeType.PESSIMISTIC_WRITE)
21+
@Query("select g from GuestbookEntry g where g.wonAt is null")
22+
List<GuestbookEntry> findAllByWonAtIsNullForUpdate();
23+
24+
// 당첨자 목록
25+
List<GuestbookEntry> findAllByWonAtIsNotNullOrderByWonAtAsc();
26+
27+
// 리셋 (wonAt = null)
28+
@Modifying(clearAutomatically = true, flushAutomatically = true)
29+
@Query("update GuestbookEntry g set g.wonAt = null where g.wonAt is not null")
30+
long clearAllWinners();
31+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package inha.gdgoc.domain.guestbook.service;
2+
3+
import inha.gdgoc.domain.guestbook.dto.request.LuckyDrawRequest;
4+
import inha.gdgoc.domain.guestbook.dto.response.GuestbookEntryResponse;
5+
import inha.gdgoc.domain.guestbook.dto.response.LuckyDrawWinnerResponse;
6+
import inha.gdgoc.domain.guestbook.entity.GuestbookEntry;
7+
import inha.gdgoc.domain.guestbook.repository.GuestbookRepository;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
import java.util.ArrayList;
13+
import java.util.Collections;
14+
import java.util.List;
15+
16+
@Service
17+
@Transactional
18+
@RequiredArgsConstructor
19+
public class GuestbookService {
20+
21+
private final GuestbookRepository repo;
22+
23+
/* ===== 등록 ===== */
24+
public GuestbookEntryResponse register(String wristbandSerial, String name) {
25+
wristbandSerial = wristbandSerial == null ? "" : wristbandSerial.trim();
26+
name = name == null ? "" : name.trim();
27+
28+
if (wristbandSerial.isBlank() || name.isBlank()) {
29+
throw new IllegalArgumentException("wristbandSerial and name are required");
30+
}
31+
if (repo.existsByWristbandSerial(wristbandSerial)) {
32+
throw new DuplicateWristbandSerialException();
33+
}
34+
35+
GuestbookEntry saved = repo.save(new GuestbookEntry(wristbandSerial, name));
36+
return GuestbookEntryResponse.from(saved);
37+
}
38+
39+
/* ===== 목록 ===== */
40+
@Transactional(readOnly = true)
41+
public List<GuestbookEntryResponse> listEntries() {
42+
return repo.findAllByOrderByCreatedAtDesc().stream().map(GuestbookEntryResponse::from).toList();
43+
}
44+
45+
/* ===== 단건 ===== */
46+
@Transactional(readOnly = true)
47+
public GuestbookEntryResponse get(Long id) {
48+
return repo.findById(id).map(GuestbookEntryResponse::from).orElseThrow(NotFoundException::new);
49+
}
50+
51+
/* ===== 삭제 ===== */
52+
public void delete(Long id) {
53+
repo.deleteById(id);
54+
}
55+
56+
/* ===== 추첨 ===== */
57+
public List<LuckyDrawWinnerResponse> draw(LuckyDrawRequest req) {
58+
int count = req.count();
59+
60+
List<GuestbookEntry> pool = repo.findAllByWonAtIsNullForUpdate();
61+
if (pool.size() < count) {
62+
throw new NoCandidatesException();
63+
}
64+
65+
Collections.shuffle(pool);
66+
67+
List<LuckyDrawWinnerResponse> winners = new ArrayList<>();
68+
for (int i = 0; i < count; i++) {
69+
GuestbookEntry e = pool.get(i);
70+
e.markWon();
71+
winners.add(LuckyDrawWinnerResponse.from(e));
72+
}
73+
return winners;
74+
}
75+
76+
/* ===== 당첨자 목록 ===== */
77+
@Transactional(readOnly = true)
78+
public List<LuckyDrawWinnerResponse> listWinners() {
79+
return repo.findAllByWonAtIsNotNullOrderByWonAtAsc().stream().map(LuckyDrawWinnerResponse::from).toList();
80+
}
81+
82+
/* ===== 리셋 ===== */
83+
public long resetWinners() {
84+
return repo.clearAllWinners();
85+
}
86+
87+
/* ===== exceptions ===== */
88+
public static class DuplicateWristbandSerialException extends RuntimeException {
89+
90+
}
91+
92+
public static class NoCandidatesException extends RuntimeException {
93+
94+
}
95+
96+
public static class NotFoundException extends RuntimeException {
97+
98+
}
99+
}

0 commit comments

Comments
 (0)