From cda4c80f8efa29c0768ab11375cc4c6ed4326c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 14 Apr 2026 11:35:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EB=9E=A8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9D=84=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가장 먼저 리뷰 가능한 최소 기능 단위로 event/event_program 스키마와 프로그램 조회 API만 남겼다 - 홈, 부스, 맵, 미니 이벤트, 콘텐츠를 제외해 첫 stacked PR 크기를 346줄로 줄였다 - 이후 PR이 이 기반 위에 수직으로 쌓이도록 controller/service/dto를 프로그램 조회 중심으로 정리했다 --- .../domain/event/controller/EventApi.java | 29 +++++++ .../event/controller/EventController.java | 25 +++++++ .../dto/EventProgramSummaryResponse.java | 12 +++ .../event/dto/EventProgramsResponse.java | 15 ++++ .../domain/event/enums/EventProgramType.java | 7 ++ .../event/enums/EventProgressStatus.java | 7 ++ .../domain/event/enums/EventStatus.java | 7 ++ .../agit/konect/domain/event/model/Event.java | 52 +++++++++++++ .../domain/event/model/EventProgram.java | 59 +++++++++++++++ .../repository/EventProgramRepository.java | 14 ++++ .../event/repository/EventRepository.java | 14 ++++ .../domain/event/service/EventService.java | 75 +++++++++++++++++++ .../db/migration/V70__add_event_tables.sql | 30 ++++++++ 13 files changed, 346 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/event/controller/EventApi.java create mode 100644 src/main/java/gg/agit/konect/domain/event/controller/EventController.java create mode 100644 src/main/java/gg/agit/konect/domain/event/dto/EventProgramSummaryResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/event/dto/EventProgramsResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/event/enums/EventProgramType.java create mode 100644 src/main/java/gg/agit/konect/domain/event/enums/EventProgressStatus.java create mode 100644 src/main/java/gg/agit/konect/domain/event/enums/EventStatus.java create mode 100644 src/main/java/gg/agit/konect/domain/event/model/Event.java create mode 100644 src/main/java/gg/agit/konect/domain/event/model/EventProgram.java create mode 100644 src/main/java/gg/agit/konect/domain/event/repository/EventProgramRepository.java create mode 100644 src/main/java/gg/agit/konect/domain/event/repository/EventRepository.java create mode 100644 src/main/java/gg/agit/konect/domain/event/service/EventService.java create mode 100644 src/main/resources/db/migration/V70__add_event_tables.sql diff --git a/src/main/java/gg/agit/konect/domain/event/controller/EventApi.java b/src/main/java/gg/agit/konect/domain/event/controller/EventApi.java new file mode 100644 index 000000000..1a215b361 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/controller/EventApi.java @@ -0,0 +1,29 @@ +package gg.agit.konect.domain.event.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import gg.agit.konect.domain.event.dto.EventProgramsResponse; +import gg.agit.konect.domain.event.enums.EventProgramType; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; + +@Tag(name = "(Normal) Event: 행사", description = "행사 API") +@RequestMapping("/events") +public interface EventApi { + + @Operation(summary = "행사 프로그램 목록을 조회한다.") + @GetMapping("/{eventId}/programs") + ResponseEntity getEventPrograms( + @PathVariable Integer eventId, + @RequestParam(defaultValue = "ALL") EventProgramType type, + @RequestParam(defaultValue = "1") @Min(1) Integer page, + @RequestParam(defaultValue = "20") @Min(1) Integer limit, + @UserId Integer userId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/event/controller/EventController.java b/src/main/java/gg/agit/konect/domain/event/controller/EventController.java new file mode 100644 index 000000000..ce07b41e4 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/controller/EventController.java @@ -0,0 +1,25 @@ +package gg.agit.konect.domain.event.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.event.dto.EventProgramsResponse; +import gg.agit.konect.domain.event.enums.EventProgramType; +import gg.agit.konect.domain.event.service.EventService; +import lombok.RequiredArgsConstructor; + +@RestController +@Validated +@RequiredArgsConstructor +public class EventController implements EventApi { + + private final EventService eventService; + + @Override + public ResponseEntity getEventPrograms(Integer eventId, EventProgramType type, Integer page, + Integer limit, + Integer userId) { + return ResponseEntity.ok(eventService.getEventPrograms(eventId, type, page, limit, userId)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/event/dto/EventProgramSummaryResponse.java b/src/main/java/gg/agit/konect/domain/event/dto/EventProgramSummaryResponse.java new file mode 100644 index 000000000..3a77bdf66 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/dto/EventProgramSummaryResponse.java @@ -0,0 +1,12 @@ +package gg.agit.konect.domain.event.dto; + +public record EventProgramSummaryResponse( + Integer programId, + String title, + String description, + String thumbnailUrl, + Integer rewardPoint, + String status, + boolean participated +) { +} diff --git a/src/main/java/gg/agit/konect/domain/event/dto/EventProgramsResponse.java b/src/main/java/gg/agit/konect/domain/event/dto/EventProgramsResponse.java new file mode 100644 index 000000000..33242a892 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/dto/EventProgramsResponse.java @@ -0,0 +1,15 @@ +package gg.agit.konect.domain.event.dto; + +import java.util.List; + +import gg.agit.konect.domain.event.enums.EventProgramType; + +public record EventProgramsResponse( + Long totalCount, + Integer currentCount, + Integer totalPage, + Integer currentPage, + EventProgramType type, + List programs +) { +} diff --git a/src/main/java/gg/agit/konect/domain/event/enums/EventProgramType.java b/src/main/java/gg/agit/konect/domain/event/enums/EventProgramType.java new file mode 100644 index 000000000..bd5388611 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/enums/EventProgramType.java @@ -0,0 +1,7 @@ +package gg.agit.konect.domain.event.enums; + +public enum EventProgramType { + ALL, + POINT, + RESONANCE +} diff --git a/src/main/java/gg/agit/konect/domain/event/enums/EventProgressStatus.java b/src/main/java/gg/agit/konect/domain/event/enums/EventProgressStatus.java new file mode 100644 index 000000000..9c734667b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/enums/EventProgressStatus.java @@ -0,0 +1,7 @@ +package gg.agit.konect.domain.event.enums; + +public enum EventProgressStatus { + UPCOMING, + ONGOING, + ENDED +} diff --git a/src/main/java/gg/agit/konect/domain/event/enums/EventStatus.java b/src/main/java/gg/agit/konect/domain/event/enums/EventStatus.java new file mode 100644 index 000000000..7974b2b83 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/enums/EventStatus.java @@ -0,0 +1,7 @@ +package gg.agit.konect.domain.event.enums; + +public enum EventStatus { + DRAFT, + PUBLISHED, + ENDED +} diff --git a/src/main/java/gg/agit/konect/domain/event/model/Event.java b/src/main/java/gg/agit/konect/domain/event/model/Event.java new file mode 100644 index 000000000..3ad75db56 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/model/Event.java @@ -0,0 +1,52 @@ +package gg.agit.konect.domain.event.model; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import gg.agit.konect.domain.event.enums.EventStatus; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "event") +@NoArgsConstructor(access = PROTECTED) +public class Event extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "subtitle", length = 255) + private String subtitle; + + @Column(name = "poster_image_url", length = 255) + private String posterImageUrl; + + @Column(name = "notice", columnDefinition = "TEXT") + private String notice; + + @Column(name = "start_at", nullable = false) + private LocalDateTime startAt; + + @Column(name = "end_at", nullable = false) + private LocalDateTime endAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private EventStatus status; +} diff --git a/src/main/java/gg/agit/konect/domain/event/model/EventProgram.java b/src/main/java/gg/agit/konect/domain/event/model/EventProgram.java new file mode 100644 index 000000000..ffd11c6b7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/model/EventProgram.java @@ -0,0 +1,59 @@ +package gg.agit.konect.domain.event.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.domain.event.enums.EventProgressStatus; +import gg.agit.konect.domain.event.enums.EventProgramType; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "event_program") +@NoArgsConstructor(access = PROTECTED) +public class EventProgram extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "event_id", nullable = false, updatable = false) + private Event event; + + @Enumerated(STRING) + @Column(name = "type", nullable = false, length = 20) + private EventProgramType type; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "description", nullable = false, length = 255) + private String description; + + @Column(name = "thumbnail_url", length = 255) + private String thumbnailUrl; + + @Column(name = "reward_point") + private Integer rewardPoint; + + @Enumerated(STRING) + @Column(name = "status", nullable = false, length = 20) + private EventProgressStatus status; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; +} diff --git a/src/main/java/gg/agit/konect/domain/event/repository/EventProgramRepository.java b/src/main/java/gg/agit/konect/domain/event/repository/EventProgramRepository.java new file mode 100644 index 000000000..4f6f0b457 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/repository/EventProgramRepository.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.event.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import gg.agit.konect.domain.event.model.EventProgram; + +public interface EventProgramRepository extends Repository { + + List findAllByEventIdOrderByDisplayOrderAscIdAsc(Integer eventId); + + int countByEventId(Integer eventId); +} diff --git a/src/main/java/gg/agit/konect/domain/event/repository/EventRepository.java b/src/main/java/gg/agit/konect/domain/event/repository/EventRepository.java new file mode 100644 index 000000000..b61c91bf3 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/repository/EventRepository.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.event.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import gg.agit.konect.domain.event.model.Event; + +public interface EventRepository extends Repository { + + Optional findById(Integer id); + + Event save(Event event); +} diff --git a/src/main/java/gg/agit/konect/domain/event/service/EventService.java b/src/main/java/gg/agit/konect/domain/event/service/EventService.java new file mode 100644 index 000000000..5fc5cc0d8 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/event/service/EventService.java @@ -0,0 +1,75 @@ +package gg.agit.konect.domain.event.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_EVENT; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.event.dto.EventProgramSummaryResponse; +import gg.agit.konect.domain.event.dto.EventProgramsResponse; +import gg.agit.konect.domain.event.enums.EventProgramType; +import gg.agit.konect.domain.event.model.EventProgram; +import gg.agit.konect.domain.event.repository.EventProgramRepository; +import gg.agit.konect.domain.event.repository.EventRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EventService { + + private final EventRepository eventRepository; + private final EventProgramRepository eventProgramRepository; + + public EventProgramsResponse getEventPrograms(Integer eventId, EventProgramType type, Integer page, Integer limit, + Integer userId) { + eventRepository.findById(eventId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_EVENT)); + + List filteredPrograms = eventProgramRepository.findAllByEventIdOrderByDisplayOrderAscIdAsc( + eventId).stream() + .filter(program -> type == EventProgramType.ALL || program.getType() == type) + .toList(); + + PagedResult pagedPrograms = paginate(filteredPrograms, page, limit); + List programs = pagedPrograms.items().stream() + .map(this::toEventProgramSummaryResponse) + .toList(); + + return new EventProgramsResponse( + (long)pagedPrograms.totalCount(), + programs.size(), + pagedPrograms.totalPage(), + page, + type, + programs + ); + } + + private PagedResult paginate(List items, Integer page, Integer limit) { + int totalCount = items.size(); + int fromIndex = Math.max((page - 1) * limit, 0); + int toIndex = Math.min(fromIndex + limit, totalCount); + List pagedItems = fromIndex >= totalCount ? List.of() : items.subList(fromIndex, toIndex); + int totalPage = totalCount == 0 ? 0 : (int)Math.ceil((double)totalCount / limit); + return new PagedResult<>(pagedItems, totalCount, totalPage); + } + + private EventProgramSummaryResponse toEventProgramSummaryResponse(EventProgram program) { + return new EventProgramSummaryResponse( + program.getId(), + program.getTitle(), + program.getDescription(), + program.getThumbnailUrl(), + program.getRewardPoint(), + program.getStatus().name(), + false + ); + } + + private record PagedResult(List items, int totalCount, int totalPage) { + } +} diff --git a/src/main/resources/db/migration/V70__add_event_tables.sql b/src/main/resources/db/migration/V70__add_event_tables.sql new file mode 100644 index 000000000..e9e359042 --- /dev/null +++ b/src/main/resources/db/migration/V70__add_event_tables.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS event +( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(100) NOT NULL, + subtitle VARCHAR(255), + poster_image_url VARCHAR(255), + notice TEXT, + start_at TIMESTAMP NOT NULL, + end_at TIMESTAMP NOT NULL, + status ENUM ('DRAFT', 'PUBLISHED', 'ENDED') NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS event_program +( + id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, + type ENUM ('POINT', 'RESONANCE') NOT NULL, + title VARCHAR(100) NOT NULL, + description VARCHAR(255) NOT NULL, + thumbnail_url VARCHAR(255), + reward_point INT, + status ENUM ('UPCOMING', 'ONGOING', 'ENDED') NOT NULL, + display_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + FOREIGN KEY (event_id) REFERENCES event (id) ON DELETE CASCADE +); From 252aae3238fe571d4f1308f3f6f77d3601a9d7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 14 Apr 2026 12:32:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=ED=96=89=EC=82=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=98=A4=EB=A5=98=20=EC=BD=94=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stacked branch 재구성 과정에서 빠진 NOT_FOUND_EVENT를 다시 추가한다 - event service가 기존 예외 계약을 그대로 사용하도록 맞춰 pre-push compile 실패를 막는다 - 이후 stacked PR 전체가 공통 응답 코드를 안정적으로 공유하도록 기반 브랜치에서 먼저 복구한다 --- src/main/java/gg/agit/konect/global/code/ApiResponseCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index b8530ba10..2dce94e7b 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -104,6 +104,7 @@ public enum ApiResponseCode { NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_INBOX(HttpStatus.NOT_FOUND, "알림을 찾을 수 없습니다."), NOT_FOUND_ADVERTISEMENT(HttpStatus.NOT_FOUND, "광고를 찾을 수 없습니다."), + NOT_FOUND_EVENT(HttpStatus.NOT_FOUND, "행사를 찾을 수 없습니다."), NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), NOT_FOUND_GOOGLE_DRIVE_AUTH(HttpStatus.NOT_FOUND, "Google Drive 권한이 연결되지 않았습니다. 먼저 Drive 권한을 연결해 주세요."),