Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
446d15b
fix: 열린피드백 회차 판정과 질문 시각에 한국 시간대를 명시
xeoxxn Oct 17, 2026
14fb958
feat: 열린피드백 회차에 답변 공개 시각을 추가
xeoxxn Oct 17, 2026
2ab16f0
feat: 열린피드백 회차 상태와 답변 집계를 도메인에 추가
xeoxxn Oct 17, 2026
897cbf2
feat: 관리자 회차 조회와 질문 검색에 필요한 저장소 메서드를 추가
xeoxxn Oct 17, 2026
486cf8e
feat: 열린피드백 회차 관리와 답변 임시저장·공개 서비스를 추가
xeoxxn Oct 17, 2026
0a23b80
feat: 열린피드백 관리자 API를 추가
xeoxxn Oct 17, 2026
08674ea
Merge remote-tracking branch 'origin/main' into feat/#139-open-feedba…
xeoxxn Oct 28, 2026
8127a1f
fix: 열린피드백 질문 검색어의 LIKE 와일드카드를 이스케이프
xeoxxn Oct 28, 2026
018eda6
refactor: 열린피드백 질문·회차 도메인을 record에서 클래스로 변경
xeoxxn Oct 28, 2026
80de2a1
refactor: 열린피드백 운영진 기능을 AdminFeedbackService로 분리
xeoxxn Oct 28, 2026
197567a
refactor: 열린피드백 운영진 API 경로를 /v1/admin/feedback 아래로 이동
xeoxxn Oct 28, 2026
431fb13
fix: 답변 공개를 질문 접수가 끝난 회차에서만 허용
xeoxxn Oct 28, 2026
3173d76
fix: 진행 중 회차를 회차 번호 대신 접수 시작일 기준으로 선택
xeoxxn Oct 28, 2026
058cf52
refactor: 회차 기간·겹침 검증을 validate 메서드로 분리
xeoxxn Oct 28, 2026
0236e31
refactor: 회차 집계 결과를 FeedbackCountProjection record로 조회
xeoxxn Oct 28, 2026
9902e5d
fix: 답변 저장 시 바뀐 컬럼만 UPDATE하도록 @DynamicUpdate 추가
xeoxxn Oct 28, 2026
7fb246e
fix: 회차 번호 동시 배정의 유니크 위반을 409로 응답
xeoxxn Oct 28, 2026
b48629f
refactor: 열린피드백 질문 작성자 이름을 member로 통일
xeoxxn Oct 28, 2026
fe9f24b
feat: 답변 저장 요청에 같은 질문이 중복되면 거절
xeoxxn Oct 28, 2026
0e34f9e
chore: Flyway 마이그레이션 버전 조정
xeoxxn Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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}가 맡는다.
* <p>
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다.
*/
@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<PageResponse<AdminFeedbackResponse>> getFeedbacks(
AdminApiUser apiUser,
Long roundId,
String keyword,
@ParameterObject PageParams pageParams
);
}
Original file line number Diff line number Diff line change
@@ -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.MemberFeedback;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.dto.PageResponse;
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/feedback/questions")
@RequiredArgsConstructor
public class AdminFeedbackController implements AdminFeedbackApi {

private final AdminFeedbackSearchUseCase adminFeedbackSearchUseCase;

@Override
@GetMapping
public ApiResponse<PageResponse<AdminFeedbackResponse>> getFeedbacks(
AdminApiUser apiUser,
@RequestParam(required = false) Long roundId,
@RequestParam(required = false) String keyword,
@Valid @ModelAttribute PageParams pageParams
) {
PageResult<MemberFeedback> result =
adminFeedbackSearchUseCase.search(roundId, keyword, pageParams.toOffset());

return ApiResponse.success(PageResponse.from(result, AdminFeedbackResponse::from));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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}가 맡는다.
* <p>
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
* 파라미터 바인딩(@{@code RequestParam}, @{@code PathVariable}, @{@code RequestBody} 등)은 구현체에 둔다.
*/
@Tag(name = "열린피드백 회차 관리", description = "운영진 열린피드백 회차 등록·조회와 답변 작성·공개")
public interface AdminFeedbackRoundApi {

/** 관리 화면 상단 요약. 답변을 아직 공개하지 않은 회차 중 지금 진행 중인 회차를 내려준다. */
@Operation(summary = "진행 중 회차 조회",
description = "답변을 아직 공개하지 않은 회차 중 접수가 시작된 회차(질문접수중·답변작성중)를 시작일이 가장 늦은 것으로 "
+ "조회하고, 그런 회차가 없으면 시작일이 가장 가까운 오픈예정 회차를 조회한다. "
+ "둘 다 없으면 data가 null이다.")
ApiResponse<FeedbackRoundResponse> 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<PageResponse<FeedbackRoundResponse>> getRounds(
AdminApiUser apiUser,
Integer year,
String status,
@ParameterObject PageParams pageParams
);

/** 회차 등록. 접수 기간만 받고 연도·회차 번호는 서버가 배정한다. */
@Operation(summary = "피드백 회차 등록",
description = "질문 접수 기간(날짜)만 받아 회차를 연다. 시각은 시작일 00:00:00 ~ 마감일 23:59:59로 채우고, "
+ "연도는 시작일 기준, 회차 번호는 그 연도의 최대 + 1로 배정한다. 다른 회차와 접수 기간이 겹치면 409다. "
+ "동시에 등록해 같은 회차 번호가 배정되면 늦은 쪽이 409이고, 다시 요청하면 다음 번호로 등록된다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(
type = FeedbackErrorCode.class,
codes = {"INVALID_FEEDBACK_ROUND_PERIOD", "FEEDBACK_ROUND_OVERLAPPED", "FEEDBACK_ROUND_ALREADY_EXISTS"}
)
ApiResponse<FeedbackRoundResponse> createRound(AdminApiUser apiUser, FeedbackRoundCreateRequest request);

/** 회차 답변 보기 모달. 그 회차의 질문 전체를 답변과 함께 내려준다. */
@Operation(summary = "회차 답변 내역 조회",
description = "그 회차에 접수된 질문 전체를 질문 시각 오름차순으로 조회한다. 답변이 없는 질문도 포함된다.")
@ApiErrorCode(type = FeedbackErrorCode.class, codes = {"FEEDBACK_ROUND_NOT_FOUND"})
ApiResponse<FeedbackRoundDetailResponse> getRoundAnswers(AdminApiUser apiUser, Long roundId);

/** 답변 임시저장. 저장만 하고 학생에게는 보이지 않는다. */
@Operation(summary = "피드백 답변 일괄 등록·수정",
description = "회차 단위로 답변을 저장한다. 보낸 것만 반영하고 이미 답변이 있는 질문은 덮어쓴다. "
+ "한 요청에 같은 질문(feedbackId)을 두 번 보내면 400(INVALID_INPUT)이다. "
+ "여기서 저장한 답변은 관리자에게만 보이고, 답변 공개 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<FeedbackRoundDetailResponse> saveAnswers(
AdminApiUser apiUser, Long roundId, FeedbackAnswerSaveRequest request);

/** 답변 공개. 그 회차의 답변을 학생 앱에 한꺼번에 띄운다. */
@Operation(summary = "피드백 답변 공개",
description = "그 회차에 작성된 답변을 학생에게 공개한다. 공개는 회차 단위이고, 질문 접수가 끝난 회차(답변작성중)만 "
+ "공개할 수 있다(오픈예정·질문접수중이면 409). "
+ "이미 공개한 회차를 다시 불러도 공개 시각을 바꾸지 않고 성공으로 응답한다(멱등).")
@ApiErrorCode(
type = FeedbackErrorCode.class,
codes = {"FEEDBACK_ANSWER_NOT_FOUND", "FEEDBACK_ROUND_NOT_FOUND", "FEEDBACK_QUESTION_PERIOD_NOT_ENDED"}
)
ApiResponse<FeedbackRoundDetailResponse> publishAnswers(AdminApiUser apiUser, Long roundId);
}
Original file line number Diff line number Diff line change
@@ -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.AdminFeedbackService;
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 AdminFeedbackService adminFeedbackService;
private final AdminFeedbackAnswerUseCase adminFeedbackAnswerUseCase;

@Override
@GetMapping("/current")
public ApiResponse<FeedbackRoundResponse> getCurrentRound(AdminApiUser apiUser) {
// 진행 중 회차가 없으면 data를 null로 내려 화면이 "진행 중인 회차 없음"을 그린다
return ApiResponse.success(
adminFeedbackService.getCurrentRound().map(FeedbackRoundResponse::from).orElse(null));
}

@Override
@GetMapping
public ApiResponse<PageResponse<FeedbackRoundResponse>> getRounds(
AdminApiUser apiUser,
@RequestParam(required = false) Integer year,
@RequestParam(required = false) String status,
@Valid @ModelAttribute PageParams pageParams
) {
FeedbackRoundStatus roundStatus = FeedbackRoundStatus.from(status);
PageResult<FeedbackRoundSummary> result =
adminFeedbackService.getRounds(year, roundStatus, pageParams.toOffset());

return ApiResponse.success(PageResponse.from(result, FeedbackRoundResponse::from));
}

@Override
@PostMapping
public ApiResponse<FeedbackRoundResponse> createRound(
AdminApiUser apiUser,
@Valid @RequestBody FeedbackRoundCreateRequest request
) {
FeedbackRoundSummary created = adminFeedbackService.createRound(request.opensOn(), request.closesOn());
return ApiResponse.success(FeedbackRoundResponse.from(created));
}

@Override
@GetMapping("/{roundId}")
public ApiResponse<FeedbackRoundDetailResponse> getRoundAnswers(
AdminApiUser apiUser,
@PathVariable Long roundId
) {
return ApiResponse.success(
FeedbackRoundDetailResponse.from(adminFeedbackAnswerUseCase.getRoundAnswers(roundId)));
}

@Override
@PatchMapping("/{roundId}/answers")
public ApiResponse<FeedbackRoundDetailResponse> 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<FeedbackRoundDetailResponse> publishAnswers(
AdminApiUser apiUser,
@PathVariable Long roundId
) {
return ApiResponse.success(
FeedbackRoundDetailResponse.from(adminFeedbackAnswerUseCase.publishAnswers(roundId)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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<FeedbackAnswerCommand> toCommands() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 같은 feedbackId가 두 번 오면 둘 다 저장되고 나중 것이 남아요. 화면에서 그럴 일은 드물겠지만 요청만 보고는 어느 답변이 남을지 알 수 없어요. toCommands() 안에서 validateFeedbackIdsNotDuplicated()로 막아두면 좋을 것 같아요(MemberSignUpRequest의 약관 중복 검사와 같은 방식이에요).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fe9f24b 수정했습니다!

validateFeedbackIdsNotDuplicated();

return answers.stream()
.map(item -> FeedbackAnswerCommand.of(item.feedbackId(), item.answer()))
.toList();
}

// 같은 질문이 두 번 오면 어느 답변이 남을지 요청만 보고 알 수 없다.
// IllegalArgumentException → GlobalExceptionHandler가 INVALID_INPUT(400)으로 응답한다
private void validateFeedbackIdsNotDuplicated() {
long distinctFeedbackIdCount = answers.stream()
.map(Item::feedbackId)
.distinct()
.count();
if (distinctFeedbackIdCount != answers.size()) {
throw new IllegalArgumentException("같은 질문의 답변이 중복되었습니다.");
}
}

public record Item(

@Schema(description = "답변할 피드백 아이디", example = "21")
@NotNull(message = "답변할 피드백을 선택해 주세요.")
Long feedbackId,

// answer는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
@Schema(description = "답변 내용. `**텍스트**`로 감싼 부분은 굵게 렌더링하기로 한 약속이다",
example = "시험 기간에는 열람실을 **24시간** 개방합니다.")
@NotBlank(message = "답변 내용은 필수 입력값입니다.")
@Size(max = 16000, message = "답변 내용은 16000자 이하로 입력해 주세요.")
String answer
) {
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package kr.ac.kookmin.stream.api.admin.welfare.feedback.response;

import java.time.LocalDateTime;
import kr.ac.kookmin.stream.api.admin.welfare.feedback.usecase.MemberFeedback;
import kr.ac.kookmin.stream.welfare.domain.feedback.domain.Feedback;

// 현재 회차 질문 탭의 한 줄. memberName은 탈퇴(소프트 삭제)한 회원의 질문이면 null이다
public record AdminFeedbackResponse(
Long feedbackId,
int year,
int round,
String question,
String memberName,
LocalDateTime questionedAt,
String answer,
LocalDateTime answeredAt
) {

public static AdminFeedbackResponse from(MemberFeedback memberFeedback) {
Feedback feedback = memberFeedback.feedback();

return new AdminFeedbackResponse(
feedback.getId(),
feedback.getYear(),
feedback.getRound(),
feedback.getQuestion(),
memberFeedback.memberName(),
feedback.getQuestionedAt(),
feedback.getAnswer(),
feedback.getAnsweredAt()
);
}
}
Loading