diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java
new file mode 100644
index 00000000..0d54b4f6
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackApi.java
@@ -0,0 +1,54 @@
+package kr.ac.kookmin.stream.api.app.welfare.feedback;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import kr.ac.kookmin.stream.api.app.AppApiUser;
+import kr.ac.kookmin.stream.api.app.welfare.feedback.request.FeedbackCreateRequest;
+import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse;
+import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackRoundsResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.PageParams;
+import kr.ac.kookmin.stream.api.common.dto.PageResponse;
+import kr.ac.kookmin.stream.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 AppFeedbackController}가 맡는다.
+ *
+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
+ * 파라미터 바인딩(@{@code RequestParam}, @{@code PathVariable}, @{@code ModelAttribute} 등)은 구현체에 둔다.
+ */
+@Tag(name = "열린피드백", description = "학생 앱 열린피드백 질문 등록·조회")
+public interface AppFeedbackApi {
+
+ /** 피드백 목록. year/round로 필터링하고 페이지 단위로 조회한다. */
+ @Operation(summary = "피드백 목록 조회",
+ description = "연도(year)·회차(round)로 필터링해 열린피드백 목록을 페이지 단위로 조회한다. "
+ + "year를 생략하면 전체 연도, round를 생략하면 그 안의 전체 회차를 대상으로 한다.")
+ @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"INVALID_FEEDBACK_ROUND"})
+ ApiResponse> getFeedbacks(
+ AppApiUser apiUser,
+ Integer year,
+ Integer round,
+ @ParameterObject PageParams pageParams
+ );
+
+ /** 피드백 상세. */
+ @Operation(summary = "피드백 상세 조회")
+ @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_NOT_FOUND"})
+ ApiResponse getFeedback(AppApiUser apiUser, Long feedbackId);
+
+ /** 피드백 질문 등록. 회차는 현재 열려 있는 회차로 서버가 자동 배정한다. */
+ @Operation(summary = "피드백 질문 등록",
+ description = "질문을 등록한다. 연도·회차는 현재 접수 기간이 열려 있는 회차로 서버가 자동 배정한다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_NOT_OPEN"})
+ ApiResponse createFeedback(AppApiUser apiUser, FeedbackCreateRequest request);
+
+ /** 목록 화면의 연도 드롭다운·회차 칩을 그리기 위한 필터 옵션. */
+ @Operation(summary = "피드백 회차 필터 조회",
+ description = "year를 생략하면 현재 연도 기준으로, 회차가 하나라도 존재했던 전체 연도 목록과 그 연도에 실제로 존재하는 회차 목록을 함께 내려준다.")
+ ApiResponse getFeedbackRounds(AppApiUser apiUser, Integer year);
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java
new file mode 100644
index 00000000..23329609
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/AppFeedbackController.java
@@ -0,0 +1,70 @@
+package kr.ac.kookmin.stream.api.app.welfare.feedback;
+
+import jakarta.validation.Valid;
+import kr.ac.kookmin.stream.api.app.AppApiUser;
+import kr.ac.kookmin.stream.api.app.welfare.feedback.request.FeedbackCreateRequest;
+import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackResponse;
+import kr.ac.kookmin.stream.api.app.welfare.feedback.response.FeedbackRoundsResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.PageParams;
+import kr.ac.kookmin.stream.api.common.dto.PageResponse;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundOptions;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback;
+import kr.ac.kookmin.stream.welfare.domain.feedback.service.OpenFeedbackService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v1/app/feedbacks")
+@RequiredArgsConstructor
+public class AppFeedbackController implements AppFeedbackApi {
+
+ private final OpenFeedbackService openFeedbackService;
+
+ @Override
+ @GetMapping("/{feedbackId}")
+ public ApiResponse getFeedback(AppApiUser apiUser, @PathVariable Long feedbackId) {
+ OpenFeedback feedback = openFeedbackService.getById(feedbackId);
+ return ApiResponse.success(FeedbackResponse.from(feedback));
+ }
+
+ @Override
+ @GetMapping
+ public ApiResponse> getFeedbacks(
+ AppApiUser apiUser,
+ @RequestParam(required = false) Integer year,
+ @RequestParam(required = false) Integer round,
+ @Valid @ModelAttribute PageParams pageParams
+ ) {
+ PageResult result = openFeedbackService.search(year, round, pageParams.toOffset());
+ return ApiResponse.success(PageResponse.from(result, FeedbackResponse::from));
+ }
+
+ @Override
+ @PostMapping
+ public ApiResponse createFeedback(
+ AppApiUser apiUser,
+ @Valid @RequestBody FeedbackCreateRequest request
+ ) {
+ OpenFeedback feedback = openFeedbackService.create(apiUser.userId(), request.question());
+ return ApiResponse.success(FeedbackResponse.from(feedback));
+ }
+
+ @Override
+ @GetMapping("/rounds")
+ public ApiResponse getFeedbackRounds(
+ AppApiUser apiUser,
+ @RequestParam(required = false) Integer year
+ ) {
+ FeedbackRoundOptions options = openFeedbackService.getRoundOptions(year);
+ return ApiResponse.success(FeedbackRoundsResponse.from(options));
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/request/FeedbackCreateRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/request/FeedbackCreateRequest.java
new file mode 100644
index 00000000..d32f4de6
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/request/FeedbackCreateRequest.java
@@ -0,0 +1,12 @@
+package kr.ac.kookmin.stream.api.app.welfare.feedback.request;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record FeedbackCreateRequest(
+
+ @NotBlank(message = "피드백 내용을 입력해 주세요.")
+ @Size(max = 500, message = "피드백 내용은 500자 이하로 입력해 주세요.")
+ String question
+) {
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackResponse.java
new file mode 100644
index 00000000..e998b185
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackResponse.java
@@ -0,0 +1,26 @@
+package kr.ac.kookmin.stream.api.app.welfare.feedback.response;
+
+import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback;
+
+public record FeedbackResponse(
+ Long feedbackId,
+ int year,
+ int round,
+ String question,
+ LocalDateTime questionedAt,
+ LocalDateTime answeredAt,
+ String answer
+) {
+ public static FeedbackResponse from(OpenFeedback feedback) {
+ return new FeedbackResponse(
+ feedback.id(),
+ feedback.year(),
+ feedback.round(),
+ feedback.question(),
+ feedback.questionedAt(),
+ feedback.answeredAt(),
+ feedback.answer()
+ );
+ }
+}
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackRoundsResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackRoundsResponse.java
new file mode 100644
index 00000000..3434da2a
--- /dev/null
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/feedback/response/FeedbackRoundsResponse.java
@@ -0,0 +1,11 @@
+package kr.ac.kookmin.stream.api.app.welfare.feedback.response;
+
+import java.util.List;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundOptions;
+
+public record FeedbackRoundsResponse(List years, int year, List rounds) {
+
+ public static FeedbackRoundsResponse from(FeedbackRoundOptions options) {
+ return new FeedbackRoundsResponse(options.years(), options.year(), options.rounds());
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackErrorCode.java
new file mode 100644
index 00000000..fe3ee9b0
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackErrorCode.java
@@ -0,0 +1,20 @@
+package kr.ac.kookmin.stream.welfare.domain.feedback.domain;
+
+import kr.ac.kookmin.stream.common.ErrorCode;
+import kr.ac.kookmin.stream.common.ErrorStatus;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.experimental.Accessors;
+
+@Getter
+@Accessors(fluent = true)
+@AllArgsConstructor
+public enum FeedbackErrorCode implements ErrorCode {
+
+ INVALID_FEEDBACK_ROUND(ErrorStatus.BAD_REQUEST, "유효하지 않은 열린피드백 회차입니다."),
+ FEEDBACK_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 피드백입니다."),
+ FEEDBACK_NOT_OPEN(ErrorStatus.CONFLICT, "현재 열린피드백 질문 접수 기간이 아닙니다.");
+
+ private final int status;
+ private final String message;
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRound.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRound.java
new file mode 100644
index 00000000..7c433741
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRound.java
@@ -0,0 +1,11 @@
+package kr.ac.kookmin.stream.welfare.domain.feedback.domain;
+
+import java.time.LocalDateTime;
+
+// 관리자가 여는 열린피드백 접수 회차. 오픈/마감 시각과 함께 연도별로 관리한다(연도별로 1차부터 다시 시작).
+public record FeedbackRound(Long id, int year, int round, LocalDateTime opensAt, LocalDateTime closesAt) {
+
+ public static FeedbackRound of(Long id, int year, int round, LocalDateTime opensAt, LocalDateTime closesAt) {
+ return new FeedbackRound(id, year, round, opensAt, closesAt);
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundOptions.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundOptions.java
new file mode 100644
index 00000000..9d570c69
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/FeedbackRoundOptions.java
@@ -0,0 +1,12 @@
+package kr.ac.kookmin.stream.welfare.domain.feedback.domain;
+
+import java.util.List;
+
+// 목록 화면의 연도 드롭다운·회차 칩을 그리는 데 쓰는 조회 전용 값 객체.
+// years: 피드백 회차가 하나라도 존재했던 전체 연도. rounds: year 기준으로 실제 존재하는 회차 번호.
+public record FeedbackRoundOptions(List years, int year, List rounds) {
+
+ public static FeedbackRoundOptions of(List years, int year, List rounds) {
+ return new FeedbackRoundOptions(years, year, rounds);
+ }
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/OpenFeedback.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/OpenFeedback.java
index 435c5117..3eeefe7c 100644
--- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/OpenFeedback.java
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/domain/OpenFeedback.java
@@ -1,35 +1,36 @@
package kr.ac.kookmin.stream.welfare.domain.feedback.domain;
import java.time.LocalDateTime;
-import lombok.AccessLevel;
-import lombok.AllArgsConstructor;
-import lombok.EqualsAndHashCode;
-import lombok.Getter;
-@Getter
-@EqualsAndHashCode
-@AllArgsConstructor(access = AccessLevel.PRIVATE)
-public class OpenFeedback {
+public record OpenFeedback(
+ Long id,
+ int year,
+ int round,
+ String question,
+ LocalDateTime questionedAt,
+ String answer,
+ LocalDateTime answeredAt,
+ Long createdBy,
+ Long answeredBy
+) {
- private Long id;
- private int round;
- private String category;
- private String question;
- private String answer;
- private LocalDateTime answeredAt;
- private Long createdBy;
- private Long answeredBy;
+ // 회차는 서버가 자동 배정하므로 여기서 받는다(요청 시각 = questionedAt, 아직 답변 없음).
+ public static OpenFeedback create(int year, int round, String question, Long createdBy) {
+ return new OpenFeedback(null, year, round, question, LocalDateTime.now(), null, null, createdBy, null);
+ }
+ // DB에서 복원할 때 모든 필드를 그대로 받는다.
public static OpenFeedback of(
Long id,
+ int year,
int round,
- String category,
String question,
+ LocalDateTime questionedAt,
String answer,
LocalDateTime answeredAt,
Long createdBy,
Long answeredBy
) {
- return new OpenFeedback(id, round, category, question, answer, answeredAt, createdBy, answeredBy);
+ return new OpenFeedback(id, year, round, question, questionedAt, answer, answeredAt, createdBy, answeredBy);
}
}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRoundRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRoundRepository.java
new file mode 100644
index 00000000..172196f4
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/FeedbackRoundRepository.java
@@ -0,0 +1,18 @@
+package kr.ac.kookmin.stream.welfare.domain.feedback.repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound;
+
+public interface FeedbackRoundRepository {
+
+ // now 시각 기준으로 접수 기간(opens_at~closes_at) 안에 있는 회차 하나를 찾는다. 여러 회차 기간이 안 겹친다는 전제.
+ Optional findOpenAt(LocalDateTime now);
+
+ // 회차가 하나라도 존재했던 연도 목록(최신순) — 목록 화면 연도 드롭다운용
+ List findDistinctYears();
+
+ // 해당 연도에 실제로 존재하는 회차 번호(오름차순) — 목록 화면 회차 칩용
+ List findRoundsByYear(int year);
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/OpenFeedbackRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/OpenFeedbackRepository.java
new file mode 100644
index 00000000..cfd679bf
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/repository/OpenFeedbackRepository.java
@@ -0,0 +1,16 @@
+package kr.ac.kookmin.stream.welfare.domain.feedback.repository;
+
+import java.util.Optional;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback;
+
+public interface OpenFeedbackRepository {
+
+ Optional findById(Long id);
+
+ // year/round가 null이면 그 조건은 필터하지 않는다
+ PageResult search(Integer year, Integer round, PageOffset pageOffset);
+
+ OpenFeedback save(OpenFeedback feedback);
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/OpenFeedbackService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/OpenFeedbackService.java
new file mode 100644
index 00000000..45611551
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/OpenFeedbackService.java
@@ -0,0 +1,20 @@
+package kr.ac.kookmin.stream.welfare.domain.feedback.service;
+
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundOptions;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback;
+
+public interface OpenFeedbackService {
+
+ OpenFeedback getById(Long id);
+
+ // year/round가 null이면 그 조건은 전체(필터 없음)
+ PageResult search(Integer year, Integer round, PageOffset pageOffset);
+
+ // 회차는 현재 열려 있는 회차로 서버가 자동 배정한다
+ OpenFeedback create(Long memberId, String question);
+
+ // year가 null이면 현재 연도 기준
+ FeedbackRoundOptions getRoundOptions(Integer year);
+}
diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java
new file mode 100644
index 00000000..8c31a8ee
--- /dev/null
+++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/feedback/service/impl/OpenFeedbackServiceImpl.java
@@ -0,0 +1,58 @@
+package kr.ac.kookmin.stream.welfare.domain.feedback.service.impl;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackErrorCode;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRoundOptions;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback;
+import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRoundRepository;
+import kr.ac.kookmin.stream.welfare.domain.feedback.repository.OpenFeedbackRepository;
+import kr.ac.kookmin.stream.welfare.domain.feedback.service.OpenFeedbackService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+class OpenFeedbackServiceImpl implements OpenFeedbackService {
+
+ private final OpenFeedbackRepository openFeedbackRepository;
+ private final FeedbackRoundRepository feedbackRoundRepository;
+
+ @Override
+ public OpenFeedback getById(Long id) {
+ return openFeedbackRepository.findById(id)
+ .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_FOUND));
+ }
+
+ @Override
+ public PageResult search(Integer year, Integer round, PageOffset pageOffset) {
+ if (round != null && round <= 0) {
+ throw new BusinessException(FeedbackErrorCode.INVALID_FEEDBACK_ROUND);
+ }
+ return openFeedbackRepository.search(year, round, pageOffset);
+ }
+
+ @Override
+ @Transactional
+ public OpenFeedback create(Long memberId, String question) {
+ FeedbackRound openRound = feedbackRoundRepository.findOpenAt(LocalDateTime.now())
+ .orElseThrow(() -> new BusinessException(FeedbackErrorCode.FEEDBACK_NOT_OPEN));
+
+ OpenFeedback feedback = OpenFeedback.create(openRound.year(), openRound.round(), question, memberId);
+ return openFeedbackRepository.save(feedback);
+ }
+
+ @Override
+ public FeedbackRoundOptions getRoundOptions(Integer year) {
+ List years = feedbackRoundRepository.findDistinctYears();
+ int targetYear = year != null ? year : LocalDateTime.now().getYear();
+ List rounds = feedbackRoundRepository.findRoundsByYear(targetYear);
+
+ return FeedbackRoundOptions.of(years, targetYear, rounds);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaEntity.java
new file mode 100644
index 00000000..87032551
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaEntity.java
@@ -0,0 +1,56 @@
+package kr.ac.kookmin.stream.db.welfare;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.db.common.BaseTimeEntity;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(
+ name = "feedback_rounds",
+ uniqueConstraints = @UniqueConstraint(name = "uk_feedback_rounds_year_round", columnNames = {"year", "round"})
+)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class FeedbackRoundJpaEntity extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "feedback_round_id")
+ private Long id;
+
+ @Column(nullable = false)
+ private int year;
+
+ @Column(nullable = false)
+ private int round;
+
+ @Column(name = "opens_at", nullable = false)
+ private LocalDateTime opensAt;
+
+ @Column(name = "closes_at", nullable = false)
+ private LocalDateTime closesAt;
+
+ private FeedbackRoundJpaEntity(FeedbackRound feedbackRound) {
+ this.id = feedbackRound.id();
+ this.year = feedbackRound.year();
+ this.round = feedbackRound.round();
+ this.opensAt = feedbackRound.opensAt();
+ this.closesAt = feedbackRound.closesAt();
+ }
+
+ public static FeedbackRoundJpaEntity from(FeedbackRound feedbackRound) {
+ return new FeedbackRoundJpaEntity(feedbackRound);
+ }
+
+ public FeedbackRound toDomain() {
+ return FeedbackRound.of(id, year, round, opensAt, closesAt);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaRepository.java
new file mode 100644
index 00000000..b5fbdcb1
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundJpaRepository.java
@@ -0,0 +1,20 @@
+package kr.ac.kookmin.stream.db.welfare;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface FeedbackRoundJpaRepository extends JpaRepository {
+
+ @Query("SELECT r FROM FeedbackRoundJpaEntity r WHERE :now BETWEEN r.opensAt AND r.closesAt")
+ Optional findOpenAt(@Param("now") LocalDateTime now);
+
+ @Query("SELECT DISTINCT r.year FROM FeedbackRoundJpaEntity r ORDER BY r.year DESC")
+ List findDistinctYears();
+
+ @Query("SELECT r.round FROM FeedbackRoundJpaEntity r WHERE r.year = :year ORDER BY r.round ASC")
+ List findRoundsByYear(@Param("year") int year);
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundRepositoryImpl.java
new file mode 100644
index 00000000..1aa0978d
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/FeedbackRoundRepositoryImpl.java
@@ -0,0 +1,31 @@
+package kr.ac.kookmin.stream.db.welfare;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.FeedbackRound;
+import kr.ac.kookmin.stream.welfare.domain.feedback.repository.FeedbackRoundRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class FeedbackRoundRepositoryImpl implements FeedbackRoundRepository {
+
+ private final FeedbackRoundJpaRepository feedbackRoundJpaRepository;
+
+ @Override
+ public Optional findOpenAt(LocalDateTime now) {
+ return feedbackRoundJpaRepository.findOpenAt(now).map(FeedbackRoundJpaEntity::toDomain);
+ }
+
+ @Override
+ public List findDistinctYears() {
+ return feedbackRoundJpaRepository.findDistinctYears();
+ }
+
+ @Override
+ public List findRoundsByYear(int year) {
+ return feedbackRoundJpaRepository.findRoundsByYear(year);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaEntity.java
index 57ab15e5..7ff3cadb 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaEntity.java
@@ -23,14 +23,20 @@ public class OpenFeedbackJpaEntity extends BaseSoftDeleteEntity {
private Long id;
@Column(nullable = false)
- private int round;
+ private int year;
- @Column(nullable = false, length = 20)
- private String category;
+ @Column(nullable = false)
+ private int round;
- @Column(nullable = false, length = 1000)
+ @Column(nullable = false, length = 500)
private String question;
+ // BaseTimeEntity의 createdAt(DB DEFAULT CURRENT_TIMESTAMP, insertable=false)에 기대지 않고 직접 값을 채운다.
+ // insertable=false 컬럼은 save() 직후엔 DB에서 다시 읽어오기 전까지 자바 객체에 null로 남아있어서,
+ // 등록 응답에 질문 시각을 바로 내려줘야 하는 이 도메인엔 맞지 않는다.
+ @Column(name = "questioned_at", nullable = false)
+ private LocalDateTime questionedAt;
+
@Column(columnDefinition = "TEXT")
private String answer;
@@ -44,14 +50,15 @@ public class OpenFeedbackJpaEntity extends BaseSoftDeleteEntity {
private Long answeredBy;
private OpenFeedbackJpaEntity(OpenFeedback feedback) {
- this.id = feedback.getId();
- this.round = feedback.getRound();
- this.category = feedback.getCategory();
- this.question = feedback.getQuestion();
- this.answer = feedback.getAnswer();
- this.answeredAt = feedback.getAnsweredAt();
- this.createdBy = feedback.getCreatedBy();
- this.answeredBy = feedback.getAnsweredBy();
+ this.id = feedback.id();
+ this.year = feedback.year();
+ this.round = feedback.round();
+ this.question = feedback.question();
+ this.questionedAt = feedback.questionedAt();
+ this.answer = feedback.answer();
+ this.answeredAt = feedback.answeredAt();
+ this.createdBy = feedback.createdBy();
+ this.answeredBy = feedback.answeredBy();
}
public static OpenFeedbackJpaEntity from(OpenFeedback feedback) {
@@ -59,6 +66,6 @@ public static OpenFeedbackJpaEntity from(OpenFeedback feedback) {
}
public OpenFeedback toDomain() {
- return OpenFeedback.of(id, round, category, question, answer, answeredAt, createdBy, answeredBy);
+ return OpenFeedback.of(id, year, round, question, questionedAt, answer, answeredAt, createdBy, answeredBy);
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaRepository.java
new file mode 100644
index 00000000..ac24bdd7
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackJpaRepository.java
@@ -0,0 +1,28 @@
+package kr.ac.kookmin.stream.db.welfare;
+
+import java.util.Optional;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface OpenFeedbackJpaRepository extends JpaRepository {
+
+ Optional findByIdAndIsDeletedFalse(Long id);
+
+ @Query(value = """
+ SELECT f FROM OpenFeedbackJpaEntity f
+ WHERE f.isDeleted = false
+ AND (:year IS NULL OR f.year = :year)
+ AND (:round IS NULL OR f.round = :round)
+ ORDER BY f.questionedAt DESC, f.id DESC
+ """,
+ countQuery = """
+ SELECT COUNT(f) FROM OpenFeedbackJpaEntity f
+ WHERE f.isDeleted = false
+ AND (:year IS NULL OR f.year = :year)
+ AND (:round IS NULL OR f.round = :round)
+ """)
+ Page search(@Param("year") Integer year, @Param("round") Integer round, Pageable pageable);
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackRepositoryImpl.java
new file mode 100644
index 00000000..f585850c
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/OpenFeedbackRepositoryImpl.java
@@ -0,0 +1,42 @@
+package kr.ac.kookmin.stream.db.welfare;
+
+import java.util.Optional;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.welfare.domain.feedback.domain.OpenFeedback;
+import kr.ac.kookmin.stream.welfare.domain.feedback.repository.OpenFeedbackRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class OpenFeedbackRepositoryImpl implements OpenFeedbackRepository {
+
+ private final OpenFeedbackJpaRepository openFeedbackJpaRepository;
+
+ @Override
+ public Optional findById(Long id) {
+ return openFeedbackJpaRepository.findByIdAndIsDeletedFalse(id).map(OpenFeedbackJpaEntity::toDomain);
+ }
+
+ @Override
+ public PageResult search(Integer year, Integer round, PageOffset pageOffset) {
+ Page result = openFeedbackJpaRepository.search(
+ year, round, PageRequest.of(pageOffset.page(), pageOffset.size())
+ );
+
+ return PageResult.of(
+ result.getContent().stream().map(OpenFeedbackJpaEntity::toDomain).toList(),
+ result.getNumber(),
+ result.getSize(),
+ result.getTotalElements()
+ );
+ }
+
+ @Override
+ public OpenFeedback save(OpenFeedback feedback) {
+ return openFeedbackJpaRepository.save(OpenFeedbackJpaEntity.from(feedback)).toDomain();
+ }
+}
diff --git a/infrastructure/db/src/main/resources/db/migration/V12__create_feedback_rounds_and_update_open_feedbacks.sql b/infrastructure/db/src/main/resources/db/migration/V12__create_feedback_rounds_and_update_open_feedbacks.sql
new file mode 100644
index 00000000..ee951064
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V12__create_feedback_rounds_and_update_open_feedbacks.sql
@@ -0,0 +1,22 @@
+CREATE TABLE feedback_rounds (
+ feedback_round_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ year INT NOT NULL,
+ round INT NOT NULL,
+ opens_at DATETIME NOT NULL,
+ closes_at DATETIME NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_feedback_rounds_year_round (year, round)
+);
+
+-- open_feedbacks(V3)는 이미 머지된 파일이라 직접 수정하지 않고 여기서 이어서 변경한다.
+-- 회차가 연도별로 1차부터 다시 시작하도록 year 컬럼을 추가하고, 화면에 카테고리 선택 UI가 없어 category는 제거한다.
+-- questioned_at은 BaseTimeEntity의 created_at(DEFAULT CURRENT_TIMESTAMP, insertable=false) 대신 애플리케이션이 직접 채우는
+-- 전용 컬럼이다 — 등록 응답에 질문 시각을 그 자리에서 바로 내려줘야 하기 때문이다.
+ALTER TABLE open_feedbacks
+ ADD COLUMN year INT NOT NULL AFTER round,
+ ADD COLUMN questioned_at DATETIME NOT NULL AFTER question,
+ MODIFY COLUMN question VARCHAR(500) NOT NULL,
+ DROP COLUMN category;
+
+CREATE INDEX idx_open_feedbacks_year_round ON open_feedbacks (year, round);