Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions api/admin-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**"
dependencies {
implementation(project(":api:common-api"))
implementation(project(":core:common"))
implementation(project(":core:domain:event"))
implementation(project(":core:domain:file"))
implementation(project(":core:domain:internal"))
implementation(project(":core:domain:member"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package kr.ac.kookmin.stream.api.admin.event.event;

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.event.event.request.EventCreateRequest;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse;
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.event.domain.event.domain.EventErrorCode;
import kr.ac.kookmin.stream.file.domain.FileErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
* 운영진 행사 API의 문서 명세. 구현은 {@link AdminEventController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정")
public interface AdminEventApi {

/** 행사 등록. 작성자는 토큰의 관리자로 정해진다. */
@Operation(summary = "행사 등록",
description = "행사와 신청서 질문을 등록한다. 이미지는 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 "
+ "있으면 등록하지 않는다. 새 행사는 비공개로 만들어진다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(
type = EventErrorCode.class,
codes = {"EVENT_INVALID_PERIOD", "EVENT_INVALID_CAPACITY", "EVENT_INVALID_QUESTION"}
)
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
ApiResponse<EventCreateResponse> createEvent(AdminApiUser apiUser, EventCreateRequest request);

/** 행사 목록. 비공개 행사도 함께 내려준다. */
@Operation(summary = "행사 목록 조회",
description = "삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순으로 페이지 단위 조회한다. keyword로 제목을 검색한다. "
+ "모집 상태는 조회 시점의 신청 기간·잔여 정원으로 계산한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
ApiResponse<PageResponse<AdminEventListItemResponse>> getEvents(
AdminApiUser apiUser,
@ParameterObject EventSearchParams params,
@ParameterObject PageParams pageParams
);

/** 행사 상세. 수정 화면의 초기값으로 쓴다. */
@Operation(summary = "행사 상세 조회",
description = "행사 정보와 신청서 질문 전체를 조회한다. 비공개 행사도 조회할 수 있다.")
@ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"})
ApiResponse<AdminEventDetailResponse> getEvent(AdminApiUser apiUser, Long eventId);

/** 행사 수정. 요청의 수정 시각이 저장된 값과 다르면 거부한다. */
@Operation(summary = "행사 수정",
description = "행사 내용과 신청서 질문을 통째로 바꾼다. 수정 화면이 행사를 읽은 뒤 다른 관리자가 먼저 고쳤다면"
+ "(수정 시각 불일치) 409로 거부한다. 신청 내역(취소 포함)이 있는 행사는 질문을 바꿀 수 없고, "
+ "선착순 정원은 현재 신청자 수보다 줄일 수 없다. 공개 여부는 바꾸지 않는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(
type = EventErrorCode.class,
codes = {
"EVENT_NOT_FOUND", "EVENT_INVALID_PERIOD", "EVENT_INVALID_CAPACITY", "EVENT_INVALID_QUESTION",
"EVENT_QUESTIONS_LOCKED", "EVENT_CAPACITY_BELOW_APPLICANTS", "EVENT_UPDATE_CONFLICT"
}
)
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
ApiResponse<EventUpdateResponse> updateEvent(AdminApiUser apiUser, Long eventId, EventUpdateRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package kr.ac.kookmin.stream.api.admin.event.event;

import jakarta.validation.Valid;
import java.time.LocalDateTime;
import java.util.Map;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse;
import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventWriteUseCase;
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.event.domain.event.domain.Event;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail;
import kr.ac.kookmin.stream.event.domain.event.service.EventService;
import kr.ac.kookmin.stream.file.domain.File;
import kr.ac.kookmin.stream.file.service.FileService;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/admin/events")
@RequiredArgsConstructor
public class AdminEventController implements AdminEventApi {

private final EventService eventService;
private final FileService fileService;
private final AdminEventWriteUseCase adminEventWriteUseCase;

@Override
@PostMapping
public ApiResponse<EventCreateResponse> createEvent(
AdminApiUser apiUser,
@Valid @RequestBody EventCreateRequest request
) {
Event event = adminEventWriteUseCase.create(apiUser.userId(), request.toCommand());
return ApiResponse.success(EventCreateResponse.from(event));
}

@Override
@GetMapping
public ApiResponse<PageResponse<AdminEventListItemResponse>> getEvents(
AdminApiUser apiUser,
@Valid @ModelAttribute EventSearchParams params,
@Valid @ModelAttribute PageParams pageParams
) {
PageResult<Event> events = eventService.getEvents(params.toKeyword(), pageParams.toOffset());
Map<Long, Long> applicantCounts = eventService.getApplicantCounts(
events.content().stream().map(Event::getId).toList());
LocalDateTime now = LocalDateTime.now();

return ApiResponse.success(PageResponse.from(events, event ->
AdminEventListItemResponse.of(event, applicantCounts.getOrDefault(event.getId(), 0L), now)));
}

@Override
@GetMapping("/{eventId}")
public ApiResponse<AdminEventDetailResponse> getEvent(AdminApiUser apiUser, @PathVariable Long eventId) {
EventManageDetail detail = eventService.getEvent(eventId);
Map<Long, File> imagesById = fileService.findAllByIdIn(detail.imageIds());

return ApiResponse.success(AdminEventDetailResponse.of(detail, imagesById, LocalDateTime.now()));
}

@Override
@PutMapping("/{eventId}")
public ApiResponse<EventUpdateResponse> updateEvent(
AdminApiUser apiUser,
@PathVariable Long eventId,
@Valid @RequestBody EventUpdateRequest request
) {
Event event = adminEventWriteUseCase.update(eventId, request.toCommand());
return ApiResponse.success(EventUpdateResponse.from(event));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package kr.ac.kookmin.stream.api.admin.event.event.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;

/**
* 작성자는 요청 본문이 아니라 토큰의 관리자 ID로 정한다. 공개 여부는 받지 않고 비공개로 시작한다.
*/
public record EventCreateRequest(
@Schema(description = "행사 제목", example = "체육대회")
@NotBlank(message = "행사 제목을 입력해 주세요.")
@Size(max = 200, message = "행사 제목은 200자 이하로 입력해 주세요.")
String title,

// description은 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
@Schema(description = "행사 소개글", example = "학과 대항 체육대회입니다.")
@NotBlank(message = "행사 소개글을 입력해 주세요.")
@Size(max = 16000, message = "행사 소개글은 16000자 이하로 입력해 주세요.")
String description,

@Schema(description = "행사 장소", example = "종합운동장")
@Size(max = 255, message = "행사 장소는 255자 이하로 입력해 주세요.")
String place,

@Schema(description = "행사 시작 일시", example = "2026-11-01T10:00:00")
@NotNull(message = "행사 시작 일시를 입력해 주세요.")
LocalDateTime eventStartAt,

@Schema(description = "행사 종료 일시. 시작 일시보다 늦어야 한다", example = "2026-11-01T18:00:00")
@NotNull(message = "행사 종료 일시를 입력해 주세요.")
LocalDateTime eventEndAt,

@Schema(description = "신청 시작 일시", example = "2026-10-14T10:00:00")
@NotNull(message = "신청 시작 일시를 입력해 주세요.")
LocalDateTime applyStartAt,

@Schema(description = "신청 마감 일시. 시작 일시보다 늦어야 한다", example = "2026-10-25T23:59:59")
@NotNull(message = "신청 마감 일시를 입력해 주세요.")
LocalDateTime applyEndAt,

@Schema(description = "모집 방식. 선착순이면 FIRST_COME, 인원 제한이 없으면 OPEN")
@NotNull(message = "모집 방식을 입력해 주세요.")
RecruitType recruitType,

@Schema(description = "모집 정원. FIRST_COME이면 1 이상 필요하고, OPEN이면 무시한다", example = "100")
Integer capacity,

@Schema(description = "학생회비 납부자만 신청 가능 여부")
@NotNull(message = "학생회비 납부자 전용 여부를 입력해 주세요.")
Boolean feePaidOnly,

@Schema(description = "행사 이미지 파일 ID 목록. 첫 번째가 대표 이미지가 된다", example = "[31, 32]")
@NotNull(message = "이미지 파일 목록을 입력해 주세요.")
@Size(max = 10, message = "이미지는 10장까지 등록할 수 있습니다.")
List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageIds,

@Schema(description = "신청서 질문 목록. 목록 순서대로 신청서에 노출된다")
@NotNull(message = "신청서 질문 목록을 입력해 주세요.")
@Valid
List<@NotNull(message = "신청서 질문이 비어 있습니다.") EventQuestionRequest> questions
) {

public EventCreateCommand toCommand() {
return new EventCreateCommand(
title,
description,
place,
eventStartAt,
eventEndAt,
applyStartAt,
applyEndAt,
recruitType,
capacity,
feePaidOnly,
imageIds.stream().distinct().toList(),
questions.stream().map(EventQuestionRequest::toCommand).toList()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package kr.ac.kookmin.stream.api.admin.event.event.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand;
import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType;

/**
* 신청서 질문 한 건. 행사 등록·수정 요청이 함께 쓴다. 노출 순서는 목록에서의 위치로 정하므로 따로 받지 않는다.
*/
public record EventQuestionRequest(
@Schema(description = "질문 유형")
@NotNull(message = "질문 유형을 입력해 주세요.")
QuestionType questionType,

@Schema(description = "질문 내용", example = "참여 종목")
@NotBlank(message = "질문 내용을 입력해 주세요.")
@Size(max = 500, message = "질문 내용은 500자 이하로 입력해 주세요.")
String questionText,

@Schema(description = "필수 응답 여부")
@NotNull(message = "필수 응답 여부를 입력해 주세요.")
Boolean isRequired,

@Schema(description = "선택지 목록. 선택형이면 1개 이상 필요하고, 텍스트형이면 무시한다", example = "[\"축구\", \"농구\"]")
List<@NotBlank(message = "선택지는 비워 둘 수 없습니다.") String> options,

@Schema(description = "'기타'(직접 입력) 선택지 추가 여부. 선택형에서만 쓰며 기본값은 false")
Boolean hasOtherOption
) {

public EventQuestionCommand toCommand() {
return new EventQuestionCommand(
questionText,
questionType,
isRequired,
options == null ? List.of() : options,
Boolean.TRUE.equals(hasOtherOption)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kr.ac.kookmin.stream.api.admin.event.event.request;

import io.swagger.v3.oas.annotations.media.Schema;

public record EventSearchParams(
@Schema(description = "행사 제목 검색어", example = "체육대회")
String keyword
) {

// 빈 검색어는 검색하지 않는 것으로 본다
public String toKeyword() {
return keyword == null || keyword.isBlank() ? null : keyword.strip();
}
}
Loading
Loading