From 446d15b171ee9f426a3bcbc34b3ae9bbfea23648 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, 18 Oct 2026 01:50:19 +0900 Subject: [PATCH 01/19] =?UTF-8?q?fix:=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=8C=90=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=20=EC=A7=88=EB=AC=B8=20=EC=8B=9C=EA=B0=81=EC=97=90=20?= =?UTF-8?q?=ED=95=9C=EA=B5=AD=20=EC=8B=9C=EA=B0=84=EB=8C=80=EB=A5=BC=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/domain/feedback/domain/Feedback.java | 10 +++++++--- .../feedback/service/impl/FeedbackServiceImpl.java | 11 ++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java index 7815fd78..0a39e4eb 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java @@ -14,9 +14,13 @@ public record Feedback( Long answeredBy ) { - // 회차는 서버가 자동 배정하므로 여기서 받는다(요청 시각 = questionedAt, 아직 답변 없음). - public static Feedback create(int year, int round, String question, Long createdBy) { - return new Feedback(null, year, round, question, LocalDateTime.now(), null, null, createdBy, null); + /** + * 회차는 서버가 자동 배정하므로 여기서 받는다(아직 답변 없음). + * + * @param questionedAt 질문 시각. 시계를 호출부에 둬서 회차 판정에 쓴 시각과 같은 값이 기록되게 한다 + */ + public static Feedback create(int year, int round, String question, Long createdBy, LocalDateTime questionedAt) { + return new Feedback(null, year, round, question, questionedAt, null, null, createdBy, null); } // DB에서 복원할 때 모든 필드를 그대로 받는다. diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java index de1d3d76..8febf7f5 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.List; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.DateUtil; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; @@ -37,11 +38,15 @@ public PageResult search(FeedbackPeriod period, PageOffset pageOffset) @Override @Transactional public Feedback create(Long memberId, String question) { - FeedbackRound openRound = feedbackRoundRepository.findOpenAt(LocalDateTime.now()) + // 시각을 한 번만 읽어 회차 판정과 질문 시각에 같은 값을 쓴다. 두 번 읽으면 자정 경계에서 + // "9월 회차로 판정했는데 질문 시각은 10월"이 될 수 있다 + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + + FeedbackRound openRound = feedbackRoundRepository.findOpenAt(now) .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_OPEN)); - Feedback feedback = Feedback.create(openRound.year(), openRound.round(), question, memberId); - return feedbackRepository.save(feedback); + return feedbackRepository.save( + Feedback.create(openRound.year(), openRound.round(), question, memberId, now)); } @Override From 14fb958b69d975f1779a4624c7945badae4d29d7 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, 18 Oct 2026 02:02:23 +0900 Subject: [PATCH 02/19] =?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=EC=97=90=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EA=B3=B5=EA=B0=9C=20=EC=8B=9C=EA=B0=81=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/feedback/domain/FeedbackRound.java | 55 +++++++++++++++++-- .../db/welfare/FeedbackRoundJpaEntity.java | 7 ++- ...nswers_published_at_to_feedback_rounds.sql | 5 ++ 3 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V22__add_answers_published_at_to_feedback_rounds.sql 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 index 7c433741..6a66cf52 100644 --- 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 @@ -1,11 +1,58 @@ package kr.ac.kookmin.stream.welfare.domain.feedback.domain; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; +import kr.ac.kookmin.stream.common.BusinessException; -// 관리자가 여는 열린피드백 접수 회차. 오픈/마감 시각과 함께 연도별로 관리한다(연도별로 1차부터 다시 시작). -public record FeedbackRound(Long id, int year, int round, LocalDateTime opensAt, LocalDateTime closesAt) { +/** + * 관리자가 여는 열린피드백 접수 회차. 오픈/마감 시각과 함께 연도별로 관리한다(연도별로 1차부터 다시 시작). + * answersPublishedAt은 그 회차의 답변을 학생에게 공개한 시각으로, null이면 아직 임시저장 단계다. + */ +public record FeedbackRound( + Long id, + int year, + int round, + LocalDateTime opensAt, + LocalDateTime closesAt, + LocalDateTime answersPublishedAt +) { - public static FeedbackRound of(Long id, int year, int round, LocalDateTime opensAt, LocalDateTime closesAt) { - return new FeedbackRound(id, year, round, opensAt, closesAt); + private static final LocalTime OPENS_TIME = LocalTime.MIN; + private static final LocalTime CLOSES_TIME = LocalTime.of(23, 59, 59); + + /** + * 화면이 날짜만 받으므로 시각은 여기서 채운다 — 시작일 00:00:00 ~ 마감일 23:59:59. + * 연도는 시작일 기준이고, 회차 번호는 그 연도의 다음 번호를 호출부가 계산해 넘긴다. + */ + public static FeedbackRound create(int round, LocalDate opensOn, LocalDate closesOn) { + if (closesOn.isBefore(opensOn)) { + throw new BusinessException(FeedbackErrorCode.INVALID_FEEDBACK_ROUND_PERIOD); + } + return new FeedbackRound( + null, opensOn.getYear(), round, opensOn.atTime(OPENS_TIME), closesOn.atTime(CLOSES_TIME), null); + } + + public static FeedbackRound of( + Long id, + int year, + int round, + LocalDateTime opensAt, + LocalDateTime closesAt, + LocalDateTime answersPublishedAt + ) { + return new FeedbackRound(id, year, round, opensAt, closesAt, answersPublishedAt); + } + + public boolean isAnswersPublished() { + return answersPublishedAt != null; + } + + // 이미 공개한 회차는 공개 시각을 그대로 둔다 — 등록하기를 다시 눌러도 결과가 같게(멱등) 하기 위함이다. + public FeedbackRound publishAnswers(LocalDateTime publishedAt) { + if (isAnswersPublished()) { + return this; + } + return new FeedbackRound(id, year, round, opensAt, closesAt, publishedAt); } } 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 index 87032551..6c8e2d79 100644 --- 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 @@ -38,12 +38,17 @@ public class FeedbackRoundJpaEntity extends BaseTimeEntity { @Column(name = "closes_at", nullable = false) private LocalDateTime closesAt; + // 답변을 학생에게 공개한 시각. null이면 아직 임시저장 단계라 학생 앱에 보이지 않는다 + @Column(name = "answers_published_at") + private LocalDateTime answersPublishedAt; + private FeedbackRoundJpaEntity(FeedbackRound feedbackRound) { this.id = feedbackRound.id(); this.year = feedbackRound.year(); this.round = feedbackRound.round(); this.opensAt = feedbackRound.opensAt(); this.closesAt = feedbackRound.closesAt(); + this.answersPublishedAt = feedbackRound.answersPublishedAt(); } public static FeedbackRoundJpaEntity from(FeedbackRound feedbackRound) { @@ -51,6 +56,6 @@ public static FeedbackRoundJpaEntity from(FeedbackRound feedbackRound) { } public FeedbackRound toDomain() { - return FeedbackRound.of(id, year, round, opensAt, closesAt); + return FeedbackRound.of(id, year, round, opensAt, closesAt, answersPublishedAt); } } diff --git a/infrastructure/db/src/main/resources/db/migration/V22__add_answers_published_at_to_feedback_rounds.sql b/infrastructure/db/src/main/resources/db/migration/V22__add_answers_published_at_to_feedback_rounds.sql new file mode 100644 index 00000000..b2dfd44d --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V22__add_answers_published_at_to_feedback_rounds.sql @@ -0,0 +1,5 @@ +-- 답변을 학생에게 공개한 시각. NULL이면 아직 임시저장 단계라 학생 앱에 보이지 않는다. +-- 관리자 화면의 `임시저장`(PATCH .../answers)과 `등록하기`(POST .../answers/publish)를 가르는 값이다. +-- 지금까지 답변을 달 수 있는 API 자체가 없어 운영 데이터에 답변이 한 건도 없으므로, 기존 행은 NULL(비공개)이 맞다. +ALTER TABLE feedback_rounds + ADD COLUMN answers_published_at DATETIME NULL AFTER closes_at; From 2ab16f0dc33b9e0021ecb05d09cdd821ac25ba6a 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, 18 Oct 2026 02:02:23 +0900 Subject: [PATCH 03/19] =?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=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=99=80=20=EB=8B=B5=EB=B3=80=20=EC=A7=91=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/feedback/domain/Feedback.java | 12 ++++++ .../domain/FeedbackAnswerCommand.java | 9 ++++ .../domain/feedback/domain/FeedbackCount.java | 14 +++++++ .../feedback/domain/FeedbackErrorCode.java | 8 +++- .../feedback/domain/FeedbackRoundDetail.java | 18 ++++++++ .../feedback/domain/FeedbackRoundStatus.java | 42 +++++++++++++++++++ .../feedback/domain/FeedbackRoundSummary.java | 17 ++++++++ 7 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackAnswerCommand.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackCount.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundDetail.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundStatus.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundSummary.java diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java index 0a39e4eb..c0c33bc4 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java @@ -37,4 +37,16 @@ public static Feedback of( ) { return new Feedback(id, year, round, question, questionedAt, answer, answeredAt, createdBy, answeredBy); } + + public boolean isAnswered() { + return answer != null; + } + + /** + * 답변을 단다. 이미 답변이 있으면 덮어쓴다 — 답변 수정이 같은 경로로 들어오기 때문이다. + * record라 값을 바꾸지 않고 새 인스턴스를 돌려준다. + */ + public Feedback withAnswer(String answer, Long answeredBy, LocalDateTime answeredAt) { + return new Feedback(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/domain/FeedbackAnswerCommand.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackAnswerCommand.java new file mode 100644 index 00000000..f1c72f12 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackAnswerCommand.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.domain; + +// 답변 일괄 저장 요청 한 건. 답변자와 답변 시각은 서버가 채우므로 받지 않는다. +public record FeedbackAnswerCommand(Long feedbackId, String answer) { + + public static FeedbackAnswerCommand of(Long feedbackId, String answer) { + return new FeedbackAnswerCommand(feedbackId, answer); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackCount.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackCount.java new file mode 100644 index 00000000..a27ba7bc --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackCount.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.domain; + +// 한 회차에 접수된 질문 수와 그중 답변이 작성된 수. 임시저장한 답변도 답변 수에 포함된다. +public record FeedbackCount(int year, int round, long questionCount, long answerCount) { + + public static FeedbackCount of(int year, int round, long questionCount, long answerCount) { + return new FeedbackCount(year, round, questionCount, answerCount); + } + + // 질문이 한 건도 없는 회차는 집계 쿼리의 GROUP BY 결과에 아예 나오지 않으므로 0으로 채운다. + public static FeedbackCount empty(int year, int round) { + return new FeedbackCount(year, round, 0, 0); + } +} 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 index fe3ee9b0..f9acdc6e 100644 --- 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 @@ -12,8 +12,14 @@ public enum FeedbackErrorCode implements ErrorCode { INVALID_FEEDBACK_ROUND(ErrorStatus.BAD_REQUEST, "유효하지 않은 열린피드백 회차입니다."), + INVALID_FEEDBACK_ROUND_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 회차 상태입니다."), + INVALID_FEEDBACK_ROUND_PERIOD(ErrorStatus.BAD_REQUEST, "질문 접수 마감일은 시작일 이후여야 합니다."), + FEEDBACK_NOT_IN_ROUND(ErrorStatus.BAD_REQUEST, "해당 회차에 접수된 질문이 아닙니다."), + FEEDBACK_ANSWER_NOT_FOUND(ErrorStatus.BAD_REQUEST, "공개할 답변이 없습니다."), FEEDBACK_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 피드백입니다."), - FEEDBACK_NOT_OPEN(ErrorStatus.CONFLICT, "현재 열린피드백 질문 접수 기간이 아닙니다."); + FEEDBACK_ROUND_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 열린피드백 회차입니다."), + FEEDBACK_NOT_OPEN(ErrorStatus.CONFLICT, "현재 열린피드백 질문 접수 기간이 아닙니다."), + FEEDBACK_ROUND_OVERLAPPED(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/FeedbackRoundDetail.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundDetail.java new file mode 100644 index 00000000..abac6675 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundDetail.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 회차 현황과 그 회차에 접수된 질문 전체(답변이 없는 질문도 포함). + * 질문을 이미 다 읽은 뒤라 질문 수·답변 수는 집계 쿼리 없이 목록에서 센다. + */ +public record FeedbackRoundDetail(FeedbackRoundSummary summary, List feedbacks) { + + public static FeedbackRoundDetail of(FeedbackRound round, List feedbacks, LocalDateTime now) { + long answerCount = feedbacks.stream().filter(Feedback::isAnswered).count(); + FeedbackCount count = FeedbackCount.of(round.year(), round.round(), feedbacks.size(), answerCount); + + return new FeedbackRoundDetail(FeedbackRoundSummary.of(round, count, now), feedbacks); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundStatus.java new file mode 100644 index 00000000..057b0e35 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundStatus.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.domain; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; + +/** + * 회차 상태. 컬럼으로 저장하지 않고 접수 기간과 답변 공개 여부로 그때그때 판정한다 — + * 시각이 지나면 저절로 바뀌는 값이라 저장해두면 갱신 사이에 틀린 값이 남는다. + */ +public enum FeedbackRoundStatus { + + SCHEDULED, // 오픈예정 — 접수 시작 전 + OPEN, // 질문접수중 + ANSWERING, // 답변작성중 — 접수는 끝났고 답변은 아직 비공개 + CLOSED; // 회차종료 — 답변을 공개함 + + public static FeedbackRoundStatus of(FeedbackRound round, LocalDateTime now) { + if (round.isAnswersPublished()) { + return CLOSED; + } + if (now.isBefore(round.opensAt())) { + return SCHEDULED; + } + if (now.isAfter(round.closesAt())) { + return ANSWERING; + } + return OPEN; + } + + // Spring MVC는 잘못된 enum 문자열을 MethodArgumentNotValidException이 아닌 다른 예외로 던지므로 + // 컨트롤러가 String으로 받아 이 메서드로 변환한다. null(상태 필터 미지정)은 그대로 null을 돌려준다. + public static FeedbackRoundStatus from(String value) { + if (value == null) { + return null; + } + try { + return FeedbackRoundStatus.valueOf(value); + } catch (IllegalArgumentException e) { + throw new BusinessException(FeedbackErrorCode.INVALID_FEEDBACK_ROUND_STATUS); + } + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundSummary.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundSummary.java new file mode 100644 index 00000000..7b68ef38 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundSummary.java @@ -0,0 +1,17 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.domain; + +import java.time.LocalDateTime; + +// 회차 한 건의 현황 — 기간·상태·질문 수·답변 수. 관리자 요약 화면과 회차 목록이 같은 모양을 쓴다. +public record FeedbackRoundSummary( + FeedbackRound round, + FeedbackRoundStatus status, + long questionCount, + long answerCount +) { + + public static FeedbackRoundSummary of(FeedbackRound round, FeedbackCount count, LocalDateTime now) { + return new FeedbackRoundSummary( + round, FeedbackRoundStatus.of(round, now), count.questionCount(), count.answerCount()); + } +} From 897cbf2947d490e6a444ba55897489f4fd4b03d7 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, 18 Oct 2026 02:02:23 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=9A=8C=EC=B0=A8=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=A7=88?= =?UTF-8?q?=EB=AC=B8=20=EA=B2=80=EC=83=89=EC=97=90=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FeedbackRepository.java | 21 +++++++ .../repository/FeedbackRoundRepository.java | 20 +++++++ .../stream/db/welfare/FeedbackCountRow.java | 13 +++++ .../db/welfare/FeedbackJpaRepository.java | 44 +++++++++++++++ .../db/welfare/FeedbackRepositoryImpl.java | 56 +++++++++++++++++-- .../welfare/FeedbackRoundJpaRepository.java | 21 +++++++ .../welfare/FeedbackRoundRepositoryImpl.java | 33 +++++++++++ 7 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountRow.java diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRepository.java index e95803f1..a236f01c 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRepository.java @@ -1,9 +1,11 @@ package kr.ac.kookmin.stream.welfare.domain.feedback.repository; +import java.util.List; 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.Feedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackCount; public interface FeedbackRepository { @@ -12,5 +14,24 @@ public interface FeedbackRepository { // year/round가 null이면 그 조건은 필터하지 않는다 PageResult search(Integer year, Integer round, PageOffset pageOffset); + // 한 회차의 질문 전체(답변이 없는 질문도 포함). 질문 시각 오름차순 + List findAllByYearAndRound(int year, int round); + + /** + * 관리자 질문 검색. keyword는 질문 내용에, memberIds는 작성자에 걸고 둘을 OR로 묶는다. + * 작성자 이름은 member 모듈에 있어 welfare가 조인할 수 없으므로, 이름으로 찾은 회원 ID를 받아 + * 자기 테이블의 created_by와만 비교한다. + * + * @param keyword null이면 검색 조건 없이 그 회차 전체 + * @param memberIds 이름이 keyword와 일치한 회원 ID. 일치가 없으면 빈 목록 + */ + PageResult searchByKeyword( + int year, int round, String keyword, List memberIds, PageOffset pageOffset); + + // 회차별 질문 수·답변 수 집계. year가 null이면 전체 연도. 질문이 없는 회차는 결과에 포함되지 않는다 + List findCountsByYear(Integer year); + Feedback save(Feedback feedback); + + List saveAll(List feedbacks); } 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 index 172196f4..a5c36005 100644 --- 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 @@ -7,12 +7,32 @@ public interface FeedbackRoundRepository { + Optional findById(Long id); + // now 시각 기준으로 접수 기간(opens_at~closes_at) 안에 있는 회차 하나를 찾는다. 여러 회차 기간이 안 겹친다는 전제. Optional findOpenAt(LocalDateTime now); + /** + * 답변을 아직 공개하지 않은 회차 중 가장 최근 것(연도·회차 내림차순). + * 관리자 화면이 말하는 "진행 중 회차"(SCHEDULED·OPEN·ANSWERING)가 이 조건과 같다 — + * 접수가 끝나 답변을 쓰는 중인 회차도 포함해야 답변 작성 화면이 질문을 받아올 수 있다. + */ + Optional findLatestUnpublished(); + + // year가 null이면 전체 연도. 최신 회차부터(연도 내림차순 → 회차 내림차순) 정렬한다 + List findAllByYear(Integer year); + + // 접수 기간이 주어진 구간과 하루라도 겹치는 회차가 있는지 + boolean existsOverlapping(LocalDateTime opensAt, LocalDateTime closesAt); + + // 그 연도의 가장 큰 회차 번호. 회차가 없으면 비어 있다 + Optional findMaxRoundOf(int year); + // 회차가 하나라도 존재했던 연도 목록(최신순) — 목록 화면 연도 드롭다운용 List findDistinctYears(); // 해당 연도에 실제로 존재하는 회차 번호(오름차순) — 목록 화면 회차 칩용 List findRoundsByYear(int year); + + FeedbackRound save(FeedbackRound feedbackRound); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountRow.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountRow.java new file mode 100644 index 00000000..d4177a84 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountRow.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.db.welfare; + +// 회차별 집계 쿼리의 결과 한 줄. 도메인 레코드(FeedbackCount)로는 RepositoryImpl이 옮긴다 +public interface FeedbackCountRow { + + int getYear(); + + int getRound(); + + long getQuestionCount(); + + long getAnswerCount(); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java index 9b6fd9e8..efaf28fd 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java @@ -1,5 +1,7 @@ package kr.ac.kookmin.stream.db.welfare; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -25,4 +27,46 @@ SELECT COUNT(f) FROM FeedbackJpaEntity f AND (:round IS NULL OR f.round = :round) """) Page search(@Param("year") Integer year, @Param("round") Integer round, Pageable pageable); + + @Query(""" + SELECT f FROM FeedbackJpaEntity f + WHERE f.isDeleted = false AND f.year = :year AND f.round = :round + ORDER BY f.questionedAt ASC, f.id ASC + """) + List findAllByYearAndRound(@Param("year") int year, @Param("round") int round); + + // createdBy는 welfare 자기 테이블의 숫자 컬럼이라 member 테이블과 조인하지 않는다. + // 이름으로 찾은 회원 ID를 호출부가 넘겨주고 여기서는 IN으로만 비교한다 + @Query(value = """ + SELECT f FROM FeedbackJpaEntity f + WHERE f.isDeleted = false AND f.year = :year AND f.round = :round + AND (:keyword IS NULL + OR f.question LIKE CONCAT('%', :keyword, '%') + OR f.createdBy IN :memberIds) + ORDER BY f.questionedAt DESC, f.id DESC + """, + countQuery = """ + SELECT COUNT(f) FROM FeedbackJpaEntity f + WHERE f.isDeleted = false AND f.year = :year AND f.round = :round + AND (:keyword IS NULL + OR f.question LIKE CONCAT('%', :keyword, '%') + OR f.createdBy IN :memberIds) + """) + Page searchByKeyword( + @Param("year") int year, + @Param("round") int round, + @Param("keyword") String keyword, + @Param("memberIds") Collection memberIds, + Pageable pageable + ); + + // COUNT(f.answer)는 answer가 NULL인 행을 세지 않으므로 그대로 "답변이 작성된 수"가 된다 + @Query(""" + SELECT f.year AS year, f.round AS round, + COUNT(f) AS questionCount, COUNT(f.answer) AS answerCount + FROM FeedbackJpaEntity f + WHERE f.isDeleted = false AND (:year IS NULL OR f.year = :year) + GROUP BY f.year, f.round + """) + List findCountsByYear(@Param("year") Integer year); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java index 0733dedc..b018749d 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java @@ -1,9 +1,11 @@ package kr.ac.kookmin.stream.db.welfare; +import java.util.List; 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.Feedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackCount; import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -14,6 +16,9 @@ @RequiredArgsConstructor public class FeedbackRepositoryImpl implements FeedbackRepository { + // IN에 빈 목록을 넘기면 JPQL이 깨지므로, 어떤 회원 ID와도 겹치지 않는 값을 넣어 "일치 없음"을 표현한다 + private static final List NO_MEMBER_IDS = List.of(0L); + private final FeedbackJpaRepository feedbackJpaRepository; @Override @@ -27,16 +32,57 @@ public PageResult search(Integer year, Integer round, PageOffset pageO year, round, PageRequest.of(pageOffset.page(), pageOffset.size()) ); - return PageResult.of( - result.getContent().stream().map(FeedbackJpaEntity::toDomain).toList(), - result.getNumber(), - result.getSize(), - result.getTotalElements() + return toPageResult(result); + } + + @Override + public List findAllByYearAndRound(int year, int round) { + return feedbackJpaRepository.findAllByYearAndRound(year, round).stream() + .map(FeedbackJpaEntity::toDomain) + .toList(); + } + + @Override + public PageResult searchByKeyword( + int year, int round, String keyword, List memberIds, PageOffset pageOffset + ) { + Page result = feedbackJpaRepository.searchByKeyword( + year, + round, + keyword, + memberIds.isEmpty() ? NO_MEMBER_IDS : memberIds, + PageRequest.of(pageOffset.page(), pageOffset.size()) ); + + return toPageResult(result); + } + + @Override + public List findCountsByYear(Integer year) { + return feedbackJpaRepository.findCountsByYear(year).stream() + .map(row -> FeedbackCount.of( + row.getYear(), row.getRound(), row.getQuestionCount(), row.getAnswerCount())) + .toList(); } @Override public Feedback save(Feedback feedback) { return feedbackJpaRepository.save(FeedbackJpaEntity.from(feedback)).toDomain(); } + + @Override + public List saveAll(List feedbacks) { + return feedbackJpaRepository.saveAll(feedbacks.stream().map(FeedbackJpaEntity::from).toList()).stream() + .map(FeedbackJpaEntity::toDomain) + .toList(); + } + + private static PageResult toPageResult(Page result) { + return PageResult.of( + result.getContent().stream().map(FeedbackJpaEntity::toDomain).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements() + ); + } } 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 index b5fbdcb1..ebffe459 100644 --- 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 @@ -12,6 +12,27 @@ public interface FeedbackRoundJpaRepository extends JpaRepository findOpenAt(@Param("now") LocalDateTime now); + Optional findFirstByAnswersPublishedAtIsNullOrderByYearDescRoundDesc(); + + @Query(""" + SELECT r FROM FeedbackRoundJpaEntity r + WHERE (:year IS NULL OR r.year = :year) + ORDER BY r.year DESC, r.round DESC + """) + List findAllByYear(@Param("year") Integer year); + + // 두 구간은 "내 시작이 상대 마감보다 이르고, 내 마감이 상대 시작보다 늦을 때" 겹친다 + @Query(""" + SELECT COUNT(r) > 0 FROM FeedbackRoundJpaEntity r + WHERE r.opensAt <= :closesAt AND r.closesAt >= :opensAt + """) + boolean existsOverlapping( + @Param("opensAt") LocalDateTime opensAt, @Param("closesAt") LocalDateTime closesAt); + + // 그 연도에 회차가 없으면 null이 온다 + @Query("SELECT MAX(r.round) FROM FeedbackRoundJpaEntity r WHERE r.year = :year") + Integer findMaxRoundOf(@Param("year") int year); + @Query("SELECT DISTINCT r.year FROM FeedbackRoundJpaEntity r ORDER BY r.year DESC") List findDistinctYears(); 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 index 1aa0978d..be90af2e 100644 --- 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 @@ -14,11 +14,39 @@ public class FeedbackRoundRepositoryImpl implements FeedbackRoundRepository { private final FeedbackRoundJpaRepository feedbackRoundJpaRepository; + @Override + public Optional findById(Long id) { + return feedbackRoundJpaRepository.findById(id).map(FeedbackRoundJpaEntity::toDomain); + } + @Override public Optional findOpenAt(LocalDateTime now) { return feedbackRoundJpaRepository.findOpenAt(now).map(FeedbackRoundJpaEntity::toDomain); } + @Override + public Optional findLatestUnpublished() { + return feedbackRoundJpaRepository.findFirstByAnswersPublishedAtIsNullOrderByYearDescRoundDesc() + .map(FeedbackRoundJpaEntity::toDomain); + } + + @Override + public List findAllByYear(Integer year) { + return feedbackRoundJpaRepository.findAllByYear(year).stream() + .map(FeedbackRoundJpaEntity::toDomain) + .toList(); + } + + @Override + public boolean existsOverlapping(LocalDateTime opensAt, LocalDateTime closesAt) { + return feedbackRoundJpaRepository.existsOverlapping(opensAt, closesAt); + } + + @Override + public Optional findMaxRoundOf(int year) { + return Optional.ofNullable(feedbackRoundJpaRepository.findMaxRoundOf(year)); + } + @Override public List findDistinctYears() { return feedbackRoundJpaRepository.findDistinctYears(); @@ -28,4 +56,9 @@ public List findDistinctYears() { public List findRoundsByYear(int year) { return feedbackRoundJpaRepository.findRoundsByYear(year); } + + @Override + public FeedbackRound save(FeedbackRound feedbackRound) { + return feedbackRoundJpaRepository.save(FeedbackRoundJpaEntity.from(feedbackRound)).toDomain(); + } } From 486cf8eb82f3d9f098d6249be26b7274e8935623 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, 18 Oct 2026 02:02:23 +0900 Subject: [PATCH 05/19] =?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=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=99=80=20=EB=8B=B5=EB=B3=80=20=EC=9E=84=EC=8B=9C=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=C2=B7=EA=B3=B5=EA=B0=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback/service/FeedbackService.java | 27 +++ .../service/impl/FeedbackServiceImpl.java | 162 ++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/FeedbackService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/FeedbackService.java index 7fdc96b2..4a62c168 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/FeedbackService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/FeedbackService.java @@ -1,10 +1,16 @@ package kr.ac.kookmin.stream.welfare.domain.feedback.service; +import java.time.LocalDate; import java.util.List; +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.Feedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackAnswerCommand; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackPeriod; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundDetail; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundStatus; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; public interface FeedbackService { @@ -20,4 +26,25 @@ public interface FeedbackService { // 해당 연도에 실제로 존재하는 회차 번호(오름차순) List getRoundsOf(int year); + + // 접수 기간만 받아 회차를 연다. 연도는 시작일 기준, 회차 번호는 그 연도의 최대 + 1로 서버가 배정한다 + FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn); + + // 답변을 아직 공개하지 않은 가장 최근 회차. 그런 회차가 없으면 비어 있다 + Optional getCurrentRound(); + + // year/status가 null이면 그 조건은 필터하지 않는다 + PageResult getRounds(Integer year, FeedbackRoundStatus status, PageOffset pageOffset); + + FeedbackRoundDetail getRoundDetail(Long roundId); + + // roundId가 null이면 진행 중 회차를 대상으로 한다. 진행 중 회차도 없으면 빈 페이지 + PageResult searchQuestions( + Long roundId, String keyword, List memberIds, PageOffset pageOffset); + + // 답변 임시저장. 보낸 것만 반영하고 기존 답변은 덮어쓴다. 여기서 저장한 답변은 아직 학생에게 보이지 않는다 + FeedbackRoundDetail saveAnswers(Long roundId, List commands, Long answeredBy); + + // 그 회차에 작성된 답변을 학생에게 공개한다. 이미 공개한 회차면 공개 시각을 바꾸지 않는다 + FeedbackRoundDetail publishAnswers(Long roundId); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java index 8febf7f5..25c0eb58 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java @@ -1,15 +1,25 @@ package kr.ac.kookmin.stream.welfare.domain.feedback.service.impl; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.DateUtil; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackAnswerCommand; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackCount; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackErrorCode; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackPeriod; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundDetail; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundStatus; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRepository; import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRoundRepository; import kr.ac.kookmin.stream.welfare.domain.feedback.service.FeedbackService; @@ -21,6 +31,8 @@ @RequiredArgsConstructor class FeedbackServiceImpl implements FeedbackService { + private static final int FIRST_ROUND = 1; + private final FeedbackRepository feedbackRepository; private final FeedbackRoundRepository feedbackRoundRepository; @@ -58,4 +70,154 @@ public List getAvailableYears() { public List getRoundsOf(int year) { return feedbackRoundRepository.findRoundsByYear(year); } + + @Override + @Transactional + public FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn) { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + int nextRound = feedbackRoundRepository.findMaxRoundOf(opensOn.getYear()) + .map(maxRound -> maxRound + 1) + .orElse(FIRST_ROUND); + FeedbackRound round = FeedbackRound.create(nextRound, opensOn, closesOn); + + // 학생 앱의 질문 등록은 접수 기간인 회차 "하나"를 찾아 배정하므로, 기간이 겹치면 질문 등록이 깨진다. + // 범위 겹침은 유니크 제약으로 막을 수 없어 등록 전에 확인한다(조회-후-삽입이라 동시 등록 구멍은 남는다) + if (feedbackRoundRepository.existsOverlapping(round.opensAt(), round.closesAt())) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_OVERLAPPED); + } + + FeedbackRound saved = feedbackRoundRepository.save(round); + return FeedbackRoundSummary.of(saved, FeedbackCount.empty(saved.year(), saved.round()), now); + } + + @Override + public Optional getCurrentRound() { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + return feedbackRoundRepository.findLatestUnpublished() + .map(round -> FeedbackRoundSummary.of(round, getCountOf(round), now)); + } + + /** + * 상태는 저장하지 않는 파생값이라 SQL의 WHERE에 걸 수 없고, 따라서 LIMIT도 걸 수 없다. + * 연도까지만 DB로 좁히고 상태 필터와 페이징은 메모리에서 한다 — 회차는 연 5~10개라 전체 연도를 + * 다 읽어도 수십 건이다. 회차가 수백 개로 늘면 상태를 질의할 수 있는 형태로 바꾸는 쪽을 다시 보자. + */ + @Override + public PageResult getRounds( + Integer year, FeedbackRoundStatus status, PageOffset pageOffset + ) { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + Map counts = getCountMapOf(year); + + List summaries = feedbackRoundRepository.findAllByYear(year).stream() + .map(round -> FeedbackRoundSummary.of(round, getCountOf(counts, round), now)) + .filter(summary -> status == null || summary.status() == status) + .toList(); + + return paginate(summaries, pageOffset); + } + + @Override + public FeedbackRoundDetail getRoundDetail(Long roundId) { + return getDetailOf(getRoundById(roundId), LocalDateTime.now(DateUtil.KST)); + } + + @Override + public PageResult searchQuestions( + Long roundId, String keyword, List memberIds, PageOffset pageOffset + ) { + Optional round = roundId == null + ? feedbackRoundRepository.findLatestUnpublished() + : Optional.of(getRoundById(roundId)); + + // 진행 중 회차가 없는데 회차를 지정하지도 않았으면 보여줄 질문이 없다 + return round + .map(target -> feedbackRepository.searchByKeyword( + target.year(), target.round(), keyword, memberIds, pageOffset)) + .orElseGet(() -> PageResult.empty(pageOffset.page(), pageOffset.size())); + } + + @Override + @Transactional + public FeedbackRoundDetail saveAnswers(Long roundId, List commands, Long answeredBy) { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + FeedbackRound round = getRoundById(roundId); + + Map roundFeedbacks = feedbackRepository + .findAllByYearAndRound(round.year(), round.round()).stream() + .collect(Collectors.toMap(Feedback::id, Function.identity())); + + // 보낸 것만 반영한다 — commands에 없는 질문의 기존 답변은 건드리지 않는다 + List answered = commands.stream() + .map(command -> answer(roundFeedbacks, command, answeredBy, now)) + .toList(); + feedbackRepository.saveAll(answered); + + return getDetailOf(round, now); + } + + @Override + @Transactional + public FeedbackRoundDetail publishAnswers(Long roundId) { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + FeedbackRound round = getRoundById(roundId); + List feedbacks = feedbackRepository.findAllByYearAndRound(round.year(), round.round()); + + if (feedbacks.stream().noneMatch(Feedback::isAnswered)) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_ANSWER_NOT_FOUND); + } + + FeedbackRound published = feedbackRoundRepository.save(round.publishAnswers(now)); + return FeedbackRoundDetail.of(published, feedbacks, now); + } + + private FeedbackRound getRoundById(Long roundId) { + return feedbackRoundRepository.findById(roundId) + .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_NOT_FOUND)); + } + + private FeedbackRoundDetail getDetailOf(FeedbackRound round, LocalDateTime now) { + return FeedbackRoundDetail.of( + round, feedbackRepository.findAllByYearAndRound(round.year(), round.round()), now); + } + + private Feedback answer( + Map roundFeedbacks, FeedbackAnswerCommand command, Long answeredBy, LocalDateTime now + ) { + Feedback feedback = roundFeedbacks.get(command.feedbackId()); + if (feedback != null) { + return feedback.withAnswer(command.answer(), answeredBy, now); + } + + // 다른 회차의 질문인지, 아예 없는 질문인지 나눠서 알려준다 + if (feedbackRepository.findById(command.feedbackId()).isEmpty()) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_FOUND); + } + throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_IN_ROUND); + } + + private FeedbackCount getCountOf(FeedbackRound round) { + return getCountOf(getCountMapOf(round.year()), round); + } + + private FeedbackCount getCountOf(Map counts, FeedbackRound round) { + return counts.getOrDefault( + FeedbackPeriod.of(round.year(), round.round()), FeedbackCount.empty(round.year(), round.round())); + } + + private Map getCountMapOf(Integer year) { + return feedbackRepository.findCountsByYear(year).stream() + .collect(Collectors.toMap( + count -> FeedbackPeriod.of(count.year(), count.round()), Function.identity())); + } + + private static PageResult paginate( + List summaries, PageOffset pageOffset + ) { + int fromIndex = Math.min(pageOffset.page() * pageOffset.size(), summaries.size()); + int toIndex = Math.min(fromIndex + pageOffset.size(), summaries.size()); + + return PageResult.of( + summaries.subList(fromIndex, toIndex), pageOffset.page(), pageOffset.size(), summaries.size()); + } } From 0a23b80627a8fb1a0ee42f11b9d387296e0eea27 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, 18 Oct 2026 02:02:23 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EA=B4=80=EB=A6=AC=EC=9E=90=20API=EB=A5=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/feedback/AdminFeedbackApi.java | 35 +++++++ .../feedback/AdminFeedbackController.java | 39 ++++++++ .../feedback/AdminFeedbackRoundApi.java | 81 +++++++++++++++ .../AdminFeedbackRoundController.java | 99 +++++++++++++++++++ .../request/FeedbackAnswerSaveRequest.java | 43 ++++++++ .../request/FeedbackRoundCreateRequest.java | 18 ++++ .../response/AdminFeedbackResponse.java | 35 +++++++ .../response/FeedbackAnswerResponse.java | 31 ++++++ .../response/FeedbackRoundDetailResponse.java | 41 ++++++++ .../response/FeedbackRoundResponse.java | 35 +++++++ .../usecase/AdminFeedbackAnswerUseCase.java | 45 +++++++++ .../usecase/AdminFeedbackSearchUseCase.java | 40 ++++++++ .../usecase/FeedbackRoundAnswers.java | 8 ++ .../feedback/usecase/FeedbackWithStudent.java | 9 ++ 14 files changed, 559 insertions(+) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackApi.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackAnswerSaveRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackRoundCreateRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundDetailResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackRoundAnswers.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackWithStudent.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackApi.java new file mode 100644 index 00000000..7fcee615 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackApi.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.response.AdminFeedbackResponse; +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; + +/** + * 운영진 열린피드백 질문 API의 문서 명세. 구현은 {@link AdminFeedbackController}가 맡는다. + *

+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. + */ +@Tag(name = "열린피드백 질문 관리", description = "운영진 열린피드백 질문 조회") +public interface AdminFeedbackApi { + + /** 현재 회차 질문 탭. 질문 내용·작성자 이름으로 검색한다. */ + @Operation(summary = "사용자 질문 조회", + description = "회차(roundId)의 질문을 질문 시각 내림차순으로 조회한다. roundId를 생략하면 진행 중 회차를 보고, " + + "진행 중 회차도 없으면 빈 목록이다. keyword는 질문 내용과 작성자 이름에 부분 일치로 걸린다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_ROUND_NOT_FOUND"}) + ApiResponse> getFeedbacks( + AdminApiUser apiUser, + Long roundId, + String keyword, + @ParameterObject PageParams pageParams + ); +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java new file mode 100644 index 00000000..ef89d347 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.response.AdminFeedbackResponse; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.AdminFeedbackSearchUseCase; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.FeedbackWithStudent; +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 lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/feedbacks") +@RequiredArgsConstructor +public class AdminFeedbackController implements AdminFeedbackApi { + + private final AdminFeedbackSearchUseCase adminFeedbackSearchUseCase; + + @Override + @GetMapping + public ApiResponse> getFeedbacks( + AdminApiUser apiUser, + @RequestParam(required = false) Long roundId, + @RequestParam(required = false) String keyword, + @Valid @ModelAttribute PageParams pageParams + ) { + PageResult result = + adminFeedbackSearchUseCase.search(roundId, keyword, pageParams.toOffset()); + + return ApiResponse.success(PageResponse.from(result, AdminFeedbackResponse::from)); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java new file mode 100644 index 00000000..a69a2eff --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java @@ -0,0 +1,81 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.request.FeedbackAnswerSaveRequest; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.request.FeedbackRoundCreateRequest; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.response.FeedbackRoundDetailResponse; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.response.FeedbackRoundResponse; +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; + +/** + * 운영진 열린피드백 회차 API의 문서 명세. 구현은 {@link AdminFeedbackRoundController}가 맡는다. + *

+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 + * 파라미터 바인딩(@{@code RequestParam}, @{@code PathVariable}, @{@code RequestBody} 등)은 구현체에 둔다. + */ +@Tag(name = "열린피드백 회차 관리", description = "운영진 열린피드백 회차 등록·조회와 답변 작성·공개") +public interface AdminFeedbackRoundApi { + + /** 관리 화면 상단 요약. 답변을 아직 공개하지 않은 가장 최근 회차를 내려준다. */ + @Operation(summary = "진행 중 회차 조회", + description = "답변을 아직 공개하지 않은 가장 최근 회차(오픈예정·질문접수중·답변작성중)를 조회한다. " + + "그런 회차가 없으면 data가 null이다.") + ApiResponse getCurrentRound(AdminApiUser apiUser); + + /** 회차 목록. 연도·상태로 필터링하고 페이지 단위로 조회한다. */ + @Operation(summary = "피드백 회차 목록 조회", + description = "연도(year)와 상태(status)로 필터링해 회차를 최신순(연도 내림차순 → 회차 내림차순)으로 조회한다. " + + "상태는 저장하지 않고 접수 기간과 답변 공개 여부로 서버가 판정한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"INVALID_FEEDBACK_ROUND_STATUS"}) + ApiResponse> getRounds( + AdminApiUser apiUser, + Integer year, + String status, + @ParameterObject PageParams pageParams + ); + + /** 회차 등록. 접수 기간만 받고 연도·회차 번호는 서버가 배정한다. */ + @Operation(summary = "피드백 회차 등록", + description = "질문 접수 기간(날짜)만 받아 회차를 연다. 시각은 시작일 00:00:00 ~ 마감일 23:59:59로 채우고, " + + "연도는 시작일 기준, 회차 번호는 그 연도의 최대 + 1로 배정한다. 다른 회차와 접수 기간이 겹치면 409다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode( + type = FeedbackErrorCode.class, + codes = {"INVALID_FEEDBACK_ROUND_PERIOD", "FEEDBACK_ROUND_OVERLAPPED"} + ) + ApiResponse createRound(AdminApiUser apiUser, FeedbackRoundCreateRequest request); + + /** 회차 답변 보기 모달. 그 회차의 질문 전체를 답변과 함께 내려준다. */ + @Operation(summary = "회차 답변 내역 조회", + description = "그 회차에 접수된 질문 전체를 질문 시각 오름차순으로 조회한다. 답변이 없는 질문도 포함된다.") + @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_ROUND_NOT_FOUND"}) + ApiResponse getRoundAnswers(AdminApiUser apiUser, Long roundId); + + /** 답변 임시저장. 저장만 하고 학생에게는 보이지 않는다. */ + @Operation(summary = "피드백 답변 일괄 등록·수정", + description = "회차 단위로 답변을 저장한다. 보낸 것만 반영하고 이미 답변이 있는 질문은 덮어쓴다. " + + "여기서 저장한 답변은 관리자에게만 보이고, 답변 공개 API를 불러야 학생 앱에 나타난다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode( + type = FeedbackErrorCode.class, + codes = {"FEEDBACK_NOT_IN_ROUND", "FEEDBACK_ROUND_NOT_FOUND", "FEEDBACK_NOT_FOUND"} + ) + ApiResponse saveAnswers( + AdminApiUser apiUser, Long roundId, FeedbackAnswerSaveRequest request); + + /** 답변 공개. 그 회차의 답변을 학생 앱에 한꺼번에 띄운다. */ + @Operation(summary = "피드백 답변 공개", + description = "그 회차에 작성된 답변을 학생에게 공개한다. 공개는 회차 단위이고, " + + "이미 공개한 회차를 다시 불러도 공개 시각을 바꾸지 않고 성공으로 응답한다(멱등).") + @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_ANSWER_NOT_FOUND", "FEEDBACK_ROUND_NOT_FOUND"}) + ApiResponse publishAnswers(AdminApiUser apiUser, Long roundId); +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java new file mode 100644 index 00000000..c97ffa54 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java @@ -0,0 +1,99 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.request.FeedbackAnswerSaveRequest; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.request.FeedbackRoundCreateRequest; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.response.FeedbackRoundDetailResponse; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.response.FeedbackRoundResponse; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.AdminFeedbackAnswerUseCase; +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.FeedbackRoundStatus; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.FeedbackService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; +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; + +@RestController +@RequestMapping("/v1/admin/feedback-rounds") +@RequiredArgsConstructor +public class AdminFeedbackRoundController implements AdminFeedbackRoundApi { + + private final FeedbackService feedbackService; + private final AdminFeedbackAnswerUseCase adminFeedbackAnswerUseCase; + + @Override + @GetMapping("/current") + public ApiResponse getCurrentRound(AdminApiUser apiUser) { + // 진행 중 회차가 없으면 data를 null로 내려 화면이 "진행 중인 회차 없음"을 그린다 + return ApiResponse.success( + feedbackService.getCurrentRound().map(FeedbackRoundResponse::from).orElse(null)); + } + + @Override + @GetMapping + public ApiResponse> getRounds( + AdminApiUser apiUser, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) String status, + @Valid @ModelAttribute PageParams pageParams + ) { + FeedbackRoundStatus roundStatus = FeedbackRoundStatus.from(status); + PageResult result = + feedbackService.getRounds(year, roundStatus, pageParams.toOffset()); + + return ApiResponse.success(PageResponse.from(result, FeedbackRoundResponse::from)); + } + + @Override + @PostMapping + public ApiResponse createRound( + AdminApiUser apiUser, + @Valid @RequestBody FeedbackRoundCreateRequest request + ) { + FeedbackRoundSummary created = feedbackService.createRound(request.opensOn(), request.closesOn()); + return ApiResponse.success(FeedbackRoundResponse.from(created)); + } + + @Override + @GetMapping("/{roundId}") + public ApiResponse getRoundAnswers( + AdminApiUser apiUser, + @PathVariable Long roundId + ) { + return ApiResponse.success( + FeedbackRoundDetailResponse.from(adminFeedbackAnswerUseCase.getRoundAnswers(roundId))); + } + + @Override + @PatchMapping("/{roundId}/answers") + public ApiResponse saveAnswers( + AdminApiUser apiUser, + @PathVariable Long roundId, + @Valid @RequestBody FeedbackAnswerSaveRequest request + ) { + return ApiResponse.success(FeedbackRoundDetailResponse.from( + adminFeedbackAnswerUseCase.saveAnswers(roundId, request.toCommands(), apiUser.userId()))); + } + + @Override + @PostMapping("/{roundId}/answers/publish") + public ApiResponse publishAnswers( + AdminApiUser apiUser, + @PathVariable Long roundId + ) { + return ApiResponse.success( + FeedbackRoundDetailResponse.from(adminFeedbackAnswerUseCase.publishAnswers(roundId))); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackAnswerSaveRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackAnswerSaveRequest.java new file mode 100644 index 00000000..c552c422 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackAnswerSaveRequest.java @@ -0,0 +1,43 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackAnswerCommand; + +/** + * 답변 임시저장 요청. 화면이 질문 여러 개를 체크해 각각 답변을 쓰는 형태라 회차 단위 일괄로 받는다. + * 보낸 것만 반영하고, 이미 답변이 있는 질문을 다시 보내면 덮어쓴다. + */ +public record FeedbackAnswerSaveRequest( + + @Schema(description = "답변 목록. 최소 1건") + @NotEmpty(message = "답변을 1건 이상 입력해 주세요.") + List<@Valid @NotNull(message = "답변을 입력해 주세요.") Item> answers +) { + + public List toCommands() { + return answers.stream() + .map(item -> FeedbackAnswerCommand.of(item.feedbackId(), item.answer())) + .toList(); + } + + public record Item( + + @Schema(description = "답변할 피드백 아이디", example = "21") + @NotNull(message = "답변할 피드백을 선택해 주세요.") + Long feedbackId, + + // answer는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다 + @Schema(description = "답변 내용. `**텍스트**`로 감싼 부분은 굵게 렌더링하기로 한 약속이다", + example = "시험 기간에는 열람실을 **24시간** 개방합니다.") + @NotBlank(message = "답변 내용은 필수 입력값입니다.") + @Size(max = 16000, message = "답변 내용은 16000자 이하로 입력해 주세요.") + String answer + ) { + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackRoundCreateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackRoundCreateRequest.java new file mode 100644 index 00000000..30c50fb1 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackRoundCreateRequest.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +// 화면이 입력받는 값은 질문 접수 기간 하나뿐이다. 연도·회차 번호는 서버가 배정한다 +public record FeedbackRoundCreateRequest( + + @Schema(description = "질문 접수 시작일", example = "2026-10-16") + @NotNull(message = "질문 접수 시작일과 마감일을 모두 입력해 주세요.") + LocalDate opensOn, + + @Schema(description = "질문 접수 마감일", example = "2026-10-24") + @NotNull(message = "질문 접수 시작일과 마감일을 모두 입력해 주세요.") + LocalDate closesOn +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java new file mode 100644 index 00000000..1391b3ab --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.FeedbackWithStudent; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; + +// 현재 회차 질문 탭의 한 줄. studentName은 탈퇴(소프트 삭제)한 회원의 질문이면 null이다 +public record AdminFeedbackResponse( + Long feedbackId, + int year, + int round, + String question, + String studentName, + LocalDateTime questionedAt, + String answer, + LocalDateTime answeredAt +) { + + public static AdminFeedbackResponse from(FeedbackWithStudent feedbackWithStudent) { + Feedback feedback = feedbackWithStudent.feedback(); + Member student = feedbackWithStudent.student(); + + return new AdminFeedbackResponse( + feedback.id(), + feedback.year(), + feedback.round(), + feedback.question(), + student == null ? null : student.getName(), + feedback.questionedAt(), + feedback.answer(), + feedback.answeredAt() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java new file mode 100644 index 00000000..b0788c35 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.FeedbackWithStudent; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; + +// 회차 답변 보기 모달의 한 항목. 답변이 없는 질문도 함께 내려간다 +public record FeedbackAnswerResponse( + Long feedbackId, + String question, + String studentName, + LocalDateTime questionedAt, + String answer, + LocalDateTime answeredAt +) { + + public static FeedbackAnswerResponse from(FeedbackWithStudent feedbackWithStudent) { + Feedback feedback = feedbackWithStudent.feedback(); + Member student = feedbackWithStudent.student(); + + return new FeedbackAnswerResponse( + feedback.id(), + feedback.question(), + student == null ? null : student.getName(), + feedback.questionedAt(), + feedback.answer(), + feedback.answeredAt() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundDetailResponse.java new file mode 100644 index 00000000..79e2a5da --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundDetailResponse.java @@ -0,0 +1,41 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.response; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.FeedbackRoundAnswers; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundStatus; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; + +// 회차 현황 + 그 회차의 질문·답변 전체. 답변 저장·공개 응답도 같은 모양이라 화면이 목록을 그대로 갱신한다 +public record FeedbackRoundDetailResponse( + Long roundId, + int year, + int round, + LocalDateTime opensAt, + LocalDateTime closesAt, + FeedbackRoundStatus status, + long questionCount, + long answerCount, + LocalDateTime answersPublishedAt, + List feedbacks +) { + + public static FeedbackRoundDetailResponse from(FeedbackRoundAnswers roundAnswers) { + FeedbackRoundSummary summary = roundAnswers.summary(); + FeedbackRound round = summary.round(); + + return new FeedbackRoundDetailResponse( + round.id(), + round.year(), + round.round(), + round.opensAt(), + round.closesAt(), + summary.status(), + summary.questionCount(), + summary.answerCount(), + round.answersPublishedAt(), + roundAnswers.feedbacks().stream().map(FeedbackAnswerResponse::from).toList() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundResponse.java new file mode 100644 index 00000000..a81d8a57 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundResponse.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundStatus; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; + +public record FeedbackRoundResponse( + Long roundId, + int year, + int round, + LocalDateTime opensAt, + LocalDateTime closesAt, + FeedbackRoundStatus status, + long questionCount, + long answerCount, + LocalDateTime answersPublishedAt +) { + + public static FeedbackRoundResponse from(FeedbackRoundSummary summary) { + FeedbackRound round = summary.round(); + + return new FeedbackRoundResponse( + round.id(), + round.year(), + round.round(), + round.opensAt(), + round.closesAt(), + summary.status(), + summary.questionCount(), + summary.answerCount(), + round.answersPublishedAt() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java new file mode 100644 index 00000000..b987fc85 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java @@ -0,0 +1,45 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase; + +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.member.domain.member.service.MemberService; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackAnswerCommand; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundDetail; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.FeedbackService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +// 회차 답변 화면이 쓰는 조합. welfare가 돌려준 질문·답변에 member의 작성자 이름을 붙인다. +@Component +@RequiredArgsConstructor +public class AdminFeedbackAnswerUseCase { + + private final FeedbackService feedbackService; + private final MemberService memberService; + + public FeedbackRoundAnswers getRoundAnswers(Long roundId) { + return withStudents(feedbackService.getRoundDetail(roundId)); + } + + public FeedbackRoundAnswers saveAnswers(Long roundId, List commands, Long answeredBy) { + return withStudents(feedbackService.saveAnswers(roundId, commands, answeredBy)); + } + + public FeedbackRoundAnswers publishAnswers(Long roundId) { + return withStudents(feedbackService.publishAnswers(roundId)); + } + + private FeedbackRoundAnswers withStudents(FeedbackRoundDetail detail) { + Map students = memberService.getMapByIds( + detail.feedbacks().stream().map(Feedback::createdBy).toList()); + + return new FeedbackRoundAnswers( + detail.summary(), + detail.feedbacks().stream() + .map(feedback -> new FeedbackWithStudent(feedback, students.get(feedback.createdBy()))) + .toList() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java new file mode 100644 index 00000000..b01e7bcd --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java @@ -0,0 +1,40 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase; + +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.member.domain.member.service.MemberService; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.FeedbackService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 관리자 질문 목록. 검색어가 질문 내용과 작성자 이름 양쪽에 걸리는데 이름은 member 도메인에 있어 + * welfare가 조인할 수 없다. 그래서 ID를 징검다리로 쓴다 — + * member에서 이름으로 회원 ID를 먼저 찾아 welfare에 넘기고(`created_by IN (...)`), + * 페이지에 뜬 질문의 작성자만 다시 이름으로 바꾼다. member 모듈은 건드리지 않는다. + */ +@Component +@RequiredArgsConstructor +public class AdminFeedbackSearchUseCase { + + private final FeedbackService feedbackService; + private final MemberService memberService; + + public PageResult search(Long roundId, String keyword, PageOffset pageOffset) { + String searchKeyword = keyword == null || keyword.isBlank() ? null : keyword; + List matchedMemberIds = searchKeyword == null + ? List.of() + : memberService.searchIdsByKeyword(searchKeyword); + + PageResult result = + feedbackService.searchQuestions(roundId, searchKeyword, matchedMemberIds, pageOffset); + Map students = memberService.getMapByIds( + result.content().stream().map(Feedback::createdBy).toList()); + + return result.map(feedback -> new FeedbackWithStudent(feedback, students.get(feedback.createdBy()))); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackRoundAnswers.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackRoundAnswers.java new file mode 100644 index 00000000..fb04cb42 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackRoundAnswers.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; + +// 회차 현황과 그 회차의 질문·답변 전체에 작성자를 붙인 결과. +public record FeedbackRoundAnswers(FeedbackRoundSummary summary, List feedbacks) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackWithStudent.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackWithStudent.java new file mode 100644 index 00000000..c32e31b1 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackWithStudent.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase; + +import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; + +// 질문에 작성자를 붙인 결과. DTO 변환은 Controller가 한다. +// student는 탈퇴(소프트 삭제)한 회원의 질문이면 null일 수 있다. +public record FeedbackWithStudent(Feedback feedback, Member student) { +} From 8127a1f6b28e45e33b3f8805db5688c449fa5d81 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: Wed, 28 Oct 2026 17:08:22 +0900 Subject: [PATCH 07/19] =?UTF-8?q?fix:=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=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=96=B4=EC=9D=98=20LIKE=20=EC=99=80=EC=9D=BC=EB=93=9C?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EB=A5=BC=20=EC=9D=B4=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/db/welfare/FeedbackJpaRepository.java | 7 ++++--- .../kookmin/stream/db/welfare/FeedbackRepositoryImpl.java | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java index efaf28fd..901263be 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java @@ -36,12 +36,13 @@ SELECT COUNT(f) FROM FeedbackJpaEntity f List findAllByYearAndRound(@Param("year") int year, @Param("round") int round); // createdBy는 welfare 자기 테이블의 숫자 컬럼이라 member 테이블과 조인하지 않는다. - // 이름으로 찾은 회원 ID를 호출부가 넘겨주고 여기서는 IN으로만 비교한다 + // 이름으로 찾은 회원 ID를 호출부가 넘겨주고 여기서는 IN으로만 비교한다. + // keyword는 LikePatterns.escape로 이스케이프한 검색어다. 쿼리의 ESCAPE 문자는 LikePatterns.ESCAPE와 같아야 한다 @Query(value = """ SELECT f FROM FeedbackJpaEntity f WHERE f.isDeleted = false AND f.year = :year AND f.round = :round AND (:keyword IS NULL - OR f.question LIKE CONCAT('%', :keyword, '%') + OR f.question LIKE CONCAT('%', :keyword, '%') ESCAPE '!' OR f.createdBy IN :memberIds) ORDER BY f.questionedAt DESC, f.id DESC """, @@ -49,7 +50,7 @@ OR f.question LIKE CONCAT('%', :keyword, '%') SELECT COUNT(f) FROM FeedbackJpaEntity f WHERE f.isDeleted = false AND f.year = :year AND f.round = :round AND (:keyword IS NULL - OR f.question LIKE CONCAT('%', :keyword, '%') + OR f.question LIKE CONCAT('%', :keyword, '%') ESCAPE '!' OR f.createdBy IN :memberIds) """) Page searchByKeyword( diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java index b018749d..19bcf76b 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java @@ -4,6 +4,7 @@ import java.util.Optional; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.db.common.LikePatterns; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackCount; import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRepository; @@ -49,7 +50,7 @@ public PageResult searchByKeyword( Page result = feedbackJpaRepository.searchByKeyword( year, round, - keyword, + LikePatterns.escape(keyword), memberIds.isEmpty() ? NO_MEMBER_IDS : memberIds, PageRequest.of(pageOffset.page(), pageOffset.size()) ); From 018eda693e27f8865ac8a39df28f2694f2a98db9 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: Wed, 28 Oct 2026 17:10:31 +0900 Subject: [PATCH 08/19] =?UTF-8?q?refactor:=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=C2=B7=ED=9A=8C=EC=B0=A8?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=84=20record=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/AdminFeedbackResponse.java | 14 +++---- .../response/FeedbackAnswerResponse.java | 10 ++--- .../response/FeedbackRoundDetailResponse.java | 12 +++--- .../response/FeedbackRoundResponse.java | 12 +++--- .../usecase/AdminFeedbackAnswerUseCase.java | 4 +- .../usecase/AdminFeedbackSearchUseCase.java | 4 +- .../feedback/response/FeedbackResponse.java | 14 +++---- .../domain/feedback/domain/Feedback.java | 40 +++++++++++-------- .../domain/feedback/domain/FeedbackRound.java | 34 ++++++++++------ .../feedback/domain/FeedbackRoundDetail.java | 2 +- .../feedback/domain/FeedbackRoundStatus.java | 4 +- .../service/impl/FeedbackServiceImpl.java | 39 +++++++++--------- .../stream/db/welfare/FeedbackJpaEntity.java | 18 ++++----- .../db/welfare/FeedbackRoundJpaEntity.java | 12 +++--- 14 files changed, 119 insertions(+), 100 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java index 1391b3ab..788344af 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java @@ -22,14 +22,14 @@ public static AdminFeedbackResponse from(FeedbackWithStudent feedbackWithStudent Member student = feedbackWithStudent.student(); return new AdminFeedbackResponse( - feedback.id(), - feedback.year(), - feedback.round(), - feedback.question(), + feedback.getId(), + feedback.getYear(), + feedback.getRound(), + feedback.getQuestion(), student == null ? null : student.getName(), - feedback.questionedAt(), - feedback.answer(), - feedback.answeredAt() + feedback.getQuestionedAt(), + feedback.getAnswer(), + feedback.getAnsweredAt() ); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java index b0788c35..82bacf39 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java @@ -20,12 +20,12 @@ public static FeedbackAnswerResponse from(FeedbackWithStudent feedbackWithStuden Member student = feedbackWithStudent.student(); return new FeedbackAnswerResponse( - feedback.id(), - feedback.question(), + feedback.getId(), + feedback.getQuestion(), student == null ? null : student.getName(), - feedback.questionedAt(), - feedback.answer(), - feedback.answeredAt() + feedback.getQuestionedAt(), + feedback.getAnswer(), + feedback.getAnsweredAt() ); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundDetailResponse.java index 79e2a5da..516d681d 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundDetailResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundDetailResponse.java @@ -26,15 +26,15 @@ public static FeedbackRoundDetailResponse from(FeedbackRoundAnswers roundAnswers FeedbackRound round = summary.round(); return new FeedbackRoundDetailResponse( - round.id(), - round.year(), - round.round(), - round.opensAt(), - round.closesAt(), + round.getId(), + round.getYear(), + round.getRound(), + round.getOpensAt(), + round.getClosesAt(), summary.status(), summary.questionCount(), summary.answerCount(), - round.answersPublishedAt(), + round.getAnswersPublishedAt(), roundAnswers.feedbacks().stream().map(FeedbackAnswerResponse::from).toList() ); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundResponse.java index a81d8a57..62ec5bb8 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackRoundResponse.java @@ -21,15 +21,15 @@ public static FeedbackRoundResponse from(FeedbackRoundSummary summary) { FeedbackRound round = summary.round(); return new FeedbackRoundResponse( - round.id(), - round.year(), - round.round(), - round.opensAt(), - round.closesAt(), + round.getId(), + round.getYear(), + round.getRound(), + round.getOpensAt(), + round.getClosesAt(), summary.status(), summary.questionCount(), summary.answerCount(), - round.answersPublishedAt() + round.getAnswersPublishedAt() ); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java index b987fc85..785b5386 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java @@ -33,12 +33,12 @@ public FeedbackRoundAnswers publishAnswers(Long roundId) { private FeedbackRoundAnswers withStudents(FeedbackRoundDetail detail) { Map students = memberService.getMapByIds( - detail.feedbacks().stream().map(Feedback::createdBy).toList()); + detail.feedbacks().stream().map(Feedback::getCreatedBy).toList()); return new FeedbackRoundAnswers( detail.summary(), detail.feedbacks().stream() - .map(feedback -> new FeedbackWithStudent(feedback, students.get(feedback.createdBy()))) + .map(feedback -> new FeedbackWithStudent(feedback, students.get(feedback.getCreatedBy()))) .toList() ); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java index b01e7bcd..9c27f0cb 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java @@ -33,8 +33,8 @@ public PageResult search(Long roundId, String keyword, Page PageResult result = feedbackService.searchQuestions(roundId, searchKeyword, matchedMemberIds, pageOffset); Map students = memberService.getMapByIds( - result.content().stream().map(Feedback::createdBy).toList()); + result.content().stream().map(Feedback::getCreatedBy).toList()); - return result.map(feedback -> new FeedbackWithStudent(feedback, students.get(feedback.createdBy()))); + return result.map(feedback -> new FeedbackWithStudent(feedback, students.get(feedback.getCreatedBy()))); } } 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 index b5efd301..b1ff7a36 100644 --- 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 @@ -14,13 +14,13 @@ public record FeedbackResponse( ) { public static FeedbackResponse from(Feedback feedback) { return new FeedbackResponse( - feedback.id(), - feedback.year(), - feedback.round(), - feedback.question(), - feedback.questionedAt(), - feedback.answeredAt(), - feedback.answer() + feedback.getId(), + feedback.getYear(), + feedback.getRound(), + feedback.getQuestion(), + feedback.getQuestionedAt(), + feedback.getAnsweredAt(), + feedback.getAnswer() ); } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java index c0c33bc4..635d1f5b 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/Feedback.java @@ -1,18 +1,25 @@ 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; -public record Feedback( - Long id, - int year, - int round, - String question, - LocalDateTime questionedAt, - String answer, - LocalDateTime answeredAt, - Long createdBy, - Long answeredBy -) { +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Feedback { + + private Long id; + private int year; + private int round; + private String question; + private LocalDateTime questionedAt; + private String answer; + private LocalDateTime answeredAt; + private Long createdBy; + private Long answeredBy; /** * 회차는 서버가 자동 배정하므로 여기서 받는다(아직 답변 없음). @@ -42,11 +49,10 @@ public boolean isAnswered() { return answer != null; } - /** - * 답변을 단다. 이미 답변이 있으면 덮어쓴다 — 답변 수정이 같은 경로로 들어오기 때문이다. - * record라 값을 바꾸지 않고 새 인스턴스를 돌려준다. - */ - public Feedback withAnswer(String answer, Long answeredBy, LocalDateTime answeredAt) { - return new Feedback(id, year, round, question, questionedAt, answer, answeredAt, createdBy, answeredBy); + // 답변을 작성한다. 이미 답변이 있으면 덮어쓴다 — 답변 수정이 같은 경로로 들어오기 때문이다 + public void writeAnswer(String answer, Long answeredBy, LocalDateTime answeredAt) { + this.answer = answer; + this.answeredBy = answeredBy; + this.answeredAt = answeredAt; } } 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 index 6a66cf52..03e16cc9 100644 --- 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 @@ -4,23 +4,30 @@ import java.time.LocalDateTime; import java.time.LocalTime; import kr.ac.kookmin.stream.common.BusinessException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; /** * 관리자가 여는 열린피드백 접수 회차. 오픈/마감 시각과 함께 연도별로 관리한다(연도별로 1차부터 다시 시작). * answersPublishedAt은 그 회차의 답변을 학생에게 공개한 시각으로, null이면 아직 임시저장 단계다. */ -public record FeedbackRound( - Long id, - int year, - int round, - LocalDateTime opensAt, - LocalDateTime closesAt, - LocalDateTime answersPublishedAt -) { +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class FeedbackRound { private static final LocalTime OPENS_TIME = LocalTime.MIN; private static final LocalTime CLOSES_TIME = LocalTime.of(23, 59, 59); + private Long id; + private int year; + private int round; + private LocalDateTime opensAt; + private LocalDateTime closesAt; + private LocalDateTime answersPublishedAt; + /** * 화면이 날짜만 받으므로 시각은 여기서 채운다 — 시작일 00:00:00 ~ 마감일 23:59:59. * 연도는 시작일 기준이고, 회차 번호는 그 연도의 다음 번호를 호출부가 계산해 넘긴다. @@ -48,11 +55,14 @@ public boolean isAnswersPublished() { return answersPublishedAt != null; } - // 이미 공개한 회차는 공개 시각을 그대로 둔다 — 등록하기를 다시 눌러도 결과가 같게(멱등) 하기 위함이다. - public FeedbackRound publishAnswers(LocalDateTime publishedAt) { + /** + * 그 회차의 답변을 학생에게 공개한다. 이미 공개한 회차는 공개 시각을 그대로 둔다 — + * 등록하기를 다시 눌러도 결과가 같게(멱등) 하기 위함이다. + */ + public void publishAnswers(LocalDateTime publishedAt) { if (isAnswersPublished()) { - return this; + return; } - return new FeedbackRound(id, year, round, opensAt, closesAt, publishedAt); + this.answersPublishedAt = publishedAt; } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundDetail.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundDetail.java index abac6675..38b25553 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundDetail.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundDetail.java @@ -11,7 +11,7 @@ public record FeedbackRoundDetail(FeedbackRoundSummary summary, List f public static FeedbackRoundDetail of(FeedbackRound round, List feedbacks, LocalDateTime now) { long answerCount = feedbacks.stream().filter(Feedback::isAnswered).count(); - FeedbackCount count = FeedbackCount.of(round.year(), round.round(), feedbacks.size(), answerCount); + FeedbackCount count = FeedbackCount.of(round.getYear(), round.getRound(), feedbacks.size(), answerCount); return new FeedbackRoundDetail(FeedbackRoundSummary.of(round, count, now), feedbacks); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundStatus.java index 057b0e35..ca15f48e 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundStatus.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundStatus.java @@ -18,10 +18,10 @@ public static FeedbackRoundStatus of(FeedbackRound round, LocalDateTime now) { if (round.isAnswersPublished()) { return CLOSED; } - if (now.isBefore(round.opensAt())) { + if (now.isBefore(round.getOpensAt())) { return SCHEDULED; } - if (now.isAfter(round.closesAt())) { + if (now.isAfter(round.getClosesAt())) { return ANSWERING; } return OPEN; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java index 25c0eb58..2ce23225 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java @@ -58,7 +58,7 @@ public Feedback create(Long memberId, String question) { .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_OPEN)); return feedbackRepository.save( - Feedback.create(openRound.year(), openRound.round(), question, memberId, now)); + Feedback.create(openRound.getYear(), openRound.getRound(), question, memberId, now)); } @Override @@ -82,12 +82,12 @@ public FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn) { // 학생 앱의 질문 등록은 접수 기간인 회차 "하나"를 찾아 배정하므로, 기간이 겹치면 질문 등록이 깨진다. // 범위 겹침은 유니크 제약으로 막을 수 없어 등록 전에 확인한다(조회-후-삽입이라 동시 등록 구멍은 남는다) - if (feedbackRoundRepository.existsOverlapping(round.opensAt(), round.closesAt())) { + if (feedbackRoundRepository.existsOverlapping(round.getOpensAt(), round.getClosesAt())) { throw new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_OVERLAPPED); } FeedbackRound saved = feedbackRoundRepository.save(round); - return FeedbackRoundSummary.of(saved, FeedbackCount.empty(saved.year(), saved.round()), now); + return FeedbackRoundSummary.of(saved, FeedbackCount.empty(saved.getYear(), saved.getRound()), now); } @Override @@ -133,7 +133,7 @@ public PageResult searchQuestions( // 진행 중 회차가 없는데 회차를 지정하지도 않았으면 보여줄 질문이 없다 return round .map(target -> feedbackRepository.searchByKeyword( - target.year(), target.round(), keyword, memberIds, pageOffset)) + target.getYear(), target.getRound(), keyword, memberIds, pageOffset)) .orElseGet(() -> PageResult.empty(pageOffset.page(), pageOffset.size())); } @@ -144,8 +144,8 @@ public FeedbackRoundDetail saveAnswers(Long roundId, List FeedbackRound round = getRoundById(roundId); Map roundFeedbacks = feedbackRepository - .findAllByYearAndRound(round.year(), round.round()).stream() - .collect(Collectors.toMap(Feedback::id, Function.identity())); + .findAllByYearAndRound(round.getYear(), round.getRound()).stream() + .collect(Collectors.toMap(Feedback::getId, Function.identity())); // 보낸 것만 반영한다 — commands에 없는 질문의 기존 답변은 건드리지 않는다 List answered = commands.stream() @@ -161,13 +161,14 @@ public FeedbackRoundDetail saveAnswers(Long roundId, List public FeedbackRoundDetail publishAnswers(Long roundId) { LocalDateTime now = LocalDateTime.now(DateUtil.KST); FeedbackRound round = getRoundById(roundId); - List feedbacks = feedbackRepository.findAllByYearAndRound(round.year(), round.round()); + List feedbacks = feedbackRepository.findAllByYearAndRound(round.getYear(), round.getRound()); if (feedbacks.stream().noneMatch(Feedback::isAnswered)) { throw new BusinessException(FeedbackErrorCode.FEEDBACK_ANSWER_NOT_FOUND); } - FeedbackRound published = feedbackRoundRepository.save(round.publishAnswers(now)); + round.publishAnswers(now); + FeedbackRound published = feedbackRoundRepository.save(round); return FeedbackRoundDetail.of(published, feedbacks, now); } @@ -178,31 +179,33 @@ private FeedbackRound getRoundById(Long roundId) { private FeedbackRoundDetail getDetailOf(FeedbackRound round, LocalDateTime now) { return FeedbackRoundDetail.of( - round, feedbackRepository.findAllByYearAndRound(round.year(), round.round()), now); + round, feedbackRepository.findAllByYearAndRound(round.getYear(), round.getRound()), now); } private Feedback answer( Map roundFeedbacks, FeedbackAnswerCommand command, Long answeredBy, LocalDateTime now ) { Feedback feedback = roundFeedbacks.get(command.feedbackId()); - if (feedback != null) { - return feedback.withAnswer(command.answer(), answeredBy, now); + if (feedback == null) { + // 다른 회차의 질문인지, 아예 없는 질문인지 나눠서 알려준다 + if (feedbackRepository.findById(command.feedbackId()).isEmpty()) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_FOUND); + } + throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_IN_ROUND); } - // 다른 회차의 질문인지, 아예 없는 질문인지 나눠서 알려준다 - if (feedbackRepository.findById(command.feedbackId()).isEmpty()) { - throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_FOUND); - } - throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_IN_ROUND); + feedback.writeAnswer(command.answer(), answeredBy, now); + return feedback; } private FeedbackCount getCountOf(FeedbackRound round) { - return getCountOf(getCountMapOf(round.year()), round); + return getCountOf(getCountMapOf(round.getYear()), round); } private FeedbackCount getCountOf(Map counts, FeedbackRound round) { return counts.getOrDefault( - FeedbackPeriod.of(round.year(), round.round()), FeedbackCount.empty(round.year(), round.round())); + FeedbackPeriod.of(round.getYear(), round.getRound()), + FeedbackCount.empty(round.getYear(), round.getRound())); } private Map getCountMapOf(Integer year) { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaEntity.java index b9ebd879..1981784e 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaEntity.java @@ -50,15 +50,15 @@ public class FeedbackJpaEntity extends BaseSoftDeleteEntity { private Long answeredBy; private FeedbackJpaEntity(Feedback feedback) { - 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(); + this.id = feedback.getId(); + this.year = feedback.getYear(); + this.round = feedback.getRound(); + this.question = feedback.getQuestion(); + this.questionedAt = feedback.getQuestionedAt(); + this.answer = feedback.getAnswer(); + this.answeredAt = feedback.getAnsweredAt(); + this.createdBy = feedback.getCreatedBy(); + this.answeredBy = feedback.getAnsweredBy(); } public static FeedbackJpaEntity from(Feedback feedback) { 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 index 6c8e2d79..194f8382 100644 --- 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 @@ -43,12 +43,12 @@ public class FeedbackRoundJpaEntity extends BaseTimeEntity { private LocalDateTime answersPublishedAt; private FeedbackRoundJpaEntity(FeedbackRound feedbackRound) { - this.id = feedbackRound.id(); - this.year = feedbackRound.year(); - this.round = feedbackRound.round(); - this.opensAt = feedbackRound.opensAt(); - this.closesAt = feedbackRound.closesAt(); - this.answersPublishedAt = feedbackRound.answersPublishedAt(); + this.id = feedbackRound.getId(); + this.year = feedbackRound.getYear(); + this.round = feedbackRound.getRound(); + this.opensAt = feedbackRound.getOpensAt(); + this.closesAt = feedbackRound.getClosesAt(); + this.answersPublishedAt = feedbackRound.getAnswersPublishedAt(); } public static FeedbackRoundJpaEntity from(FeedbackRound feedbackRound) { From 80de2a1b8c5ff190e95248ae10d1528f441246b6 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: Wed, 28 Oct 2026 17:12:32 +0900 Subject: [PATCH 09/19] =?UTF-8?q?refactor:=20=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=9A=B4=EC=98=81=EC=A7=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20AdminFeedbackService=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminFeedbackRoundController.java | 10 +- .../usecase/AdminFeedbackAnswerUseCase.java | 10 +- .../usecase/AdminFeedbackSearchUseCase.java | 6 +- .../service/AdminFeedbackService.java | 40 ++++ .../feedback/service/FeedbackService.java | 30 +-- .../impl/AdminFeedbackServiceImpl.java | 191 ++++++++++++++++++ .../service/impl/FeedbackServiceImpl.java | 165 --------------- 7 files changed, 247 insertions(+), 205 deletions(-) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/AdminFeedbackService.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java index c97ffa54..cc1a750d 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java @@ -13,7 +13,7 @@ import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundStatus; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; -import kr.ac.kookmin.stream.welfare.domain.feedback.service.FeedbackService; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.AdminFeedbackService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -30,7 +30,7 @@ @RequiredArgsConstructor public class AdminFeedbackRoundController implements AdminFeedbackRoundApi { - private final FeedbackService feedbackService; + private final AdminFeedbackService adminFeedbackService; private final AdminFeedbackAnswerUseCase adminFeedbackAnswerUseCase; @Override @@ -38,7 +38,7 @@ public class AdminFeedbackRoundController implements AdminFeedbackRoundApi { public ApiResponse getCurrentRound(AdminApiUser apiUser) { // 진행 중 회차가 없으면 data를 null로 내려 화면이 "진행 중인 회차 없음"을 그린다 return ApiResponse.success( - feedbackService.getCurrentRound().map(FeedbackRoundResponse::from).orElse(null)); + adminFeedbackService.getCurrentRound().map(FeedbackRoundResponse::from).orElse(null)); } @Override @@ -51,7 +51,7 @@ public ApiResponse> getRounds( ) { FeedbackRoundStatus roundStatus = FeedbackRoundStatus.from(status); PageResult result = - feedbackService.getRounds(year, roundStatus, pageParams.toOffset()); + adminFeedbackService.getRounds(year, roundStatus, pageParams.toOffset()); return ApiResponse.success(PageResponse.from(result, FeedbackRoundResponse::from)); } @@ -62,7 +62,7 @@ public ApiResponse createRound( AdminApiUser apiUser, @Valid @RequestBody FeedbackRoundCreateRequest request ) { - FeedbackRoundSummary created = feedbackService.createRound(request.opensOn(), request.closesOn()); + FeedbackRoundSummary created = adminFeedbackService.createRound(request.opensOn(), request.closesOn()); return ApiResponse.success(FeedbackRoundResponse.from(created)); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java index 785b5386..27591315 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java @@ -7,7 +7,7 @@ import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackAnswerCommand; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundDetail; -import kr.ac.kookmin.stream.welfare.domain.feedback.service.FeedbackService; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.AdminFeedbackService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -16,19 +16,19 @@ @RequiredArgsConstructor public class AdminFeedbackAnswerUseCase { - private final FeedbackService feedbackService; + private final AdminFeedbackService adminFeedbackService; private final MemberService memberService; public FeedbackRoundAnswers getRoundAnswers(Long roundId) { - return withStudents(feedbackService.getRoundDetail(roundId)); + return withStudents(adminFeedbackService.getRoundDetail(roundId)); } public FeedbackRoundAnswers saveAnswers(Long roundId, List commands, Long answeredBy) { - return withStudents(feedbackService.saveAnswers(roundId, commands, answeredBy)); + return withStudents(adminFeedbackService.saveAnswers(roundId, commands, answeredBy)); } public FeedbackRoundAnswers publishAnswers(Long roundId) { - return withStudents(feedbackService.publishAnswers(roundId)); + return withStudents(adminFeedbackService.publishAnswers(roundId)); } private FeedbackRoundAnswers withStudents(FeedbackRoundDetail detail) { diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java index 9c27f0cb..81d0ddea 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java @@ -7,7 +7,7 @@ import kr.ac.kookmin.stream.member.domain.member.domain.Member; import kr.ac.kookmin.stream.member.domain.member.service.MemberService; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; -import kr.ac.kookmin.stream.welfare.domain.feedback.service.FeedbackService; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.AdminFeedbackService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -21,7 +21,7 @@ @RequiredArgsConstructor public class AdminFeedbackSearchUseCase { - private final FeedbackService feedbackService; + private final AdminFeedbackService adminFeedbackService; private final MemberService memberService; public PageResult search(Long roundId, String keyword, PageOffset pageOffset) { @@ -31,7 +31,7 @@ public PageResult search(Long roundId, String keyword, Page : memberService.searchIdsByKeyword(searchKeyword); PageResult result = - feedbackService.searchQuestions(roundId, searchKeyword, matchedMemberIds, pageOffset); + adminFeedbackService.searchQuestions(roundId, searchKeyword, matchedMemberIds, pageOffset); Map students = memberService.getMapByIds( result.content().stream().map(Feedback::getCreatedBy).toList()); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/AdminFeedbackService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/AdminFeedbackService.java new file mode 100644 index 00000000..851c81b6 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/AdminFeedbackService.java @@ -0,0 +1,40 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.service; + +import java.time.LocalDate; +import java.util.List; +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.Feedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackAnswerCommand; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundDetail; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundStatus; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; + +/** + * 운영진용 열린피드백 서비스. 회차 등록·조회, 질문 검색, 답변 임시저장·공개를 맡는다. + * 학생 앱의 질문 조회·작성은 {@link FeedbackService}가 맡는다. + */ +public interface AdminFeedbackService { + + // 접수 기간만 받아 회차를 연다. 연도는 시작일 기준, 회차 번호는 그 연도의 최대 + 1로 서버가 배정한다 + FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn); + + // 답변을 아직 공개하지 않은 가장 최근 회차. 그런 회차가 없으면 비어 있다 + Optional getCurrentRound(); + + // year/status가 null이면 그 조건은 필터하지 않는다 + PageResult getRounds(Integer year, FeedbackRoundStatus status, PageOffset pageOffset); + + FeedbackRoundDetail getRoundDetail(Long roundId); + + // roundId가 null이면 진행 중 회차를 대상으로 한다. 진행 중 회차도 없으면 빈 페이지 + PageResult searchQuestions( + Long roundId, String keyword, List memberIds, PageOffset pageOffset); + + // 답변 임시저장. 보낸 것만 반영하고 기존 답변은 덮어쓴다. 여기서 저장한 답변은 아직 학생에게 보이지 않는다 + FeedbackRoundDetail saveAnswers(Long roundId, List commands, Long answeredBy); + + // 그 회차에 작성된 답변을 학생에게 공개한다. 이미 공개한 회차면 공개 시각을 바꾸지 않는다 + FeedbackRoundDetail publishAnswers(Long roundId); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/FeedbackService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/FeedbackService.java index 4a62c168..c33ec77e 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/FeedbackService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/FeedbackService.java @@ -1,17 +1,14 @@ package kr.ac.kookmin.stream.welfare.domain.feedback.service; -import java.time.LocalDate; import java.util.List; -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.Feedback; -import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackAnswerCommand; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackPeriod; -import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundDetail; -import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundStatus; -import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; +/** + * 학생 앱의 열린피드백 질문 조회·작성. 운영진의 회차 관리와 답변 작성·공개는 {@link AdminFeedbackService}가 맡는다. + */ public interface FeedbackService { Feedback getById(Long id); @@ -26,25 +23,4 @@ public interface FeedbackService { // 해당 연도에 실제로 존재하는 회차 번호(오름차순) List getRoundsOf(int year); - - // 접수 기간만 받아 회차를 연다. 연도는 시작일 기준, 회차 번호는 그 연도의 최대 + 1로 서버가 배정한다 - FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn); - - // 답변을 아직 공개하지 않은 가장 최근 회차. 그런 회차가 없으면 비어 있다 - Optional getCurrentRound(); - - // year/status가 null이면 그 조건은 필터하지 않는다 - PageResult getRounds(Integer year, FeedbackRoundStatus status, PageOffset pageOffset); - - FeedbackRoundDetail getRoundDetail(Long roundId); - - // roundId가 null이면 진행 중 회차를 대상으로 한다. 진행 중 회차도 없으면 빈 페이지 - PageResult searchQuestions( - Long roundId, String keyword, List memberIds, PageOffset pageOffset); - - // 답변 임시저장. 보낸 것만 반영하고 기존 답변은 덮어쓴다. 여기서 저장한 답변은 아직 학생에게 보이지 않는다 - FeedbackRoundDetail saveAnswers(Long roundId, List commands, Long answeredBy); - - // 그 회차에 작성된 답변을 학생에게 공개한다. 이미 공개한 회차면 공개 시각을 바꾸지 않는다 - FeedbackRoundDetail publishAnswers(Long roundId); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java new file mode 100644 index 00000000..b205588f --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java @@ -0,0 +1,191 @@ +package kr.ac.kookmin.stream.welfare.domain.feedback.service.impl; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackAnswerCommand; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackCount; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackErrorCode; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackPeriod; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundDetail; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundStatus; +import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; +import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRepository; +import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRoundRepository; +import kr.ac.kookmin.stream.welfare.domain.feedback.service.AdminFeedbackService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class AdminFeedbackServiceImpl implements AdminFeedbackService { + + private static final int FIRST_ROUND = 1; + + private final FeedbackRepository feedbackRepository; + private final FeedbackRoundRepository feedbackRoundRepository; + + @Override + @Transactional + public FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn) { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + int nextRound = feedbackRoundRepository.findMaxRoundOf(opensOn.getYear()) + .map(maxRound -> maxRound + 1) + .orElse(FIRST_ROUND); + FeedbackRound round = FeedbackRound.create(nextRound, opensOn, closesOn); + + // 학생 앱의 질문 등록은 접수 기간인 회차 "하나"를 찾아 배정하므로, 기간이 겹치면 질문 등록이 깨진다. + // 범위 겹침은 유니크 제약으로 막을 수 없어 등록 전에 확인한다(조회-후-삽입이라 동시 등록 구멍은 남는다) + if (feedbackRoundRepository.existsOverlapping(round.getOpensAt(), round.getClosesAt())) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_OVERLAPPED); + } + + FeedbackRound saved = feedbackRoundRepository.save(round); + return FeedbackRoundSummary.of(saved, FeedbackCount.empty(saved.getYear(), saved.getRound()), now); + } + + @Override + public Optional getCurrentRound() { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + return feedbackRoundRepository.findLatestUnpublished() + .map(round -> FeedbackRoundSummary.of(round, getCountOf(round), now)); + } + + /** + * 상태는 저장하지 않는 파생값이라 SQL의 WHERE에 걸 수 없고, 따라서 LIMIT도 걸 수 없다. + * 연도까지만 DB로 좁히고 상태 필터와 페이징은 메모리에서 한다 — 회차는 연 5~10개라 전체 연도를 + * 다 읽어도 수십 건이다. 회차가 수백 개로 늘면 상태를 질의할 수 있는 형태로 바꾸는 쪽을 다시 보자. + */ + @Override + public PageResult getRounds( + Integer year, FeedbackRoundStatus status, PageOffset pageOffset + ) { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + Map counts = getCountMapOf(year); + + List summaries = feedbackRoundRepository.findAllByYear(year).stream() + .map(round -> FeedbackRoundSummary.of(round, getCountOf(counts, round), now)) + .filter(summary -> status == null || summary.status() == status) + .toList(); + + return paginate(summaries, pageOffset); + } + + @Override + public FeedbackRoundDetail getRoundDetail(Long roundId) { + return getDetailOf(getRoundById(roundId), LocalDateTime.now(DateUtil.KST)); + } + + @Override + public PageResult searchQuestions( + Long roundId, String keyword, List memberIds, PageOffset pageOffset + ) { + Optional round = roundId == null + ? feedbackRoundRepository.findLatestUnpublished() + : Optional.of(getRoundById(roundId)); + + // 진행 중 회차가 없는데 회차를 지정하지도 않았으면 보여줄 질문이 없다 + return round + .map(target -> feedbackRepository.searchByKeyword( + target.getYear(), target.getRound(), keyword, memberIds, pageOffset)) + .orElseGet(() -> PageResult.empty(pageOffset.page(), pageOffset.size())); + } + + @Override + @Transactional + public FeedbackRoundDetail saveAnswers(Long roundId, List commands, Long answeredBy) { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + FeedbackRound round = getRoundById(roundId); + + Map roundFeedbacks = feedbackRepository + .findAllByYearAndRound(round.getYear(), round.getRound()).stream() + .collect(Collectors.toMap(Feedback::getId, Function.identity())); + + // 보낸 것만 반영한다 — commands에 없는 질문의 기존 답변은 건드리지 않는다 + List answered = commands.stream() + .map(command -> answer(roundFeedbacks, command, answeredBy, now)) + .toList(); + feedbackRepository.saveAll(answered); + + return getDetailOf(round, now); + } + + @Override + @Transactional + public FeedbackRoundDetail publishAnswers(Long roundId) { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + FeedbackRound round = getRoundById(roundId); + List feedbacks = feedbackRepository.findAllByYearAndRound(round.getYear(), round.getRound()); + + if (feedbacks.stream().noneMatch(Feedback::isAnswered)) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_ANSWER_NOT_FOUND); + } + + round.publishAnswers(now); + FeedbackRound published = feedbackRoundRepository.save(round); + return FeedbackRoundDetail.of(published, feedbacks, now); + } + + private FeedbackRound getRoundById(Long roundId) { + return feedbackRoundRepository.findById(roundId) + .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_NOT_FOUND)); + } + + private FeedbackRoundDetail getDetailOf(FeedbackRound round, LocalDateTime now) { + return FeedbackRoundDetail.of( + round, feedbackRepository.findAllByYearAndRound(round.getYear(), round.getRound()), now); + } + + private Feedback answer( + Map roundFeedbacks, FeedbackAnswerCommand command, Long answeredBy, LocalDateTime now + ) { + Feedback feedback = roundFeedbacks.get(command.feedbackId()); + if (feedback == null) { + // 다른 회차의 질문인지, 아예 없는 질문인지 나눠서 알려준다 + if (feedbackRepository.findById(command.feedbackId()).isEmpty()) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_FOUND); + } + throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_IN_ROUND); + } + + feedback.writeAnswer(command.answer(), answeredBy, now); + return feedback; + } + + private FeedbackCount getCountOf(FeedbackRound round) { + return getCountOf(getCountMapOf(round.getYear()), round); + } + + private FeedbackCount getCountOf(Map counts, FeedbackRound round) { + return counts.getOrDefault( + FeedbackPeriod.of(round.getYear(), round.getRound()), + FeedbackCount.empty(round.getYear(), round.getRound())); + } + + private Map getCountMapOf(Integer year) { + return feedbackRepository.findCountsByYear(year).stream() + .collect(Collectors.toMap( + count -> FeedbackPeriod.of(count.year(), count.round()), Function.identity())); + } + + private static PageResult paginate( + List summaries, PageOffset pageOffset + ) { + int fromIndex = Math.min(pageOffset.page() * pageOffset.size(), summaries.size()); + int toIndex = Math.min(fromIndex + pageOffset.size(), summaries.size()); + + return PageResult.of( + summaries.subList(fromIndex, toIndex), pageOffset.page(), pageOffset.size(), summaries.size()); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java index 2ce23225..454424d4 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/FeedbackServiceImpl.java @@ -1,25 +1,15 @@ package kr.ac.kookmin.stream.welfare.domain.feedback.service.impl; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.DateUtil; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; -import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackAnswerCommand; -import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackCount; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackErrorCode; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackPeriod; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound; -import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundDetail; -import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundStatus; -import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRepository; import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRoundRepository; import kr.ac.kookmin.stream.welfare.domain.feedback.service.FeedbackService; @@ -31,8 +21,6 @@ @RequiredArgsConstructor class FeedbackServiceImpl implements FeedbackService { - private static final int FIRST_ROUND = 1; - private final FeedbackRepository feedbackRepository; private final FeedbackRoundRepository feedbackRoundRepository; @@ -70,157 +58,4 @@ public List getAvailableYears() { public List getRoundsOf(int year) { return feedbackRoundRepository.findRoundsByYear(year); } - - @Override - @Transactional - public FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn) { - LocalDateTime now = LocalDateTime.now(DateUtil.KST); - int nextRound = feedbackRoundRepository.findMaxRoundOf(opensOn.getYear()) - .map(maxRound -> maxRound + 1) - .orElse(FIRST_ROUND); - FeedbackRound round = FeedbackRound.create(nextRound, opensOn, closesOn); - - // 학생 앱의 질문 등록은 접수 기간인 회차 "하나"를 찾아 배정하므로, 기간이 겹치면 질문 등록이 깨진다. - // 범위 겹침은 유니크 제약으로 막을 수 없어 등록 전에 확인한다(조회-후-삽입이라 동시 등록 구멍은 남는다) - if (feedbackRoundRepository.existsOverlapping(round.getOpensAt(), round.getClosesAt())) { - throw new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_OVERLAPPED); - } - - FeedbackRound saved = feedbackRoundRepository.save(round); - return FeedbackRoundSummary.of(saved, FeedbackCount.empty(saved.getYear(), saved.getRound()), now); - } - - @Override - public Optional getCurrentRound() { - LocalDateTime now = LocalDateTime.now(DateUtil.KST); - return feedbackRoundRepository.findLatestUnpublished() - .map(round -> FeedbackRoundSummary.of(round, getCountOf(round), now)); - } - - /** - * 상태는 저장하지 않는 파생값이라 SQL의 WHERE에 걸 수 없고, 따라서 LIMIT도 걸 수 없다. - * 연도까지만 DB로 좁히고 상태 필터와 페이징은 메모리에서 한다 — 회차는 연 5~10개라 전체 연도를 - * 다 읽어도 수십 건이다. 회차가 수백 개로 늘면 상태를 질의할 수 있는 형태로 바꾸는 쪽을 다시 보자. - */ - @Override - public PageResult getRounds( - Integer year, FeedbackRoundStatus status, PageOffset pageOffset - ) { - LocalDateTime now = LocalDateTime.now(DateUtil.KST); - Map counts = getCountMapOf(year); - - List summaries = feedbackRoundRepository.findAllByYear(year).stream() - .map(round -> FeedbackRoundSummary.of(round, getCountOf(counts, round), now)) - .filter(summary -> status == null || summary.status() == status) - .toList(); - - return paginate(summaries, pageOffset); - } - - @Override - public FeedbackRoundDetail getRoundDetail(Long roundId) { - return getDetailOf(getRoundById(roundId), LocalDateTime.now(DateUtil.KST)); - } - - @Override - public PageResult searchQuestions( - Long roundId, String keyword, List memberIds, PageOffset pageOffset - ) { - Optional round = roundId == null - ? feedbackRoundRepository.findLatestUnpublished() - : Optional.of(getRoundById(roundId)); - - // 진행 중 회차가 없는데 회차를 지정하지도 않았으면 보여줄 질문이 없다 - return round - .map(target -> feedbackRepository.searchByKeyword( - target.getYear(), target.getRound(), keyword, memberIds, pageOffset)) - .orElseGet(() -> PageResult.empty(pageOffset.page(), pageOffset.size())); - } - - @Override - @Transactional - public FeedbackRoundDetail saveAnswers(Long roundId, List commands, Long answeredBy) { - LocalDateTime now = LocalDateTime.now(DateUtil.KST); - FeedbackRound round = getRoundById(roundId); - - Map roundFeedbacks = feedbackRepository - .findAllByYearAndRound(round.getYear(), round.getRound()).stream() - .collect(Collectors.toMap(Feedback::getId, Function.identity())); - - // 보낸 것만 반영한다 — commands에 없는 질문의 기존 답변은 건드리지 않는다 - List answered = commands.stream() - .map(command -> answer(roundFeedbacks, command, answeredBy, now)) - .toList(); - feedbackRepository.saveAll(answered); - - return getDetailOf(round, now); - } - - @Override - @Transactional - public FeedbackRoundDetail publishAnswers(Long roundId) { - LocalDateTime now = LocalDateTime.now(DateUtil.KST); - FeedbackRound round = getRoundById(roundId); - List feedbacks = feedbackRepository.findAllByYearAndRound(round.getYear(), round.getRound()); - - if (feedbacks.stream().noneMatch(Feedback::isAnswered)) { - throw new BusinessException(FeedbackErrorCode.FEEDBACK_ANSWER_NOT_FOUND); - } - - round.publishAnswers(now); - FeedbackRound published = feedbackRoundRepository.save(round); - return FeedbackRoundDetail.of(published, feedbacks, now); - } - - private FeedbackRound getRoundById(Long roundId) { - return feedbackRoundRepository.findById(roundId) - .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_NOT_FOUND)); - } - - private FeedbackRoundDetail getDetailOf(FeedbackRound round, LocalDateTime now) { - return FeedbackRoundDetail.of( - round, feedbackRepository.findAllByYearAndRound(round.getYear(), round.getRound()), now); - } - - private Feedback answer( - Map roundFeedbacks, FeedbackAnswerCommand command, Long answeredBy, LocalDateTime now - ) { - Feedback feedback = roundFeedbacks.get(command.feedbackId()); - if (feedback == null) { - // 다른 회차의 질문인지, 아예 없는 질문인지 나눠서 알려준다 - if (feedbackRepository.findById(command.feedbackId()).isEmpty()) { - throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_FOUND); - } - throw new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_IN_ROUND); - } - - feedback.writeAnswer(command.answer(), answeredBy, now); - return feedback; - } - - private FeedbackCount getCountOf(FeedbackRound round) { - return getCountOf(getCountMapOf(round.getYear()), round); - } - - private FeedbackCount getCountOf(Map counts, FeedbackRound round) { - return counts.getOrDefault( - FeedbackPeriod.of(round.getYear(), round.getRound()), - FeedbackCount.empty(round.getYear(), round.getRound())); - } - - private Map getCountMapOf(Integer year) { - return feedbackRepository.findCountsByYear(year).stream() - .collect(Collectors.toMap( - count -> FeedbackPeriod.of(count.year(), count.round()), Function.identity())); - } - - private static PageResult paginate( - List summaries, PageOffset pageOffset - ) { - int fromIndex = Math.min(pageOffset.page() * pageOffset.size(), summaries.size()); - int toIndex = Math.min(fromIndex + pageOffset.size(), summaries.size()); - - return PageResult.of( - summaries.subList(fromIndex, toIndex), pageOffset.page(), pageOffset.size(), summaries.size()); - } } From 197567a11a48a19bc6031dc38a2b4cf40a6e646e 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: Wed, 28 Oct 2026 17:13:24 +0900 Subject: [PATCH 10/19] =?UTF-8?q?refactor:=20=EC=97=B4=EB=A6=B0=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=9A=B4=EC=98=81=EC=A7=84=20API=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A5=BC=20/v1/admin/feedback=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=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 --- .../api/admin/welfare/feedback/AdminFeedbackController.java | 2 +- .../admin/welfare/feedback/AdminFeedbackRoundController.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java index ef89d347..38697edc 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java @@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/v1/admin/feedbacks") +@RequestMapping("/v1/admin/feedback/questions") @RequiredArgsConstructor public class AdminFeedbackController implements AdminFeedbackApi { diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java index cc1a750d..7298bb8f 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundController.java @@ -26,7 +26,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/v1/admin/feedback-rounds") +@RequestMapping("/v1/admin/feedback/rounds") @RequiredArgsConstructor public class AdminFeedbackRoundController implements AdminFeedbackRoundApi { From 431fb13787fd5d9390adc564e1fe2fa8d1a11f8b 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: Wed, 28 Oct 2026 17:14:55 +0900 Subject: [PATCH 11/19] =?UTF-8?q?fix:=20=EB=8B=B5=EB=B3=80=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=EB=A5=BC=20=EC=A7=88=EB=AC=B8=20=EC=A0=91=EC=88=98?= =?UTF-8?q?=EA=B0=80=20=EB=81=9D=EB=82=9C=20=ED=9A=8C=EC=B0=A8=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=A7=8C=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/feedback/AdminFeedbackRoundApi.java | 8 ++++++-- .../feedback/domain/FeedbackErrorCode.java | 3 ++- .../service/impl/AdminFeedbackServiceImpl.java | 17 +++++++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java index a69a2eff..36a334ff 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java @@ -74,8 +74,12 @@ ApiResponse saveAnswers( /** 답변 공개. 그 회차의 답변을 학생 앱에 한꺼번에 띄운다. */ @Operation(summary = "피드백 답변 공개", - description = "그 회차에 작성된 답변을 학생에게 공개한다. 공개는 회차 단위이고, " + description = "그 회차에 작성된 답변을 학생에게 공개한다. 공개는 회차 단위이고, 질문 접수가 끝난 회차(답변작성중)만 " + + "공개할 수 있다(오픈예정·질문접수중이면 409). " + "이미 공개한 회차를 다시 불러도 공개 시각을 바꾸지 않고 성공으로 응답한다(멱등).") - @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_ANSWER_NOT_FOUND", "FEEDBACK_ROUND_NOT_FOUND"}) + @ApiErrorCode( + type = FeedbackErrorCode.class, + codes = {"FEEDBACK_ANSWER_NOT_FOUND", "FEEDBACK_ROUND_NOT_FOUND", "FEEDBACK_QUESTION_PERIOD_NOT_ENDED"} + ) ApiResponse publishAnswers(AdminApiUser apiUser, Long roundId); } 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 index f9acdc6e..c11b8269 100644 --- 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 @@ -19,7 +19,8 @@ public enum FeedbackErrorCode implements ErrorCode { FEEDBACK_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 피드백입니다."), FEEDBACK_ROUND_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 열린피드백 회차입니다."), FEEDBACK_NOT_OPEN(ErrorStatus.CONFLICT, "현재 열린피드백 질문 접수 기간이 아닙니다."), - FEEDBACK_ROUND_OVERLAPPED(ErrorStatus.CONFLICT, "이미 등록된 회차와 질문 접수 기간이 겹칩니다."); + FEEDBACK_ROUND_OVERLAPPED(ErrorStatus.CONFLICT, "이미 등록된 회차와 질문 접수 기간이 겹칩니다."), + FEEDBACK_QUESTION_PERIOD_NOT_ENDED(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/service/impl/AdminFeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java index b205588f..c78b2a04 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java @@ -127,16 +127,25 @@ public FeedbackRoundDetail publishAnswers(Long roundId) { LocalDateTime now = LocalDateTime.now(DateUtil.KST); FeedbackRound round = getRoundById(roundId); List feedbacks = feedbackRepository.findAllByYearAndRound(round.getYear(), round.getRound()); - - if (feedbacks.stream().noneMatch(Feedback::isAnswered)) { - throw new BusinessException(FeedbackErrorCode.FEEDBACK_ANSWER_NOT_FOUND); - } + validatePublishable(round, feedbacks, now); round.publishAnswers(now); FeedbackRound published = feedbackRoundRepository.save(round); return FeedbackRoundDetail.of(published, feedbacks, now); } + // 질문 접수가 끝난 회차만 공개한다. 접수 중에 공개하면 회차는 종료로 보이는데 질문은 계속 들어오고, 그 뒤에 단 답변은 + // 임시저장 없이 바로 학생에게 보인다. 이미 공개한 회차는 종료(CLOSED)라 여기를 통과하고 다시 불러도 그대로다 + private void validatePublishable(FeedbackRound round, List feedbacks, LocalDateTime now) { + FeedbackRoundStatus status = FeedbackRoundStatus.of(round, now); + if (status == FeedbackRoundStatus.SCHEDULED || status == FeedbackRoundStatus.OPEN) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_QUESTION_PERIOD_NOT_ENDED); + } + if (feedbacks.stream().noneMatch(Feedback::isAnswered)) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_ANSWER_NOT_FOUND); + } + } + private FeedbackRound getRoundById(Long roundId) { return feedbackRoundRepository.findById(roundId) .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_NOT_FOUND)); From 3173d761e52368fcfc27dc43e634adbc4bb4566e 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: Wed, 28 Oct 2026 17:17:03 +0900 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20=EC=A7=84=ED=96=89=20=EC=A4=91=20?= =?UTF-8?q?=ED=9A=8C=EC=B0=A8=EB=A5=BC=20=ED=9A=8C=EC=B0=A8=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=8C=80=EC=8B=A0=20=EC=A0=91=EC=88=98=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=EC=9D=BC=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/feedback/AdminFeedbackRoundApi.java | 7 ++++--- .../feedback/repository/FeedbackRoundRepository.java | 11 +++++------ .../feedback/service/AdminFeedbackService.java | 3 ++- .../service/impl/AdminFeedbackServiceImpl.java | 12 ++++++++++-- .../db/welfare/FeedbackRoundJpaRepository.java | 6 +++++- .../db/welfare/FeedbackRoundRepositoryImpl.java | 12 ++++++++++-- 6 files changed, 36 insertions(+), 15 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java index 36a334ff..ac503f4b 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java @@ -24,10 +24,11 @@ @Tag(name = "열린피드백 회차 관리", description = "운영진 열린피드백 회차 등록·조회와 답변 작성·공개") public interface AdminFeedbackRoundApi { - /** 관리 화면 상단 요약. 답변을 아직 공개하지 않은 가장 최근 회차를 내려준다. */ + /** 관리 화면 상단 요약. 답변을 아직 공개하지 않은 회차 중 지금 진행 중인 회차를 내려준다. */ @Operation(summary = "진행 중 회차 조회", - description = "답변을 아직 공개하지 않은 가장 최근 회차(오픈예정·질문접수중·답변작성중)를 조회한다. " - + "그런 회차가 없으면 data가 null이다.") + description = "답변을 아직 공개하지 않은 회차 중 접수가 시작된 회차(질문접수중·답변작성중)를 시작일이 가장 늦은 것으로 " + + "조회하고, 그런 회차가 없으면 시작일이 가장 가까운 오픈예정 회차를 조회한다. " + + "둘 다 없으면 data가 null이다.") ApiResponse getCurrentRound(AdminApiUser apiUser); /** 회차 목록. 연도·상태로 필터링하고 페이지 단위로 조회한다. */ 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 index a5c36005..dfece050 100644 --- 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 @@ -12,12 +12,11 @@ public interface FeedbackRoundRepository { // now 시각 기준으로 접수 기간(opens_at~closes_at) 안에 있는 회차 하나를 찾는다. 여러 회차 기간이 안 겹친다는 전제. Optional findOpenAt(LocalDateTime now); - /** - * 답변을 아직 공개하지 않은 회차 중 가장 최근 것(연도·회차 내림차순). - * 관리자 화면이 말하는 "진행 중 회차"(SCHEDULED·OPEN·ANSWERING)가 이 조건과 같다 — - * 접수가 끝나 답변을 쓰는 중인 회차도 포함해야 답변 작성 화면이 질문을 받아올 수 있다. - */ - Optional findLatestUnpublished(); + // 답변을 아직 공개하지 않은 회차 중 now까지 접수가 시작된(OPEN·ANSWERING) 회차에서 시작일이 가장 늦은 것 + Optional findLatestStartedUnpublished(LocalDateTime now); + + // 답변을 아직 공개하지 않은 회차 중 now 이후에 접수가 시작되는(SCHEDULED) 회차에서 시작일이 가장 이른 것 + Optional findNearestUpcoming(LocalDateTime now); // year가 null이면 전체 연도. 최신 회차부터(연도 내림차순 → 회차 내림차순) 정렬한다 List findAllByYear(Integer year); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/AdminFeedbackService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/AdminFeedbackService.java index 851c81b6..01ca6e49 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/AdminFeedbackService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/AdminFeedbackService.java @@ -20,7 +20,8 @@ public interface AdminFeedbackService { // 접수 기간만 받아 회차를 연다. 연도는 시작일 기준, 회차 번호는 그 연도의 최대 + 1로 서버가 배정한다 FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn); - // 답변을 아직 공개하지 않은 가장 최근 회차. 그런 회차가 없으면 비어 있다 + // 진행 중 회차. 답변 공개 전 회차 중 접수가 시작된 회차(시작일이 가장 늦은 것), 없으면 가장 가까운 오픈예정 회차다. + // 그런 회차도 없으면 비어 있다 Optional getCurrentRound(); // year/status가 null이면 그 조건은 필터하지 않는다 diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java index c78b2a04..9f43827c 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java @@ -58,7 +58,7 @@ public FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn) { @Override public Optional getCurrentRound() { LocalDateTime now = LocalDateTime.now(DateUtil.KST); - return feedbackRoundRepository.findLatestUnpublished() + return findCurrentRound(now) .map(round -> FeedbackRoundSummary.of(round, getCountOf(round), now)); } @@ -92,7 +92,7 @@ public PageResult searchQuestions( Long roundId, String keyword, List memberIds, PageOffset pageOffset ) { Optional round = roundId == null - ? feedbackRoundRepository.findLatestUnpublished() + ? findCurrentRound(LocalDateTime.now(DateUtil.KST)) : Optional.of(getRoundById(roundId)); // 진행 중 회차가 없는데 회차를 지정하지도 않았으면 보여줄 질문이 없다 @@ -146,6 +146,14 @@ private void validatePublishable(FeedbackRound round, List feedbacks, } } + // 진행 중 회차는 답변을 공개하기 전 회차 중 접수가 이미 시작된 회차(질문접수중·답변작성중)를 먼저 고르고, 없으면 가장 가까운 + // 오픈예정 회차를 고른다. 다음 회차를 미리 등록해 둬도 지금 회차가 잡히게 하려는 것이다. 회차 번호는 등록 순서대로 + // 붙어 기간 순서와 다를 수 있으므로 시작일로 고른다 + private Optional findCurrentRound(LocalDateTime now) { + return feedbackRoundRepository.findLatestStartedUnpublished(now) + .or(() -> feedbackRoundRepository.findNearestUpcoming(now)); + } + private FeedbackRound getRoundById(Long roundId) { return feedbackRoundRepository.findById(roundId) .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_NOT_FOUND)); 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 index ebffe459..894ec347 100644 --- 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 @@ -12,7 +12,11 @@ public interface FeedbackRoundJpaRepository extends JpaRepository findOpenAt(@Param("now") LocalDateTime now); - Optional findFirstByAnswersPublishedAtIsNullOrderByYearDescRoundDesc(); + Optional findFirstByAnswersPublishedAtIsNullAndOpensAtLessThanEqualOrderByOpensAtDesc( + LocalDateTime now); + + Optional findFirstByAnswersPublishedAtIsNullAndOpensAtAfterOrderByOpensAtAsc( + LocalDateTime now); @Query(""" SELECT r FROM FeedbackRoundJpaEntity r 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 index be90af2e..d5b53f16 100644 --- 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 @@ -25,8 +25,16 @@ public Optional findOpenAt(LocalDateTime now) { } @Override - public Optional findLatestUnpublished() { - return feedbackRoundJpaRepository.findFirstByAnswersPublishedAtIsNullOrderByYearDescRoundDesc() + public Optional findLatestStartedUnpublished(LocalDateTime now) { + return feedbackRoundJpaRepository + .findFirstByAnswersPublishedAtIsNullAndOpensAtLessThanEqualOrderByOpensAtDesc(now) + .map(FeedbackRoundJpaEntity::toDomain); + } + + @Override + public Optional findNearestUpcoming(LocalDateTime now) { + return feedbackRoundJpaRepository + .findFirstByAnswersPublishedAtIsNullAndOpensAtAfterOrderByOpensAtAsc(now) .map(FeedbackRoundJpaEntity::toDomain); } From 058cf523dfe8ec99f43135c18dc76cbf6c1f802c 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: Wed, 28 Oct 2026 17:18:23 +0900 Subject: [PATCH 13/19] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=B0=A8=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=C2=B7=EA=B2=B9=EC=B9=A8=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20validate=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/feedback/domain/FeedbackRound.java | 11 ++++++++--- .../service/impl/AdminFeedbackServiceImpl.java | 15 +++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) 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 index 03e16cc9..0b4077eb 100644 --- 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 @@ -33,9 +33,7 @@ public class FeedbackRound { * 연도는 시작일 기준이고, 회차 번호는 그 연도의 다음 번호를 호출부가 계산해 넘긴다. */ public static FeedbackRound create(int round, LocalDate opensOn, LocalDate closesOn) { - if (closesOn.isBefore(opensOn)) { - throw new BusinessException(FeedbackErrorCode.INVALID_FEEDBACK_ROUND_PERIOD); - } + validatePeriod(opensOn, closesOn); return new FeedbackRound( null, opensOn.getYear(), round, opensOn.atTime(OPENS_TIME), closesOn.atTime(CLOSES_TIME), null); } @@ -51,6 +49,13 @@ public static FeedbackRound of( return new FeedbackRound(id, year, round, opensAt, closesAt, answersPublishedAt); } + // 시작일과 마감일이 같은 하루짜리 회차는 허용한다 + private static void validatePeriod(LocalDate opensOn, LocalDate closesOn) { + if (closesOn.isBefore(opensOn)) { + throw new BusinessException(FeedbackErrorCode.INVALID_FEEDBACK_ROUND_PERIOD); + } + } + public boolean isAnswersPublished() { return answersPublishedAt != null; } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java index 9f43827c..a46760fb 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/AdminFeedbackServiceImpl.java @@ -44,12 +44,7 @@ public FeedbackRoundSummary createRound(LocalDate opensOn, LocalDate closesOn) { .map(maxRound -> maxRound + 1) .orElse(FIRST_ROUND); FeedbackRound round = FeedbackRound.create(nextRound, opensOn, closesOn); - - // 학생 앱의 질문 등록은 접수 기간인 회차 "하나"를 찾아 배정하므로, 기간이 겹치면 질문 등록이 깨진다. - // 범위 겹침은 유니크 제약으로 막을 수 없어 등록 전에 확인한다(조회-후-삽입이라 동시 등록 구멍은 남는다) - if (feedbackRoundRepository.existsOverlapping(round.getOpensAt(), round.getClosesAt())) { - throw new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_OVERLAPPED); - } + validateNotOverlapped(round); FeedbackRound saved = feedbackRoundRepository.save(round); return FeedbackRoundSummary.of(saved, FeedbackCount.empty(saved.getYear(), saved.getRound()), now); @@ -134,6 +129,14 @@ public FeedbackRoundDetail publishAnswers(Long roundId) { return FeedbackRoundDetail.of(published, feedbacks, now); } + // 학생 앱의 질문 등록은 접수 기간인 회차 "하나"를 찾아 배정하므로, 기간이 겹치면 질문 등록이 깨진다. + // 범위 겹침은 유니크 제약으로 막을 수 없어 등록 전에 확인한다(조회-후-삽입이라 동시 등록 구멍은 남는다) + private void validateNotOverlapped(FeedbackRound round) { + if (feedbackRoundRepository.existsOverlapping(round.getOpensAt(), round.getClosesAt())) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_OVERLAPPED); + } + } + // 질문 접수가 끝난 회차만 공개한다. 접수 중에 공개하면 회차는 종료로 보이는데 질문은 계속 들어오고, 그 뒤에 단 답변은 // 임시저장 없이 바로 학생에게 보인다. 이미 공개한 회차는 종료(CLOSED)라 여기를 통과하고 다시 불러도 그대로다 private void validatePublishable(FeedbackRound round, List feedbacks, LocalDateTime now) { From 0236e31072eb32e5d6bca79d13b83f14af6e31a8 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: Wed, 28 Oct 2026 17:21:16 +0900 Subject: [PATCH 14/19] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=B0=A8=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20FeedbackCoun?= =?UTF-8?q?tProjection=20record=EB=A1=9C=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/db/welfare/FeedbackCountProjection.java | 7 +++++++ .../kookmin/stream/db/welfare/FeedbackCountRow.java | 13 ------------- .../stream/db/welfare/FeedbackJpaRepository.java | 9 +++++---- .../stream/db/welfare/FeedbackRepositoryImpl.java | 3 +-- 4 files changed, 13 insertions(+), 19 deletions(-) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountProjection.java delete mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountRow.java diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountProjection.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountProjection.java new file mode 100644 index 00000000..5ede48a3 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountProjection.java @@ -0,0 +1,7 @@ +package kr.ac.kookmin.stream.db.welfare; + +/** + * 회차별 질문 수·답변 수 집계 결과. JPQL 생성자 표현식으로 채워지는 조회 전용 프로젝션이다. + * 도메인 레코드(FeedbackCount)로는 RepositoryImpl이 옮긴다. + */ +public record FeedbackCountProjection(int year, int round, long questionCount, long answerCount) {} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountRow.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountRow.java deleted file mode 100644 index d4177a84..00000000 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackCountRow.java +++ /dev/null @@ -1,13 +0,0 @@ -package kr.ac.kookmin.stream.db.welfare; - -// 회차별 집계 쿼리의 결과 한 줄. 도메인 레코드(FeedbackCount)로는 RepositoryImpl이 옮긴다 -public interface FeedbackCountRow { - - int getYear(); - - int getRound(); - - long getQuestionCount(); - - long getAnswerCount(); -} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java index 901263be..afbad460 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaRepository.java @@ -61,13 +61,14 @@ Page searchByKeyword( Pageable pageable ); - // COUNT(f.answer)는 answer가 NULL인 행을 세지 않으므로 그대로 "답변이 작성된 수"가 된다 + // COUNT(f.answer)는 answer가 NULL인 행을 세지 않으므로 그대로 "답변이 작성된 수"가 된다. + // 생성자 인자는 이름이 아니라 순서로 들어가므로 질문 수·답변 수 순서를 FeedbackCountProjection과 맞춘다 @Query(""" - SELECT f.year AS year, f.round AS round, - COUNT(f) AS questionCount, COUNT(f.answer) AS answerCount + SELECT new kr.ac.kookmin.stream.db.welfare.FeedbackCountProjection( + f.year, f.round, COUNT(f), COUNT(f.answer)) FROM FeedbackJpaEntity f WHERE f.isDeleted = false AND (:year IS NULL OR f.year = :year) GROUP BY f.year, f.round """) - List findCountsByYear(@Param("year") Integer year); + List findCountsByYear(@Param("year") Integer year); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java index 19bcf76b..b431f148 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRepositoryImpl.java @@ -61,8 +61,7 @@ public PageResult searchByKeyword( @Override public List findCountsByYear(Integer year) { return feedbackJpaRepository.findCountsByYear(year).stream() - .map(row -> FeedbackCount.of( - row.getYear(), row.getRound(), row.getQuestionCount(), row.getAnswerCount())) + .map(count -> FeedbackCount.of(count.year(), count.round(), count.questionCount(), count.answerCount())) .toList(); } From 9902e5d33e402f317acea401c325f8ac611548ed 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: Wed, 28 Oct 2026 17:22:13 +0900 Subject: [PATCH 15/19] =?UTF-8?q?fix:=20=EB=8B=B5=EB=B3=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=20=EB=B0=94=EB=80=90=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EB=A7=8C=20UPDATE=ED=95=98=EB=8F=84=EB=A1=9D=20@DynamicUpdate?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/ac/kookmin/stream/db/welfare/FeedbackJpaEntity.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaEntity.java index 1981784e..04fb56f8 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackJpaEntity.java @@ -11,9 +11,14 @@ import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; +// 바뀐 컬럼만 UPDATE한다(@DynamicUpdate). 도메인 Feedback에는 삭제 여부가 없어 from()으로 만든 엔티티의 isDeleted는 늘 false라, +// 답변 저장이 질문 삭제와 겹치면 모든 컬럼을 되쓰면서 삭제 표시(is_deleted)를 되돌릴 수 있다. 답변 컬럼만 쓰게 해 이를 막는다. +// 동시 수정을 막는 락은 두지 않아 같은 질문의 답변 저장이 겹치면 나중에 저장한 내용이 반영된다. @Entity @Table(name = "open_feedbacks") +@DynamicUpdate @NoArgsConstructor(access = AccessLevel.PROTECTED) public class FeedbackJpaEntity extends BaseSoftDeleteEntity { From 7fb246eef57f14d4030525b6b7ffa1f6b31b85df 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: Wed, 28 Oct 2026 17:23:43 +0900 Subject: [PATCH 16/19] =?UTF-8?q?fix:=20=ED=9A=8C=EC=B0=A8=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=8F=99=EC=8B=9C=20=EB=B0=B0=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=9C=84=EB=B0=98=EC=9D=84=20409?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=91=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/feedback/AdminFeedbackRoundApi.java | 5 +++-- .../feedback/domain/FeedbackErrorCode.java | 1 + .../db/welfare/FeedbackRoundRepositoryImpl.java | 16 +++++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java index ac503f4b..7761807a 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java @@ -47,11 +47,12 @@ ApiResponse> getRounds( /** 회차 등록. 접수 기간만 받고 연도·회차 번호는 서버가 배정한다. */ @Operation(summary = "피드백 회차 등록", description = "질문 접수 기간(날짜)만 받아 회차를 연다. 시각은 시작일 00:00:00 ~ 마감일 23:59:59로 채우고, " - + "연도는 시작일 기준, 회차 번호는 그 연도의 최대 + 1로 배정한다. 다른 회차와 접수 기간이 겹치면 409다.") + + "연도는 시작일 기준, 회차 번호는 그 연도의 최대 + 1로 배정한다. 다른 회차와 접수 기간이 겹치면 409다. " + + "동시에 등록해 같은 회차 번호가 배정되면 늦은 쪽이 409이고, 다시 요청하면 다음 번호로 등록된다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode( type = FeedbackErrorCode.class, - codes = {"INVALID_FEEDBACK_ROUND_PERIOD", "FEEDBACK_ROUND_OVERLAPPED"} + codes = {"INVALID_FEEDBACK_ROUND_PERIOD", "FEEDBACK_ROUND_OVERLAPPED", "FEEDBACK_ROUND_ALREADY_EXISTS"} ) ApiResponse createRound(AdminApiUser apiUser, FeedbackRoundCreateRequest request); 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 index c11b8269..5255f773 100644 --- 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 @@ -20,6 +20,7 @@ public enum FeedbackErrorCode implements ErrorCode { FEEDBACK_ROUND_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 열린피드백 회차입니다."), FEEDBACK_NOT_OPEN(ErrorStatus.CONFLICT, "현재 열린피드백 질문 접수 기간이 아닙니다."), FEEDBACK_ROUND_OVERLAPPED(ErrorStatus.CONFLICT, "이미 등록된 회차와 질문 접수 기간이 겹칩니다."), + FEEDBACK_ROUND_ALREADY_EXISTS(ErrorStatus.CONFLICT, "같은 회차가 방금 등록되었습니다. 다시 시도해 주세요."), FEEDBACK_QUESTION_PERIOD_NOT_ENDED(ErrorStatus.CONFLICT, "질문 접수가 끝난 회차만 답변을 공개할 수 있습니다."); private final int status; 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 index d5b53f16..51a84297 100644 --- 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 @@ -3,15 +3,21 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.db.common.ConstraintViolationUtil; +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.repository.FeedbackRoundRepository; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class FeedbackRoundRepositoryImpl implements FeedbackRoundRepository { + private static final String YEAR_ROUND_UNIQUE_CONSTRAINT = "uk_feedback_rounds_year_round"; + private final FeedbackRoundJpaRepository feedbackRoundJpaRepository; @Override @@ -67,6 +73,14 @@ public List findRoundsByYear(int year) { @Override public FeedbackRound save(FeedbackRound feedbackRound) { - return feedbackRoundJpaRepository.save(FeedbackRoundJpaEntity.from(feedbackRound)).toDomain(); + try { + // 동시 등록으로 같은 회차 번호를 함께 배정받은 경우 유니크 위반을 이 자리에서 잡으려면 바로 반영해야 한다 + return feedbackRoundJpaRepository.saveAndFlush(FeedbackRoundJpaEntity.from(feedbackRound)).toDomain(); + } catch (DataIntegrityViolationException e) { + if (ConstraintViolationUtil.isViolated(e, YEAR_ROUND_UNIQUE_CONSTRAINT)) { + throw new BusinessException(FeedbackErrorCode.FEEDBACK_ROUND_ALREADY_EXISTS); + } + throw e; + } } } From b48629f39d50913bf859e72c3aa3182352900927 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: Wed, 28 Oct 2026 17:24:52 +0900 Subject: [PATCH 17/19] =?UTF-8?q?refactor:=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=EC=9E=91=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B4=EB=A6=84=EC=9D=84=20member=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/feedback/AdminFeedbackController.java | 4 ++-- .../feedback/response/AdminFeedbackResponse.java | 14 ++++++-------- .../feedback/response/FeedbackAnswerResponse.java | 12 +++++------- .../usecase/AdminFeedbackAnswerUseCase.java | 12 ++++++------ .../usecase/AdminFeedbackSearchUseCase.java | 6 +++--- .../feedback/usecase/FeedbackRoundAnswers.java | 2 +- ...eedbackWithStudent.java => MemberFeedback.java} | 8 ++++++-- 7 files changed, 29 insertions(+), 29 deletions(-) rename api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/{FeedbackWithStudent.java => MemberFeedback.java} (53%) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java index 38697edc..839ebce1 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackController.java @@ -4,7 +4,7 @@ import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.welfare.feedback.response.AdminFeedbackResponse; import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.AdminFeedbackSearchUseCase; -import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.FeedbackWithStudent; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.MemberFeedback; 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; @@ -31,7 +31,7 @@ public ApiResponse> getFeedbacks( @RequestParam(required = false) String keyword, @Valid @ModelAttribute PageParams pageParams ) { - PageResult result = + PageResult result = adminFeedbackSearchUseCase.search(roundId, keyword, pageParams.toOffset()); return ApiResponse.success(PageResponse.from(result, AdminFeedbackResponse::from)); diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java index 788344af..5368286a 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/AdminFeedbackResponse.java @@ -1,32 +1,30 @@ package kr.ac.kookmin.stream.api.admin.welfare.feedback.response; import java.time.LocalDateTime; -import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.FeedbackWithStudent; -import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.MemberFeedback; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; -// 현재 회차 질문 탭의 한 줄. studentName은 탈퇴(소프트 삭제)한 회원의 질문이면 null이다 +// 현재 회차 질문 탭의 한 줄. memberName은 탈퇴(소프트 삭제)한 회원의 질문이면 null이다 public record AdminFeedbackResponse( Long feedbackId, int year, int round, String question, - String studentName, + String memberName, LocalDateTime questionedAt, String answer, LocalDateTime answeredAt ) { - public static AdminFeedbackResponse from(FeedbackWithStudent feedbackWithStudent) { - Feedback feedback = feedbackWithStudent.feedback(); - Member student = feedbackWithStudent.student(); + public static AdminFeedbackResponse from(MemberFeedback memberFeedback) { + Feedback feedback = memberFeedback.feedback(); return new AdminFeedbackResponse( feedback.getId(), feedback.getYear(), feedback.getRound(), feedback.getQuestion(), - student == null ? null : student.getName(), + memberFeedback.memberName(), feedback.getQuestionedAt(), feedback.getAnswer(), feedback.getAnsweredAt() diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java index 82bacf39..acc0181f 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/response/FeedbackAnswerResponse.java @@ -1,28 +1,26 @@ package kr.ac.kookmin.stream.api.admin.welfare.feedback.response; import java.time.LocalDateTime; -import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.FeedbackWithStudent; -import kr.ac.kookmin.stream.member.domain.member.domain.Member; +import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.MemberFeedback; import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; // 회차 답변 보기 모달의 한 항목. 답변이 없는 질문도 함께 내려간다 public record FeedbackAnswerResponse( Long feedbackId, String question, - String studentName, + String memberName, LocalDateTime questionedAt, String answer, LocalDateTime answeredAt ) { - public static FeedbackAnswerResponse from(FeedbackWithStudent feedbackWithStudent) { - Feedback feedback = feedbackWithStudent.feedback(); - Member student = feedbackWithStudent.student(); + public static FeedbackAnswerResponse from(MemberFeedback memberFeedback) { + Feedback feedback = memberFeedback.feedback(); return new FeedbackAnswerResponse( feedback.getId(), feedback.getQuestion(), - student == null ? null : student.getName(), + memberFeedback.memberName(), feedback.getQuestionedAt(), feedback.getAnswer(), feedback.getAnsweredAt() diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java index 27591315..97e0b7de 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackAnswerUseCase.java @@ -20,25 +20,25 @@ public class AdminFeedbackAnswerUseCase { private final MemberService memberService; public FeedbackRoundAnswers getRoundAnswers(Long roundId) { - return withStudents(adminFeedbackService.getRoundDetail(roundId)); + return attachMembers(adminFeedbackService.getRoundDetail(roundId)); } public FeedbackRoundAnswers saveAnswers(Long roundId, List commands, Long answeredBy) { - return withStudents(adminFeedbackService.saveAnswers(roundId, commands, answeredBy)); + return attachMembers(adminFeedbackService.saveAnswers(roundId, commands, answeredBy)); } public FeedbackRoundAnswers publishAnswers(Long roundId) { - return withStudents(adminFeedbackService.publishAnswers(roundId)); + return attachMembers(adminFeedbackService.publishAnswers(roundId)); } - private FeedbackRoundAnswers withStudents(FeedbackRoundDetail detail) { - Map students = memberService.getMapByIds( + private FeedbackRoundAnswers attachMembers(FeedbackRoundDetail detail) { + Map members = memberService.getMapByIds( detail.feedbacks().stream().map(Feedback::getCreatedBy).toList()); return new FeedbackRoundAnswers( detail.summary(), detail.feedbacks().stream() - .map(feedback -> new FeedbackWithStudent(feedback, students.get(feedback.getCreatedBy()))) + .map(feedback -> new MemberFeedback(feedback, members.get(feedback.getCreatedBy()))) .toList() ); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java index 81d0ddea..82a84d8a 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/AdminFeedbackSearchUseCase.java @@ -24,7 +24,7 @@ public class AdminFeedbackSearchUseCase { private final AdminFeedbackService adminFeedbackService; private final MemberService memberService; - public PageResult search(Long roundId, String keyword, PageOffset pageOffset) { + public PageResult search(Long roundId, String keyword, PageOffset pageOffset) { String searchKeyword = keyword == null || keyword.isBlank() ? null : keyword; List matchedMemberIds = searchKeyword == null ? List.of() @@ -32,9 +32,9 @@ public PageResult search(Long roundId, String keyword, Page PageResult result = adminFeedbackService.searchQuestions(roundId, searchKeyword, matchedMemberIds, pageOffset); - Map students = memberService.getMapByIds( + Map members = memberService.getMapByIds( result.content().stream().map(Feedback::getCreatedBy).toList()); - return result.map(feedback -> new FeedbackWithStudent(feedback, students.get(feedback.getCreatedBy()))); + return result.map(feedback -> new MemberFeedback(feedback, members.get(feedback.getCreatedBy()))); } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackRoundAnswers.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackRoundAnswers.java index fb04cb42..ad60223a 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackRoundAnswers.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackRoundAnswers.java @@ -4,5 +4,5 @@ import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundSummary; // 회차 현황과 그 회차의 질문·답변 전체에 작성자를 붙인 결과. -public record FeedbackRoundAnswers(FeedbackRoundSummary summary, List feedbacks) { +public record FeedbackRoundAnswers(FeedbackRoundSummary summary, List feedbacks) { } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackWithStudent.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/MemberFeedback.java similarity index 53% rename from api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackWithStudent.java rename to api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/MemberFeedback.java index c32e31b1..dd02e0bc 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/FeedbackWithStudent.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/usecase/MemberFeedback.java @@ -4,6 +4,10 @@ import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback; // 질문에 작성자를 붙인 결과. DTO 변환은 Controller가 한다. -// student는 탈퇴(소프트 삭제)한 회원의 질문이면 null일 수 있다. -public record FeedbackWithStudent(Feedback feedback, Member student) { +// member는 탈퇴(소프트 삭제)한 회원의 질문이면 null일 수 있다. +public record MemberFeedback(Feedback feedback, Member member) { + + public String memberName() { + return member == null ? null : member.getName(); + } } From fe9f24bceaf2524a4c847867f362929e4c8c709d 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: Wed, 28 Oct 2026 17:25:43 +0900 Subject: [PATCH 18/19] =?UTF-8?q?feat:=20=EB=8B=B5=EB=B3=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=9A=94=EC=B2=AD=EC=97=90=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=EC=9D=B4=20=EC=A4=91=EB=B3=B5=EB=90=98?= =?UTF-8?q?=EB=A9=B4=20=EA=B1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../welfare/feedback/AdminFeedbackRoundApi.java | 1 + .../request/FeedbackAnswerSaveRequest.java | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java index 7761807a..56e77e84 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/AdminFeedbackRoundApi.java @@ -65,6 +65,7 @@ ApiResponse> getRounds( /** 답변 임시저장. 저장만 하고 학생에게는 보이지 않는다. */ @Operation(summary = "피드백 답변 일괄 등록·수정", description = "회차 단위로 답변을 저장한다. 보낸 것만 반영하고 이미 답변이 있는 질문은 덮어쓴다. " + + "한 요청에 같은 질문(feedbackId)을 두 번 보내면 400(INVALID_INPUT)이다. " + "여기서 저장한 답변은 관리자에게만 보이고, 답변 공개 API를 불러야 학생 앱에 나타난다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode( diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackAnswerSaveRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackAnswerSaveRequest.java index c552c422..922d252a 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackAnswerSaveRequest.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/feedback/request/FeedbackAnswerSaveRequest.java @@ -21,11 +21,25 @@ public record FeedbackAnswerSaveRequest( ) { public List toCommands() { + validateFeedbackIdsNotDuplicated(); + return answers.stream() .map(item -> FeedbackAnswerCommand.of(item.feedbackId(), item.answer())) .toList(); } + // 같은 질문이 두 번 오면 어느 답변이 남을지 요청만 보고 알 수 없다. + // IllegalArgumentException → GlobalExceptionHandler가 INVALID_INPUT(400)으로 응답한다 + private void validateFeedbackIdsNotDuplicated() { + long distinctFeedbackIdCount = answers.stream() + .map(Item::feedbackId) + .distinct() + .count(); + if (distinctFeedbackIdCount != answers.size()) { + throw new IllegalArgumentException("같은 질문의 답변이 중복되었습니다."); + } + } + public record Item( @Schema(description = "답변할 피드백 아이디", example = "21") From 0e34f9ea9424b7742e82931a4a019e476016d720 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: Thu, 29 Oct 2026 19:52:39 +0900 Subject: [PATCH 19/19] =?UTF-8?q?chore:=20Flyway=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V22 → V20 add_answers_published_at_to_feedback_rounds --- ...s.sql => V20__add_answers_published_at_to_feedback_rounds.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename infrastructure/db/src/main/resources/db/migration/{V22__add_answers_published_at_to_feedback_rounds.sql => V20__add_answers_published_at_to_feedback_rounds.sql} (100%) diff --git a/infrastructure/db/src/main/resources/db/migration/V22__add_answers_published_at_to_feedback_rounds.sql b/infrastructure/db/src/main/resources/db/migration/V20__add_answers_published_at_to_feedback_rounds.sql similarity index 100% rename from infrastructure/db/src/main/resources/db/migration/V22__add_answers_published_at_to_feedback_rounds.sql rename to infrastructure/db/src/main/resources/db/migration/V20__add_answers_published_at_to_feedback_rounds.sql