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 1/6] =?UTF-8?q?fix:=20=EC=97=B4=EB=A6=B0=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=ED=9A=8C=EC=B0=A8=20=ED=8C=90=EC=A0=95=EA=B3=BC=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EC=8B=9C=EA=B0=81=EC=97=90=20=ED=95=9C?= =?UTF-8?q?=EA=B5=AD=20=EC=8B=9C=EA=B0=84=EB=8C=80=EB=A5=BC=20=EB=AA=85?= =?UTF-8?q?=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 2/6] =?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 3/6] =?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 4/6] =?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 5/6] =?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 6/6] =?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) { +}