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
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)));
}
Comment on lines +48 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

경로 변수 검증 누락

entryId 경로 변수에 검증 어노테이션이 없어 음수나 0 같은 유효하지 않은 값이 전달될 수 있습니다. 이는 서비스 레이어에서 혼란스러운 오류 메시지를 유발하거나 예상치 못한 동작을 일으킬 수 있습니다.

🔎 경로 변수 검증 추가 제안
+import jakarta.validation.constraints.Positive;
+
 /* ===== 방명록: 단건 조회(필요하면) ===== */
 @GetMapping("/entries/{entryId}")
-public ResponseEntity<ApiResponse<GuestbookEntryResponse, Void>> getEntry(@PathVariable Long entryId) {
+public ResponseEntity<ApiResponse<GuestbookEntryResponse, Void>> getEntry(@PathVariable @Positive 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) {
+public ResponseEntity<ApiResponse<Map<String, Object>, Void>> deleteEntry(@PathVariable @Positive Long entryId) {
     service.delete(entryId);
     return okUpdated(GuestbookMessage.ENTRY_DELETED_SUCCESS, 1L);
 }

Also applies to: 54-58

🤖 Prompt for AI Agents
In src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java
around lines 48-51 (and similarly for lines 54-58), the @PathVariable Long
entryId lacks validation allowing 0 or negative IDs; add validation by
annotating the parameter with @Positive (or @Min(1)) and ensure the controller
class is annotated with @Validated (import javax.validation.constraints.Positive
or javax.validation.constraints.Min and
org.springframework.validation.annotation.Validated) so Spring enforces
constraint violations; update method signatures to use @PathVariable @Positive
Long entryId (or @Min(1)) and add required imports — repeat the same change for
the other endpoint at lines 54-58.


/* ===== 방명록: 삭제(운영 중 실수 입력 정정용) ===== */
@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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

위험한 삭제 작업 보호 필요

당첨자 초기화는 데이터를 삭제하는 위험한 작업입니다. 현재 구현은 확인 절차 없이 즉시 실행됩니다.

다음 중 하나 이상의 보호 장치를 고려하세요:

  1. 확인 매개변수: 요청 본문에 {"confirm": true} 같은 명시적 확인 필드 추가
  2. 환경별 제한: 프로덕션 환경에서는 비활성화하고 개발/스테이징 환경에서만 활성화
  3. 추가 권한 검증: ADMIN 역할만 접근 가능하도록 메서드 레벨 @PreAuthorize 추가
  4. 감사 로그: 누가 언제 초기화했는지 기록
🔎 확인 매개변수 추가 예시

먼저 확인 요청 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);
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java
around lines 75-79, the resetWinners endpoint performs a dangerous delete
without protection; update it to require explicit confirmation and stronger
access/audit controls: change the handler to accept a request body DTO (e.g.,
ResetConfirmRequest with a non-null Boolean confirm) and return 400 if confirm
is not true, add method-level authorization (e.g.,
@PreAuthorize("hasRole('ADMIN')")) so only admins can invoke it, gate the
operation by environment if needed (disable in production via a config flag),
and record an audit entry with the user id and timestamp before performing
service.resetWinners(); ensure validation annotations are applied and requests
lacking confirmation are rejected.

}
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")})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

인덱스 컬럼명 수정 필요

인덱스 정의에서 Java 필드명(createdAt) 대신 실제 데이터베이스 컬럼명(created_at)을 사용해야 합니다.

🔎 제안하는 수정사항
-@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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@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")})
🤖 Prompt for AI Agents
In src/main/java/inha/gdgoc/domain/guestbook/entity/GuestbookEntry.java around
line 10, the @Table index uses the Java field name "createdAt" instead of the
actual DB column name "created_at"; update the @Index annotation's columnList to
use the database column name "created_at" (i.e., replace "createdAt" with
"created_at") so the index is created on the correct column.

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

createdAt 초기화 방식 수정 필요

createdAtfinal 필드로 선언하고 LocalDateTime.now()로 직접 초기화하는 방식은 다음과 같은 문제가 있습니다:

  1. 객체 생성 시점과 실제 DB 저장 시점이 다를 경우 부정확한 타임스탬프가 기록됩니다
  2. JPA는 final 필드를 프록시 생성 시 처리하기 어렵습니다
  3. 프로젝트 내 다른 엔티티들이 사용하는 BaseEntity의 감사(auditing) 패턴과 일관성이 없습니다

BaseEntity를 상속받아 @CreatedDate를 사용하거나, @PrePersist를 활용하는 것을 권장합니다.

🔎 제안하는 수정사항 (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이면 미당첨)

참고: BaseEntityInstant 타입의 createdAtupdatedAt을 제공하므로, LocalDateTime이 필요한 경우 별도 고려가 필요합니다.

🤖 Prompt for AI Agents
In src/main/java/inha/gdgoc/domain/guestbook/entity/GuestbookEntry.java around
lines 23-24, the createdAt field is declared final and initialized with
LocalDateTime.now(), which causes inaccurate timestamps, breaks JPA proxying,
and is inconsistent with the project's auditing pattern; remove the final and
the direct initialization, inherit and use the project's BaseEntity (which
exposes createdAt via @CreatedDate) or annotate a non-final
Instant/LocalDateTime field with @CreatedDate, or implement a @PrePersist method
to set the timestamp at persist time, ensuring the field is mutable and
compatible with JPA auditing.


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 {

}
}
Loading