From 432e984898f0055e9131e80905d6d397d82a1f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:33:20 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EA=B3=BC=20=EB=8F=84=EB=A9=94=EC=9D=B8/?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC/=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B8=B0=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feedback_rounds 테이블을 새로 만들고 open_feedbacks에 year 컬럼을 추가·category 컬럼을 삭제한다. OpenFeedback을 record로 전환하고 회차 자동 배정을 위한 FeedbackRound/FeedbackRoundOptions 도메인, Repository/Service 계층을 둔다. --- .../feedback/domain/FeedbackErrorCode.java | 20 ++++++ .../domain/feedback/domain/FeedbackRound.java | 11 ++++ .../feedback/domain/FeedbackRoundOptions.java | 12 ++++ .../domain/feedback/domain/OpenFeedback.java | 37 +++++------ .../repository/FeedbackRoundRepository.java | 18 ++++++ .../repository/OpenFeedbackRepository.java | 16 +++++ .../feedback/service/OpenFeedbackService.java | 20 ++++++ .../service/impl/OpenFeedbackServiceImpl.java | 61 +++++++++++++++++++ .../db/welfare/FeedbackRoundJpaEntity.java | 56 +++++++++++++++++ .../welfare/FeedbackRoundJpaRepository.java | 20 ++++++ .../welfare/FeedbackRoundRepositoryImpl.java | 31 ++++++++++ .../db/welfare/OpenFeedbackJpaEntity.java | 33 ++++++---- .../db/welfare/OpenFeedbackJpaRepository.java | 28 +++++++++ .../welfare/OpenFeedbackRepositoryImpl.java | 42 +++++++++++++ ...dback_rounds_and_update_open_feedbacks.sql | 22 +++++++ 15 files changed, 396 insertions(+), 31 deletions(-) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackErrorCode.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRound.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundOptions.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRoundRepository.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/OpenFeedbackRepository.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/OpenFeedbackService.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaEntity.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundRepositoryImpl.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackRepositoryImpl.java create mode 100644 infrastructure/db/src/main/resources/db/migration/V9__create_feedback_rounds_and_update_open_feedbacks.sql diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackErrorCode.java new file mode 100644 index 00000000..fe3ee9b0 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackErrorCode.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum FeedbackErrorCode implements ErrorCode { + + INVALID_FEEDBACK_ROUND(ErrorStatus.BAD_REQUEST, "유효하지 않은 열린피드백 회차입니다."), + FEEDBACK_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 피드백입니다."), + FEEDBACK_NOT_OPEN(ErrorStatus.CONFLICT, "현재 열린피드백 질문 접수 기간이 아닙니다."); + + private final int status; + private final String message; +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRound.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRound.java new file mode 100644 index 00000000..7c433741 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRound.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.domain; + +import java.time.LocalDateTime; + +// 관리자가 여는 열린피드백 접수 회차. 오픈/마감 시각과 함께 연도별로 관리한다(연도별로 1차부터 다시 시작). +public record FeedbackRound(Long id, int year, int round, LocalDateTime opensAt, LocalDateTime closesAt) { + + public static FeedbackRound of(Long id, int year, int round, LocalDateTime opensAt, LocalDateTime closesAt) { + return new FeedbackRound(id, year, round, opensAt, closesAt); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundOptions.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundOptions.java new file mode 100644 index 00000000..9d570c69 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundOptions.java @@ -0,0 +1,12 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.domain; + +import java.util.List; + +// 목록 화면의 연도 드롭다운·회차 칩을 그리는 데 쓰는 조회 전용 값 객체. +// years: 피드백 회차가 하나라도 존재했던 전체 연도. rounds: year 기준으로 실제 존재하는 회차 번호. +public record FeedbackRoundOptions(List years, int year, List rounds) { + + public static FeedbackRoundOptions of(List years, int year, List rounds) { + return new FeedbackRoundOptions(years, year, rounds); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/OpenFeedback.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/OpenFeedback.java index 435c5117..3eeefe7c 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/OpenFeedback.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/OpenFeedback.java @@ -1,35 +1,36 @@ package kr.ac.kookmin.stream.welfare.domain.feedback.domain; import java.time.LocalDateTime; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -@Getter -@EqualsAndHashCode -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class OpenFeedback { +public record OpenFeedback( + Long id, + int year, + int round, + String question, + LocalDateTime questionedAt, + String answer, + LocalDateTime answeredAt, + Long createdBy, + Long answeredBy +) { - private Long id; - private int round; - private String category; - private String question; - private String answer; - private LocalDateTime answeredAt; - private Long createdBy; - private Long answeredBy; + // 회차는 서버가 자동 배정하므로 여기서 받는다(요청 시각 = questionedAt, 아직 답변 없음). + public static OpenFeedback create(int year, int round, String question, Long createdBy) { + return new OpenFeedback(null, year, round, question, LocalDateTime.now(), null, null, createdBy, null); + } + // DB에서 복원할 때 모든 필드를 그대로 받는다. public static OpenFeedback of( Long id, + int year, int round, - String category, String question, + LocalDateTime questionedAt, String answer, LocalDateTime answeredAt, Long createdBy, Long answeredBy ) { - return new OpenFeedback(id, round, category, question, answer, answeredAt, createdBy, answeredBy); + return new OpenFeedback(id, year, round, question, questionedAt, answer, answeredAt, createdBy, answeredBy); } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRoundRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRoundRepository.java new file mode 100644 index 00000000..172196f4 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRoundRepository.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound; + +public interface FeedbackRoundRepository { + + // now 시각 기준으로 접수 기간(opens_at~closes_at) 안에 있는 회차 하나를 찾는다. 여러 회차 기간이 안 겹친다는 전제. + Optional findOpenAt(LocalDateTime now); + + // 회차가 하나라도 존재했던 연도 목록(최신순) — 목록 화면 연도 드롭다운용 + List findDistinctYears(); + + // 해당 연도에 실제로 존재하는 회차 번호(오름차순) — 목록 화면 회차 칩용 + List findRoundsByYear(int year); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/OpenFeedbackRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/OpenFeedbackRepository.java new file mode 100644 index 00000000..cfd679bf --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/OpenFeedbackRepository.java @@ -0,0 +1,16 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.repository; + +import java.util.Optional; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback; + +public interface OpenFeedbackRepository { + + Optional findById(Long id); + + // year/round가 null이면 그 조건은 필터하지 않는다 + PageResult search(Integer year, Integer round, PageOffset pageOffset); + + OpenFeedback save(OpenFeedback feedback); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/OpenFeedbackService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/OpenFeedbackService.java new file mode 100644 index 00000000..45611551 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/OpenFeedbackService.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.service; + +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundOptions; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback; + +public interface OpenFeedbackService { + + OpenFeedback getById(Long id); + + // year/round가 null이면 그 조건은 전체(필터 없음) + PageResult search(Integer year, Integer round, PageOffset pageOffset); + + // 회차는 현재 열려 있는 회차로 서버가 자동 배정한다 + OpenFeedback create(Long memberId, String question); + + // year가 null이면 현재 연도 기준 + FeedbackRoundOptions getRoundOptions(Integer year); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java new file mode 100644 index 00000000..984f5b5e --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java @@ -0,0 +1,61 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.service.impl; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackErrorCode; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundOptions; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRoundRepository; +import kr.ac.kookmin.stream.welfare.domain.feedback.repository.OpenFeedbackRepository; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.OpenFeedbackService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class OpenFeedbackServiceImpl implements OpenFeedbackService { + + private final OpenFeedbackRepository openFeedbackRepository; + private final FeedbackRoundRepository feedbackRoundRepository; + + @Override + @Transactional(readOnly = true) + public OpenFeedback getById(Long id) { + return openFeedbackRepository.findById(id) + .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_FOUND)); + } + + @Override + @Transactional(readOnly = true) + public PageResult search(Integer year, Integer round, PageOffset pageOffset) { + if (round != null && round <= 0) { + throw new BusinessException(FeedbackErrorCode.INVALID_FEEDBACK_ROUND); + } + return openFeedbackRepository.search(year, round, pageOffset); + } + + @Override + @Transactional + public OpenFeedback create(Long memberId, String question) { + FeedbackRound openRound = feedbackRoundRepository.findOpenAt(LocalDateTime.now()) + .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_OPEN)); + + OpenFeedback feedback = OpenFeedback.create(openRound.year(), openRound.round(), question, memberId); + return openFeedbackRepository.save(feedback); + } + + @Override + @Transactional(readOnly = true) + public FeedbackRoundOptions getRoundOptions(Integer year) { + List years = feedbackRoundRepository.findDistinctYears(); + int targetYear = year != null ? year : LocalDateTime.now().getYear(); + List rounds = feedbackRoundRepository.findRoundsByYear(targetYear); + + return FeedbackRoundOptions.of(years, targetYear, rounds); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaEntity.java new file mode 100644 index 00000000..87032551 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaEntity.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.db.welfare; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.db.common.BaseTimeEntity; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "feedback_rounds", + uniqueConstraints = @UniqueConstraint(name = "uk_feedback_rounds_year_round", columnNames = {"year", "round"}) +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FeedbackRoundJpaEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "feedback_round_id") + private Long id; + + @Column(nullable = false) + private int year; + + @Column(nullable = false) + private int round; + + @Column(name = "opens_at", nullable = false) + private LocalDateTime opensAt; + + @Column(name = "closes_at", nullable = false) + private LocalDateTime closesAt; + + private FeedbackRoundJpaEntity(FeedbackRound feedbackRound) { + this.id = feedbackRound.id(); + this.year = feedbackRound.year(); + this.round = feedbackRound.round(); + this.opensAt = feedbackRound.opensAt(); + this.closesAt = feedbackRound.closesAt(); + } + + public static FeedbackRoundJpaEntity from(FeedbackRound feedbackRound) { + return new FeedbackRoundJpaEntity(feedbackRound); + } + + public FeedbackRound toDomain() { + return FeedbackRound.of(id, year, round, opensAt, closesAt); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaRepository.java new file mode 100644 index 00000000..b5fbdcb1 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaRepository.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface FeedbackRoundJpaRepository extends JpaRepository { + + @Query("SELECT r FROM FeedbackRoundJpaEntity r WHERE :now BETWEEN r.opensAt AND r.closesAt") + Optional findOpenAt(@Param("now") LocalDateTime now); + + @Query("SELECT DISTINCT r.year FROM FeedbackRoundJpaEntity r ORDER BY r.year DESC") + List findDistinctYears(); + + @Query("SELECT r.round FROM FeedbackRoundJpaEntity r WHERE r.year = :year ORDER BY r.round ASC") + List findRoundsByYear(@Param("year") int year); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundRepositoryImpl.java new file mode 100644 index 00000000..1aa0978d --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundRepositoryImpl.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound; +import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRoundRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class FeedbackRoundRepositoryImpl implements FeedbackRoundRepository { + + private final FeedbackRoundJpaRepository feedbackRoundJpaRepository; + + @Override + public Optional findOpenAt(LocalDateTime now) { + return feedbackRoundJpaRepository.findOpenAt(now).map(FeedbackRoundJpaEntity::toDomain); + } + + @Override + public List findDistinctYears() { + return feedbackRoundJpaRepository.findDistinctYears(); + } + + @Override + public List findRoundsByYear(int year) { + return feedbackRoundJpaRepository.findRoundsByYear(year); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaEntity.java index 57ab15e5..7ff3cadb 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaEntity.java @@ -23,14 +23,20 @@ public class OpenFeedbackJpaEntity extends BaseSoftDeleteEntity { private Long id; @Column(nullable = false) - private int round; + private int year; - @Column(nullable = false, length = 20) - private String category; + @Column(nullable = false) + private int round; - @Column(nullable = false, length = 1000) + @Column(nullable = false, length = 500) private String question; + // BaseTimeEntity의 createdAt(DB DEFAULT CURRENT_TIMESTAMP, insertable=false)에 기대지 않고 직접 값을 채운다. + // insertable=false 컬럼은 save() 직후엔 DB에서 다시 읽어오기 전까지 자바 객체에 null로 남아있어서, + // 등록 응답에 질문 시각을 바로 내려줘야 하는 이 도메인엔 맞지 않는다. + @Column(name = "questioned_at", nullable = false) + private LocalDateTime questionedAt; + @Column(columnDefinition = "TEXT") private String answer; @@ -44,14 +50,15 @@ public class OpenFeedbackJpaEntity extends BaseSoftDeleteEntity { private Long answeredBy; private OpenFeedbackJpaEntity(OpenFeedback feedback) { - this.id = feedback.getId(); - this.round = feedback.getRound(); - this.category = feedback.getCategory(); - this.question = feedback.getQuestion(); - this.answer = feedback.getAnswer(); - this.answeredAt = feedback.getAnsweredAt(); - this.createdBy = feedback.getCreatedBy(); - this.answeredBy = feedback.getAnsweredBy(); + this.id = feedback.id(); + this.year = feedback.year(); + this.round = feedback.round(); + this.question = feedback.question(); + this.questionedAt = feedback.questionedAt(); + this.answer = feedback.answer(); + this.answeredAt = feedback.answeredAt(); + this.createdBy = feedback.createdBy(); + this.answeredBy = feedback.answeredBy(); } public static OpenFeedbackJpaEntity from(OpenFeedback feedback) { @@ -59,6 +66,6 @@ public static OpenFeedbackJpaEntity from(OpenFeedback feedback) { } public OpenFeedback toDomain() { - return OpenFeedback.of(id, round, category, question, answer, answeredAt, createdBy, answeredBy); + return OpenFeedback.of(id, year, round, question, questionedAt, answer, answeredAt, createdBy, answeredBy); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaRepository.java new file mode 100644 index 00000000..ac24bdd7 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaRepository.java @@ -0,0 +1,28 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface OpenFeedbackJpaRepository extends JpaRepository { + + Optional findByIdAndIsDeletedFalse(Long id); + + @Query(value = """ + SELECT f FROM OpenFeedbackJpaEntity f + WHERE f.isDeleted = false + AND (:year IS NULL OR f.year = :year) + AND (:round IS NULL OR f.round = :round) + ORDER BY f.questionedAt DESC, f.id DESC + """, + countQuery = """ + SELECT COUNT(f) FROM OpenFeedbackJpaEntity f + WHERE f.isDeleted = false + AND (:year IS NULL OR f.year = :year) + AND (:round IS NULL OR f.round = :round) + """) + Page search(@Param("year") Integer year, @Param("round") Integer round, Pageable pageable); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackRepositoryImpl.java new file mode 100644 index 00000000..f585850c --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackRepositoryImpl.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.Optional; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.repository.OpenFeedbackRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OpenFeedbackRepositoryImpl implements OpenFeedbackRepository { + + private final OpenFeedbackJpaRepository openFeedbackJpaRepository; + + @Override + public Optional findById(Long id) { + return openFeedbackJpaRepository.findByIdAndIsDeletedFalse(id).map(OpenFeedbackJpaEntity::toDomain); + } + + @Override + public PageResult search(Integer year, Integer round, PageOffset pageOffset) { + Page result = openFeedbackJpaRepository.search( + year, round, PageRequest.of(pageOffset.page(), pageOffset.size()) + ); + + return PageResult.of( + result.getContent().stream().map(OpenFeedbackJpaEntity::toDomain).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements() + ); + } + + @Override + public OpenFeedback save(OpenFeedback feedback) { + return openFeedbackJpaRepository.save(OpenFeedbackJpaEntity.from(feedback)).toDomain(); + } +} diff --git a/infrastructure/db/src/main/resources/db/migration/V9__create_feedback_rounds_and_update_open_feedbacks.sql b/infrastructure/db/src/main/resources/db/migration/V9__create_feedback_rounds_and_update_open_feedbacks.sql new file mode 100644 index 00000000..ee951064 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V9__create_feedback_rounds_and_update_open_feedbacks.sql @@ -0,0 +1,22 @@ +CREATE TABLE feedback_rounds ( + feedback_round_id BIGINT AUTO_INCREMENT PRIMARY KEY, + year INT NOT NULL, + round INT NOT NULL, + opens_at DATETIME NOT NULL, + closes_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_feedback_rounds_year_round (year, round) +); + +-- open_feedbacks(V3)는 이미 머지된 파일이라 직접 수정하지 않고 여기서 이어서 변경한다. +-- 회차가 연도별로 1차부터 다시 시작하도록 year 컬럼을 추가하고, 화면에 카테고리 선택 UI가 없어 category는 제거한다. +-- questioned_at은 BaseTimeEntity의 created_at(DEFAULT CURRENT_TIMESTAMP, insertable=false) 대신 애플리케이션이 직접 채우는 +-- 전용 컬럼이다 — 등록 응답에 질문 시각을 그 자리에서 바로 내려줘야 하기 때문이다. +ALTER TABLE open_feedbacks + ADD COLUMN year INT NOT NULL AFTER round, + ADD COLUMN questioned_at DATETIME NOT NULL AFTER question, + MODIFY COLUMN question VARCHAR(500) NOT NULL, + DROP COLUMN category; + +CREATE INDEX idx_open_feedbacks_year_round ON open_feedbacks (year, round); From 3b8c418830fe284c1758ec2516d8f73d3ef0a723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:35:07 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/feedbacks/{feedbackId}. Swagger 명세는 AppFeedbackApi 인터페이스에, 라우팅·바인딩은 AppFeedbackController에 둔다. --- .../app/welfare/feedback/AppFeedbackApi.java | 24 +++++++++++++++++ .../feedback/AppFeedbackController.java | 27 +++++++++++++++++++ .../request/FeedbackCreateRequest.java | 12 +++++++++ .../feedback/response/FeedbackResponse.java | 26 ++++++++++++++++++ .../response/FeedbackRoundsResponse.java | 11 ++++++++ 5 files changed, 100 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/request/FeedbackCreateRequest.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackRoundsResponse.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java new file mode 100644 index 00000000..203f60e1 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java @@ -0,0 +1,24 @@ +package kr.ac.kookmin.stream.api.app.welfare.feedback; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackErrorCode; + +/** + * 학생 앱 열린피드백 API의 문서 명세. 구현은 {@link AppFeedbackController}가 맡는다. + *

+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 + * 파라미터 바인딩(@{@code RequestParam}, @{@code PathVariable}, @{@code ModelAttribute} 등)은 구현체에 둔다. + */ +@Tag(name = "열린피드백", description = "학생 앱 열린피드백 질문 등록·조회") +public interface AppFeedbackApi { + + /** 피드백 상세. */ + @Operation(summary = "피드백 상세 조회") + @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_NOT_FOUND"}) + ApiResponse getFeedback(AppApiUser apiUser, Long feedbackId); +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java new file mode 100644 index 00000000..f84fce95 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java @@ -0,0 +1,27 @@ +package kr.ac.kookmin.stream.api.app.welfare.feedback; + +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.OpenFeedbackService; +import lombok.RequiredArgsConstructor; +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.RestController; + +@RestController +@RequestMapping("/v1/feedbacks") +@RequiredArgsConstructor +public class AppFeedbackController implements AppFeedbackApi { + + private final OpenFeedbackService openFeedbackService; + + @Override + @GetMapping("/{feedbackId}") + public ApiResponse getFeedback(AppApiUser apiUser, @PathVariable Long feedbackId) { + OpenFeedback feedback = openFeedbackService.getById(feedbackId); + return ApiResponse.success(FeedbackResponse.from(feedback)); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/request/FeedbackCreateRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/request/FeedbackCreateRequest.java new file mode 100644 index 00000000..d32f4de6 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/request/FeedbackCreateRequest.java @@ -0,0 +1,12 @@ +package kr.ac.kookmin.stream.api.app.welfare.feedback.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record FeedbackCreateRequest( + + @NotBlank(message = "피드백 내용을 입력해 주세요.") + @Size(max = 500, message = "피드백 내용은 500자 이하로 입력해 주세요.") + String question +) { +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackResponse.java new file mode 100644 index 00000000..e998b185 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackResponse.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.api.app.welfare.feedback.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback; + +public record FeedbackResponse( + Long feedbackId, + int year, + int round, + String question, + LocalDateTime questionedAt, + LocalDateTime answeredAt, + String answer +) { + public static FeedbackResponse from(OpenFeedback feedback) { + return new FeedbackResponse( + feedback.id(), + feedback.year(), + feedback.round(), + feedback.question(), + feedback.questionedAt(), + feedback.answeredAt(), + feedback.answer() + ); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackRoundsResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackRoundsResponse.java new file mode 100644 index 00000000..3434da2a --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackRoundsResponse.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.api.app.welfare.feedback.response; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundOptions; + +public record FeedbackRoundsResponse(List years, int year, List rounds) { + + public static FeedbackRoundsResponse from(FeedbackRoundOptions options) { + return new FeedbackRoundsResponse(options.years(), options.year(), options.rounds()); + } +} From 005d9bb4701ed0cb398800801cc8b75ab486dd12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:36:50 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/feedbacks. year/round 쿼리 파라미터로 필터링하고 PageParams로 페이지네이션한다. --- .../app/welfare/feedback/AppFeedbackApi.java | 15 +++++++++++++++ .../feedback/AppFeedbackController.java | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java index 203f60e1..3ce6429a 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java @@ -5,8 +5,11 @@ import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackErrorCode; +import org.springdoc.core.annotations.ParameterObject; /** * 학생 앱 열린피드백 API의 문서 명세. 구현은 {@link AppFeedbackController}가 맡는다. @@ -17,6 +20,18 @@ @Tag(name = "열린피드백", description = "학생 앱 열린피드백 질문 등록·조회") public interface AppFeedbackApi { + /** 피드백 목록. year/round로 필터링하고 페이지 단위로 조회한다. */ + @Operation(summary = "피드백 목록 조회", + description = "연도(year)·회차(round)로 필터링해 열린피드백 목록을 페이지 단위로 조회한다. " + + "year를 생략하면 전체 연도, round를 생략하면 그 안의 전체 회차를 대상으로 한다.") + @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"INVALID_FEEDBACK_ROUND"}) + ApiResponse> getFeedbacks( + AppApiUser apiUser, + Integer year, + Integer round, + @ParameterObject PageParams pageParams + ); + /** 피드백 상세. */ @Operation(summary = "피드백 상세 조회") @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_NOT_FOUND"}) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java index f84fce95..cef9b668 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java @@ -1,14 +1,20 @@ package kr.ac.kookmin.stream.api.app.welfare.feedback; +import jakarta.validation.Valid; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback; import kr.ac.kookmin.stream.welfare.domain.feedback.service.OpenFeedbackService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -24,4 +30,16 @@ public ApiResponse getFeedback(AppApiUser apiUser, @PathVariab OpenFeedback feedback = openFeedbackService.getById(feedbackId); return ApiResponse.success(FeedbackResponse.from(feedback)); } + + @Override + @GetMapping + public ApiResponse> getFeedbacks( + AppApiUser apiUser, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer round, + @Valid @ModelAttribute PageParams pageParams + ) { + PageResult result = openFeedbackService.search(year, round, pageParams.toOffset()); + return ApiResponse.success(PageResponse.from(result, FeedbackResponse::from)); + } } From eeecb6f924993ef42a378f1e829ba8516862b047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:39:51 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=A7=88=EB=AC=B8=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /v1/feedbacks. 현재 접수 기간이 열려 있는 회차로 연도·회차를 자동 배정하고, 열려 있는 회차가 없으면 FEEDBACK_NOT_OPEN을 응답한다. --- .../api/app/welfare/feedback/AppFeedbackApi.java | 9 +++++++++ .../app/welfare/feedback/AppFeedbackController.java | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java index 3ce6429a..2e0e9f89 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java @@ -3,11 +3,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.feedback.request.FeedbackCreateRequest; import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.PageParams; import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackErrorCode; import org.springdoc.core.annotations.ParameterObject; @@ -36,4 +38,11 @@ ApiResponse> getFeedbacks( @Operation(summary = "피드백 상세 조회") @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_NOT_FOUND"}) ApiResponse getFeedback(AppApiUser apiUser, Long feedbackId); + + /** 피드백 질문 등록. 회차는 현재 열려 있는 회차로 서버가 자동 배정한다. */ + @Operation(summary = "피드백 질문 등록", + description = "질문을 등록한다. 연도·회차는 현재 접수 기간이 열려 있는 회차로 서버가 자동 배정한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_NOT_OPEN"}) + ApiResponse createFeedback(AppApiUser apiUser, FeedbackCreateRequest request); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java index cef9b668..3dc1dbb6 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.feedback.request.FeedbackCreateRequest; import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.PageParams; @@ -13,6 +14,8 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -42,4 +45,14 @@ public ApiResponse> getFeedbacks( PageResult result = openFeedbackService.search(year, round, pageParams.toOffset()); return ApiResponse.success(PageResponse.from(result, FeedbackResponse::from)); } + + @Override + @PostMapping + public ApiResponse createFeedback( + AppApiUser apiUser, + @Valid @RequestBody FeedbackCreateRequest request + ) { + OpenFeedback feedback = openFeedbackService.create(apiUser.userId(), request.question()); + return ApiResponse.success(FeedbackResponse.from(feedback)); + } } From f0de0b723413f13fb376b0f2c00c0dde269ebb28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:42:47 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=ED=9A=8C=EC=B0=A8=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/feedbacks/rounds. 목록 화면의 연도 드롭다운·회차 칩을 그리는 데 쓰는 연도 목록/회차 목록을 내려준다. year 생략 시 현재 연도 기준. --- .../api/app/welfare/feedback/AppFeedbackApi.java | 6 ++++++ .../app/welfare/feedback/AppFeedbackController.java | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java index 2e0e9f89..0d54b4f6 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java @@ -5,6 +5,7 @@ import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.feedback.request.FeedbackCreateRequest; import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse; +import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackRoundsResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.PageParams; import kr.ac.kookmin.stream.api.common.dto.PageResponse; @@ -45,4 +46,9 @@ ApiResponse> getFeedbacks( @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_NOT_OPEN"}) ApiResponse createFeedback(AppApiUser apiUser, FeedbackCreateRequest request); + + /** 목록 화면의 연도 드롭다운·회차 칩을 그리기 위한 필터 옵션. */ + @Operation(summary = "피드백 회차 필터 조회", + description = "year를 생략하면 현재 연도 기준으로, 회차가 하나라도 존재했던 전체 연도 목록과 그 연도에 실제로 존재하는 회차 목록을 함께 내려준다.") + ApiResponse getFeedbackRounds(AppApiUser apiUser, Integer year); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java index 3dc1dbb6..80fd1f3b 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java @@ -4,10 +4,12 @@ import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.feedback.request.FeedbackCreateRequest; import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse; +import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackRoundsResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.PageParams; import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundOptions; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback; import kr.ac.kookmin.stream.welfare.domain.feedback.service.OpenFeedbackService; import lombok.RequiredArgsConstructor; @@ -55,4 +57,14 @@ public ApiResponse createFeedback( OpenFeedback feedback = openFeedbackService.create(apiUser.userId(), request.question()); return ApiResponse.success(FeedbackResponse.from(feedback)); } + + @Override + @GetMapping("/rounds") + public ApiResponse getFeedbackRounds( + AppApiUser apiUser, + @RequestParam(required = false) Integer year + ) { + FeedbackRoundOptions options = openFeedbackService.getRoundOptions(year); + return ApiResponse.success(FeedbackRoundsResponse.from(options)); + } } From fc8b0b7fc9b00129451e1ebefee49b3a50958501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:29:19 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=EC=97=B4=EB=A6=B0=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=8B=A4=EB=A5=B8=20app-api=EC=99=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=BC=ED=95=98=EA=B2=8C=20/v1/app=20=ED=95=98=EC=9C=84?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/app/welfare/feedback/AppFeedbackController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java index 80fd1f3b..23329609 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java @@ -23,7 +23,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/v1/feedbacks") +@RequestMapping("/v1/app/feedbacks") @RequiredArgsConstructor public class AppFeedbackController implements AppFeedbackApi { From ee137da388f152cafed612a114a28088a4058ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:36:59 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EC=8B=A4=EC=A7=88=EC=A0=81=20?= =?UTF-8?q?=EC=9D=B4=EB=93=9D=20=EC=97=86=EB=8A=94=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EC=9D=98=20readOnly=3Dtrue=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/feedback/service/impl/OpenFeedbackServiceImpl.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java index 984f5b5e..8c31a8ee 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java @@ -24,14 +24,12 @@ class OpenFeedbackServiceImpl implements OpenFeedbackService { private final FeedbackRoundRepository feedbackRoundRepository; @Override - @Transactional(readOnly = true) public OpenFeedback getById(Long id) { return openFeedbackRepository.findById(id) .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_FOUND)); } @Override - @Transactional(readOnly = true) public PageResult search(Integer year, Integer round, PageOffset pageOffset) { if (round != null && round <= 0) { throw new BusinessException(FeedbackErrorCode.INVALID_FEEDBACK_ROUND); @@ -50,7 +48,6 @@ public OpenFeedback create(Long memberId, String question) { } @Override - @Transactional(readOnly = true) public FeedbackRoundOptions getRoundOptions(Integer year) { List years = feedbackRoundRepository.findDistinctYears(); int targetYear = year != null ? year : LocalDateTime.now().getYear(); From c9a7420a23636d76dbe239e1e01027aaf0bc7f95 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Tue, 29 Sep 2026 20:35:36 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=EC=9D=84=20V9?= =?UTF-8?q?=EC=97=90=EC=84=9C=20V12=EB=A1=9C=20=EC=9E=AC=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main에 먼저 머지된 #65(V9 사물함), #69(V10 빌릴게), #71(V11 사물함 신청)과 번호가 겹쳐 다음 빈 번호로 옮긴다. 파일명 외 내용·다른 코드의 참조는 없다. --- ... => V12__create_feedback_rounds_and_update_open_feedbacks.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename infrastructure/db/src/main/resources/db/migration/{V9__create_feedback_rounds_and_update_open_feedbacks.sql => V12__create_feedback_rounds_and_update_open_feedbacks.sql} (100%) diff --git a/infrastructure/db/src/main/resources/db/migration/V9__create_feedback_rounds_and_update_open_feedbacks.sql b/infrastructure/db/src/main/resources/db/migration/V12__create_feedback_rounds_and_update_open_feedbacks.sql similarity index 100% rename from infrastructure/db/src/main/resources/db/migration/V9__create_feedback_rounds_and_update_open_feedbacks.sql rename to infrastructure/db/src/main/resources/db/migration/V12__create_feedback_rounds_and_update_open_feedbacks.sql