From 7c98aec0f3743120b35edf01c545b7248e632297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Thu, 6 Aug 2026 22:19:30 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B0=95=EC=9D=98=20=EC=83=9D=EC=84=B1=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#2322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 강의 생성 에러 코드 추가 * feat: 관리자 강의 생성 로직 추가 * feat: 관리자 강의 생성 API 추가 * refactor: 학기 형식 에러 처리 이동 * feat: 관리자 강의 일괄 생성 지원 * refactor: 강의 API 응답 코드 명세 변경 * refactor: 관리자 학기 저장소 분리 * refactor: 관리자 강의 학기 입력 타입 변경 * refactor: 강의 중복 키 모델 분리 * refactor: 강의 중복 키 생성 방식 변경 * docs: 관리자 강의 API 설명 보강 --- .../koin/admin/history/enums/DomainType.java | 1 + .../lecture/controller/AdminLectureApi.java | 79 +++++++++++ .../controller/AdminLectureController.java | 33 +++++ .../dto/AdminLectureCreateRequest.java | 126 ++++++++++++++++++ .../koin/admin/lecture/model/LectureKey.java | 11 ++ .../repository/AdminLectureRepository.java | 14 ++ .../repository/AdminSemesterRepository.java | 24 ++++ .../lecture/service/AdminLectureService.java | 48 +++++++ .../exception/InvalidTermFormatException.java | 20 --- .../koin/domain/timetableV3/model/Term.java | 6 +- .../koin/global/code/ApiResponseCode.java | 2 + 11 files changed, 342 insertions(+), 22 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureApi.java create mode 100644 src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureController.java create mode 100644 src/main/java/in/koreatech/koin/admin/lecture/dto/AdminLectureCreateRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/lecture/model/LectureKey.java create mode 100644 src/main/java/in/koreatech/koin/admin/lecture/repository/AdminLectureRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/lecture/repository/AdminSemesterRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/lecture/service/AdminLectureService.java delete mode 100644 src/main/java/in/koreatech/koin/domain/timetableV3/exception/InvalidTermFormatException.java diff --git a/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java b/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java index 25a43a3f0f..f6d11c71a8 100644 --- a/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java +++ b/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java @@ -26,6 +26,7 @@ public enum DomainType { ABTEST("AB 테스트"), LANDS("복덕방"), + LECTURES("강의"), COOP_SHOPS("생협 매장"), USERS("회원"), diff --git a/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureApi.java b/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureApi.java new file mode 100644 index 0000000000..123bfa42f6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureApi.java @@ -0,0 +1,79 @@ +package in.koreatech.koin.admin.lecture.controller; + +import static in.koreatech.koin.admin.history.enums.DomainType.LECTURES; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; +import static in.koreatech.koin.global.code.ApiResponseCode.DUPLICATE_LECTURE; +import static in.koreatech.koin.global.code.ApiResponseCode.FORBIDDEN_ADMIN; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_REQUEST_BODY; +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_SEMESTER; +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_READABLE_HTTP_MESSAGE; +import static in.koreatech.koin.global.code.ApiResponseCode.OK; +import static in.koreatech.koin.global.code.ApiResponseCode.UNAUTHORIZED_USER; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import in.koreatech.koin.admin.history.aop.AdminActivityLogging; +import in.koreatech.koin.admin.lecture.dto.AdminLectureCreateRequest; +import in.koreatech.koin.global.auth.Auth; +import in.koreatech.koin.global.code.ApiResponseCodes; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Admin) Lecture: 강의", description = "관리자 권한으로 강의를 관리한다") +public interface AdminLectureApi { + + @ApiResponseCodes({ + OK, + INVALID_REQUEST_BODY, + UNAUTHORIZED_USER, + FORBIDDEN_ADMIN, + NOT_READABLE_HTTP_MESSAGE, + NOT_FOUND_SEMESTER, + DUPLICATE_LECTURE + }) + @Operation( + summary = "강의를 일괄 생성한다", + description = """ + ## 강의 일괄 생성 + 입력한 연도와 학기에 해당하는 강의들을 한 번에 생성합니다. + 요청한 학기가 존재하지 않거나 중복 강의가 포함된 경우 강의를 생성하지 않습니다. + 중복 여부는 학기, 과목 코드, 분반의 조합을 기준으로 판단합니다. + + ## 요청 Body 필드 설명 + - `year`: 강의를 등록할 연도 (필수, 양수) + - `term`: 강의를 등록할 학기 (필수) + - `FIRST`: 1학기 + - `SECOND`: 2학기 + - `SUMMER`: 여름학기 + - `WINTER`: 겨울학기 + - `lectures`: 생성할 강의 정보 리스트 (필수, 빈 리스트 불가) + - `code`: 과목 코드 (필수, 최대 10자) + - `name`: 과목 이름 (필수, 최대 50자) + - `grades`: 대상 학년 (필수, 최대 2자) + - `lecture_class`: 분반 (필수, 최대 3자) + - `regular_number`: 수강 인원 (필수, 최대 4자) + - `department`: 학부 (필수, 최대 30자) + - `target`: 수강 대상 (필수, 최대 200자) + - `professor`: 교수명 (선택, 최대 30자) + - `is_english`: 영어 강의 여부 (필수, 최대 2자) + - `design_score`: 설계 학점 (필수, 최대 2자) + - `is_elearning`: 이러닝 여부 (필수, 최대 2자) + - `class_time`: 강의 시간 코드 리스트 (필수, 최대 50개, 각 값은 0~999) + + ## 처리 결과 + - 모든 강의가 유효한 경우 일괄 생성하고 `200 OK`를 반환합니다. + - 요청 리스트 내부 또는 기존 강의와 중복되는 항목이 있으면 `DUPLICATE_LECTURE`를 반환합니다. + """ + ) + @SecurityRequirement(name = "Jwt Authentication") + @PostMapping("/admin/lectures") + @AdminActivityLogging(domain = LECTURES) + ResponseEntity createLectures( + @RequestBody @Valid AdminLectureCreateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureController.java b/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureController.java new file mode 100644 index 0000000000..99bafb5779 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/controller/AdminLectureController.java @@ -0,0 +1,33 @@ +package in.koreatech.koin.admin.lecture.controller; + +import static in.koreatech.koin.admin.history.enums.DomainType.LECTURES; +import static in.koreatech.koin.domain.user.model.UserType.ADMIN; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.admin.history.aop.AdminActivityLogging; +import in.koreatech.koin.admin.lecture.dto.AdminLectureCreateRequest; +import in.koreatech.koin.admin.lecture.service.AdminLectureService; +import in.koreatech.koin.global.auth.Auth; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class AdminLectureController implements AdminLectureApi { + + private final AdminLectureService adminLectureService; + + @PostMapping("/admin/lectures") + @AdminActivityLogging(domain = LECTURES) + public ResponseEntity createLectures( + @RequestBody @Valid AdminLectureCreateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminLectureService.createLectures(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/dto/AdminLectureCreateRequest.java b/src/main/java/in/koreatech/koin/admin/lecture/dto/AdminLectureCreateRequest.java new file mode 100644 index 0000000000..137c1fb391 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/dto/AdminLectureCreateRequest.java @@ -0,0 +1,126 @@ +package in.koreatech.koin.admin.lecture.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetable.model.Lecture; +import in.koreatech.koin.domain.timetableV3.model.Term; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminLectureCreateRequest( + @Schema(description = "연도", example = "2026", requiredMode = REQUIRED) + @NotNull(message = "연도는 필수입니다.") + @Positive(message = "연도는 양수여야 합니다.") + Integer year, + + @Schema(description = "학기", example = "FIRST", requiredMode = REQUIRED) + @NotNull(message = "학기는 필수입니다.") + Term term, + + @Schema(description = "강의 정보 리스트", requiredMode = REQUIRED) + @Valid + @NotEmpty(message = "강의 정보 리스트는 비어 있을 수 없습니다.") + List lectures +) { + + public List toEntities(String semester) { + return lectures.stream() + .map(lecture -> lecture.toEntity(semester)) + .toList(); + } + + @JsonNaming(SnakeCaseStrategy.class) + public record LectureRequest( + @Schema(description = "과목 코드", example = "ARB244", requiredMode = REQUIRED) + @NotBlank(message = "과목 코드는 필수입니다.") + @Size(max = 10, message = "과목 코드는 10자 이하여야 합니다.") + String code, + + @Schema(description = "과목 이름", example = "건축구조의 이해 및 실습", requiredMode = REQUIRED) + @NotBlank(message = "과목 이름은 필수입니다.") + @Size(max = 50, message = "과목 이름은 50자 이하여야 합니다.") + String name, + + @Schema(description = "대상 학년", example = "3", requiredMode = REQUIRED) + @NotBlank(message = "대상 학년은 필수입니다.") + @Size(max = 2, message = "대상 학년은 2자 이하여야 합니다.") + String grades, + + @Schema(description = "분반", example = "01", requiredMode = REQUIRED) + @NotBlank(message = "분반은 필수입니다.") + @Size(max = 3, message = "분반은 3자 이하여야 합니다.") + String lectureClass, + + @Schema(description = "수강 인원", example = "25", requiredMode = REQUIRED) + @NotNull(message = "수강 인원은 필수입니다.") + @Size(max = 4, message = "수강 인원은 4자 이하여야 합니다.") + String regularNumber, + + @Schema(description = "학부", example = "디자인ㆍ건축공학부", requiredMode = REQUIRED) + @NotBlank(message = "학부는 필수입니다.") + @Size(max = 30, message = "학부는 30자 이하여야 합니다.") + String department, + + @Schema(description = "대상", example = "디자 1 건축", requiredMode = REQUIRED) + @NotNull(message = "대상은 필수입니다.") + @Size(max = 200, message = "대상은 200자 이하여야 합니다.") + String target, + + @Schema(description = "교수", example = "황현식", requiredMode = NOT_REQUIRED) + @Size(max = 30, message = "교수명은 30자 이하여야 합니다.") + String professor, + + @Schema(description = "영어 강의 여부", example = "N", requiredMode = REQUIRED) + @NotNull(message = "영어 강의 여부는 필수입니다.") + @Size(max = 2, message = "영어 강의 여부는 2자 이하여야 합니다.") + String isEnglish, + + @Schema(description = "설계 학점", example = "0", requiredMode = REQUIRED) + @NotBlank(message = "설계 학점은 필수입니다.") + @Size(max = 2, message = "설계 학점은 2자 이하여야 합니다.") + String designScore, + + @Schema(description = "이러닝 여부", example = "N", requiredMode = REQUIRED) + @NotNull(message = "이러닝 여부는 필수입니다.") + @Size(max = 2, message = "이러닝 여부는 2자 이하여야 합니다.") + String isElearning, + + @Schema(description = "강의 시간", example = "[200, 201, 202, 203]", requiredMode = REQUIRED) + @NotNull(message = "강의 시간은 필수입니다.") + @Size(max = 50, message = "강의 시간은 최대 50개까지 입력할 수 있습니다.") + List<@NotNull @PositiveOrZero @Max(999) Integer> classTime + ) { + + public Lecture toEntity(String semester) { + return Lecture.builder() + .semester(semester) + .code(code) + .name(name) + .grades(grades) + .lectureClass(lectureClass) + .regularNumber(regularNumber) + .department(department) + .target(target) + .professor(professor) + .isEnglish(isEnglish) + .designScore(designScore) + .isElearning(isElearning) + .classTime(classTime.toString()) + .build(); + } + } +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/model/LectureKey.java b/src/main/java/in/koreatech/koin/admin/lecture/model/LectureKey.java new file mode 100644 index 0000000000..ccfd165a77 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/model/LectureKey.java @@ -0,0 +1,11 @@ +package in.koreatech.koin.admin.lecture.model; + +public record LectureKey( + String code, + String lectureClass +) { + + public static LectureKey of(String code, String lectureClass) { + return new LectureKey(code, lectureClass); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminLectureRepository.java b/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminLectureRepository.java new file mode 100644 index 0000000000..617bb68c6b --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminLectureRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.admin.lecture.repository; + +import java.util.List; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.model.Lecture; + +public interface AdminLectureRepository extends Repository { + + boolean existsBySemesterAndCodeAndLectureClass(String semester, String code, String lectureClass); + + List saveAll(Iterable lectures); +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminSemesterRepository.java b/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminSemesterRepository.java new file mode 100644 index 0000000000..b3ffe6d033 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/repository/AdminSemesterRepository.java @@ -0,0 +1,24 @@ +package in.koreatech.koin.admin.lecture.repository; + +import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_SEMESTER; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV3.model.Term; +import in.koreatech.koin.global.exception.CustomException; + +public interface AdminSemesterRepository extends Repository { + + Optional findByYearAndTerm(Integer year, Term term); + + default Semester getByYearAndTerm(Integer year, Term term) { + return findByYearAndTerm(year, term) + .orElseThrow(() -> CustomException.of( + NOT_FOUND_SEMESTER, + "year: " + year + ", term: " + term.getDescription() + )); + } +} diff --git a/src/main/java/in/koreatech/koin/admin/lecture/service/AdminLectureService.java b/src/main/java/in/koreatech/koin/admin/lecture/service/AdminLectureService.java new file mode 100644 index 0000000000..a583e0acaa --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/lecture/service/AdminLectureService.java @@ -0,0 +1,48 @@ +package in.koreatech.koin.admin.lecture.service; + +import static in.koreatech.koin.global.code.ApiResponseCode.DUPLICATE_LECTURE; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.lecture.dto.AdminLectureCreateRequest; +import in.koreatech.koin.admin.lecture.dto.AdminLectureCreateRequest.LectureRequest; +import in.koreatech.koin.admin.lecture.model.LectureKey; +import in.koreatech.koin.admin.lecture.repository.AdminLectureRepository; +import in.koreatech.koin.admin.lecture.repository.AdminSemesterRepository; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminLectureService { + + private final AdminLectureRepository adminLectureRepository; + private final AdminSemesterRepository adminSemesterRepository; + + @Transactional + public void createLectures(AdminLectureCreateRequest request) { + Semester semester = adminSemesterRepository.getByYearAndTerm(request.year(), request.term()); + + Set lectureKeys = new HashSet<>(); + for (LectureRequest lecture : request.lectures()) { + LectureKey lectureKey = LectureKey.of(lecture.code(), lecture.lectureClass()); + if (!lectureKeys.add(lectureKey) || adminLectureRepository.existsBySemesterAndCodeAndLectureClass( + semester.getSemester(), lecture.code(), lecture.lectureClass() + )) { + throw CustomException.of( + DUPLICATE_LECTURE, + "semester: " + semester.getSemester() + ", code: " + lecture.code() + + ", lectureClass: " + lecture.lectureClass() + ); + } + } + + adminLectureRepository.saveAll(request.toEntities(semester.getSemester())); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV3/exception/InvalidTermFormatException.java b/src/main/java/in/koreatech/koin/domain/timetableV3/exception/InvalidTermFormatException.java deleted file mode 100644 index 70bed37615..0000000000 --- a/src/main/java/in/koreatech/koin/domain/timetableV3/exception/InvalidTermFormatException.java +++ /dev/null @@ -1,20 +0,0 @@ -package in.koreatech.koin.domain.timetableV3.exception; - -import in.koreatech.koin.global.exception.custom.KoinIllegalArgumentException; - -public class InvalidTermFormatException extends KoinIllegalArgumentException { - - private static final String DEFAULT_MESSAGE = "term 양식이 잘못됐습니다."; - - public InvalidTermFormatException(String message) { - super(message); - } - - public InvalidTermFormatException(String message, String detail) { - super(message, detail); - } - - public static InvalidTermFormatException withDetail(String detail) { - return new InvalidTermFormatException(DEFAULT_MESSAGE, detail); - } -} diff --git a/src/main/java/in/koreatech/koin/domain/timetableV3/model/Term.java b/src/main/java/in/koreatech/koin/domain/timetableV3/model/Term.java index 08ab181e80..c39b1f1e58 100644 --- a/src/main/java/in/koreatech/koin/domain/timetableV3/model/Term.java +++ b/src/main/java/in/koreatech/koin/domain/timetableV3/model/Term.java @@ -1,6 +1,8 @@ package in.koreatech.koin.domain.timetableV3.model; -import in.koreatech.koin.domain.timetableV3.exception.InvalidTermFormatException; +import static in.koreatech.koin.global.code.ApiResponseCode.INVALID_SEMESTER_FORMAT; + +import in.koreatech.koin.global.exception.CustomException; import lombok.Getter; @Getter @@ -24,6 +26,6 @@ public static Term fromDescription(String description) { return term; } } - throw new InvalidTermFormatException("term : " + description); + throw CustomException.of(INVALID_SEMESTER_FORMAT, "term: " + description); } } diff --git a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java index 15f486b981..c0da61a7c3 100644 --- a/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java +++ b/src/main/java/in/koreatech/koin/global/code/ApiResponseCode.java @@ -140,6 +140,7 @@ public enum ApiResponseCode { NOT_FOUND_PAYMENT(HttpStatus.NOT_FOUND, "결제 정보가 존재하지 않습니다."), NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, "주문 정보가 존재하지 않습니다."), NOT_FOUND_SHOP(HttpStatus.NOT_FOUND, "상점이 존재하지 않습니다."), + NOT_FOUND_SEMESTER(HttpStatus.NOT_FOUND, "해당 학기가 존재하지 않습니다."), NOT_FOUND_COOP_SEMESTER(HttpStatus.NOT_FOUND, "해당 학기가 존재하지 않습니다."), NOT_FOUND_SHOP_ORDER_SERVICE_REQUEST(HttpStatus.NOT_FOUND, "상점 서비스 전환 요청이 존재하지 않습니다."), NOT_FOUND_CHAT_PARTNER(HttpStatus.NOT_FOUND, "채팅 상대방이 존재하지 않습니다."), @@ -153,6 +154,7 @@ public enum ApiResponseCode { DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "이미 존재하는 닉네임입니다."), DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 존재하는 이메일입니다."), DUPLICATE_PHONE_NUMBER(HttpStatus.CONFLICT, "이미 존재하는 전화번호입니다."), + DUPLICATE_LECTURE(HttpStatus.CONFLICT, "이미 존재하는 강의입니다."), REQUEST_TOO_FAST(HttpStatus.CONFLICT, "요청이 너무 빠릅니다. 다시 요청해주세요."), OPTIMISTIC_LOCKING_FAILURE(HttpStatus.CONFLICT, "이미 처리된 요청입니다."), DUPLICATE_CLUB_RECRUITMENT(HttpStatus.CONFLICT, "동아리 공고가 이미 존재합니다."), From de1eabff80c02981da894dcdc0195c42321e6b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 8 Aug 2026 12:05:57 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=95=99=EA=B8=B0=20=EC=83=9D=EC=84=B1=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#2324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 시간표 학기 생성 로직 추가 * feat: 시간표 학기 생성 어드민 API 추가 --- .../koin/admin/history/enums/DomainType.java | 1 + .../semester/controller/AdminSemesterApi.java | 24 +++++++++++++ .../controller/AdminSemesterController.java | 14 ++++++++ .../AdminTimetableSemesterCreateRequest.java | 25 ++++++++++++++ .../AdminTimetableSemesterRepository.java | 13 +++++++ .../AdminTimetableSemesterService.java | 34 +++++++++++++++++++ .../koin/domain/timetable/model/Semester.java | 10 ++++++ 7 files changed, 121 insertions(+) create mode 100644 src/main/java/in/koreatech/koin/admin/semester/dto/AdminTimetableSemesterCreateRequest.java create mode 100644 src/main/java/in/koreatech/koin/admin/semester/repository/AdminTimetableSemesterRepository.java create mode 100644 src/main/java/in/koreatech/koin/admin/semester/service/AdminTimetableSemesterService.java diff --git a/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java b/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java index f6d11c71a8..f123591306 100644 --- a/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java +++ b/src/main/java/in/koreatech/koin/admin/history/enums/DomainType.java @@ -27,6 +27,7 @@ public enum DomainType { LANDS("복덕방"), LECTURES("강의"), + SEMESTER("학기"), COOP_SHOPS("생협 매장"), USERS("회원"), diff --git a/src/main/java/in/koreatech/koin/admin/semester/controller/AdminSemesterApi.java b/src/main/java/in/koreatech/koin/admin/semester/controller/AdminSemesterApi.java index 0b30837473..4dde4a9d42 100644 --- a/src/main/java/in/koreatech/koin/admin/semester/controller/AdminSemesterApi.java +++ b/src/main/java/in/koreatech/koin/admin/semester/controller/AdminSemesterApi.java @@ -1,6 +1,7 @@ package in.koreatech.koin.admin.semester.controller; import static in.koreatech.koin.admin.history.enums.DomainType.COOP_SEMESTER; +import static in.koreatech.koin.admin.history.enums.DomainType.SEMESTER; import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import static in.koreatech.koin.global.code.ApiResponseCode.*; @@ -14,15 +15,38 @@ import in.koreatech.koin.admin.history.aop.AdminActivityLogging; import in.koreatech.koin.admin.semester.dto.AdminSemesterCreateRequest; import in.koreatech.koin.admin.semester.dto.AdminSemesterResponse; +import in.koreatech.koin.admin.semester.dto.AdminTimetableSemesterCreateRequest; import in.koreatech.koin.global.auth.Auth; import in.koreatech.koin.global.code.ApiResponseCodes; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @Tag(name = "(ADMIN) Semester : 학기", description = "관리자 권한으로 학기 정보를 관리한다") public interface AdminSemesterApi { + @ApiResponseCodes({ + OK, + INVALID_REQUEST_BODY, + UNAUTHORIZED_USER, + FORBIDDEN_ADMIN, + NOT_READABLE_HTTP_MESSAGE, + DUPLICATE_SEMESTER + }) + @Operation(summary = "(ADMIN) 시간표 학기 생성", description = """ + - `year`와 `term`을 사용해 시간표 학기를 생성합니다. + - `term`은 `FIRST`, `SECOND`, `SUMMER`, `WINTER` 중 하나입니다. + - 이미 같은 연도와 학기가 존재하면 생성하지 않습니다. + """) + @SecurityRequirement(name = "Jwt Authentication") + @AdminActivityLogging(domain = SEMESTER) + @PostMapping("/admin/semesters") + ResponseEntity createSemester( + @Valid @RequestBody AdminTimetableSemesterCreateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ); + @ApiResponseCodes({ OK, INVALID_SEMESTER_FORMAT, diff --git a/src/main/java/in/koreatech/koin/admin/semester/controller/AdminSemesterController.java b/src/main/java/in/koreatech/koin/admin/semester/controller/AdminSemesterController.java index 4f23450aea..df6195bb38 100644 --- a/src/main/java/in/koreatech/koin/admin/semester/controller/AdminSemesterController.java +++ b/src/main/java/in/koreatech/koin/admin/semester/controller/AdminSemesterController.java @@ -1,6 +1,7 @@ package in.koreatech.koin.admin.semester.controller; import static in.koreatech.koin.admin.history.enums.DomainType.COOP_SEMESTER; +import static in.koreatech.koin.admin.history.enums.DomainType.SEMESTER; import static in.koreatech.koin.domain.user.model.UserType.ADMIN; import java.util.List; @@ -14,7 +15,9 @@ import in.koreatech.koin.admin.history.aop.AdminActivityLogging; import in.koreatech.koin.admin.semester.dto.AdminSemesterCreateRequest; import in.koreatech.koin.admin.semester.dto.AdminSemesterResponse; +import in.koreatech.koin.admin.semester.dto.AdminTimetableSemesterCreateRequest; import in.koreatech.koin.admin.semester.service.AdminCoopShopSemesterService; +import in.koreatech.koin.admin.semester.service.AdminTimetableSemesterService; import in.koreatech.koin.global.auth.Auth; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -24,6 +27,17 @@ public class AdminSemesterController implements AdminSemesterApi { private final AdminCoopShopSemesterService adminCoopShopSemesterService; + private final AdminTimetableSemesterService adminTimetableSemesterService; + + @AdminActivityLogging(domain = SEMESTER) + @PostMapping("/admin/semesters") + public ResponseEntity createSemester( + @Valid @RequestBody AdminTimetableSemesterCreateRequest request, + @Auth(permit = {ADMIN}) Integer adminId + ) { + adminTimetableSemesterService.createSemester(request); + return ResponseEntity.ok().build(); + } @AdminActivityLogging(domain = COOP_SEMESTER) @PostMapping("/admin/coopshop/semesters") diff --git a/src/main/java/in/koreatech/koin/admin/semester/dto/AdminTimetableSemesterCreateRequest.java b/src/main/java/in/koreatech/koin/admin/semester/dto/AdminTimetableSemesterCreateRequest.java new file mode 100644 index 0000000000..ffd1a98773 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/semester/dto/AdminTimetableSemesterCreateRequest.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.admin.semester.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.timetableV3.model.Term; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +@JsonNaming(SnakeCaseStrategy.class) +public record AdminTimetableSemesterCreateRequest( + @Schema(description = "연도", example = "2026", requiredMode = REQUIRED) + @NotNull(message = "연도는 필수입니다.") + @Positive(message = "연도는 양수여야 합니다.") + Integer year, + + @Schema(description = "학기", example = "FIRST", requiredMode = REQUIRED) + @NotNull(message = "학기는 필수입니다.") + Term term +) { + +} diff --git a/src/main/java/in/koreatech/koin/admin/semester/repository/AdminTimetableSemesterRepository.java b/src/main/java/in/koreatech/koin/admin/semester/repository/AdminTimetableSemesterRepository.java new file mode 100644 index 0000000000..9aeee093c6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/semester/repository/AdminTimetableSemesterRepository.java @@ -0,0 +1,13 @@ +package in.koreatech.koin.admin.semester.repository; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.domain.timetableV3.model.Term; + +public interface AdminTimetableSemesterRepository extends Repository { + + boolean existsBySemesterOrYearAndTerm(String semester, Integer year, Term term); + + Semester save(Semester semester); +} diff --git a/src/main/java/in/koreatech/koin/admin/semester/service/AdminTimetableSemesterService.java b/src/main/java/in/koreatech/koin/admin/semester/service/AdminTimetableSemesterService.java new file mode 100644 index 0000000000..83f000bc4e --- /dev/null +++ b/src/main/java/in/koreatech/koin/admin/semester/service/AdminTimetableSemesterService.java @@ -0,0 +1,34 @@ +package in.koreatech.koin.admin.semester.service; + +import static in.koreatech.koin.global.code.ApiResponseCode.DUPLICATE_SEMESTER; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.admin.semester.dto.AdminTimetableSemesterCreateRequest; +import in.koreatech.koin.admin.semester.repository.AdminTimetableSemesterRepository; +import in.koreatech.koin.domain.timetable.model.Semester; +import in.koreatech.koin.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminTimetableSemesterService { + + private final AdminTimetableSemesterRepository adminTimetableSemesterRepository; + + @Transactional + public void createSemester(AdminTimetableSemesterCreateRequest request) { + Semester semester = Semester.of(request.year(), request.term()); + validateDuplicateSemester(semester); + adminTimetableSemesterRepository.save(semester); + } + + private void validateDuplicateSemester(Semester semester) { + if (adminTimetableSemesterRepository.existsBySemesterOrYearAndTerm( + semester.getSemester(), semester.getYear(), semester.getTerm() + )) { + throw CustomException.of(DUPLICATE_SEMESTER); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/timetable/model/Semester.java b/src/main/java/in/koreatech/koin/domain/timetable/model/Semester.java index ef9dc1eeea..ab1d4200bd 100644 --- a/src/main/java/in/koreatech/koin/domain/timetable/model/Semester.java +++ b/src/main/java/in/koreatech/koin/domain/timetable/model/Semester.java @@ -50,4 +50,14 @@ public Semester(String semester, Integer year, Term term) { this.year = year; this.term = term; } + + public static Semester of(Integer year, Term term) { + String semester = switch (term) { + case FIRST -> year + "1"; + case SECOND -> year + "2"; + case SUMMER -> year + "-여름"; + case WINTER -> year + "-겨울"; + }; + return new Semester(semester, year, term); + } } From f59c72e3679fccce9b33793ccd9df8d5a65c57d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Mon, 10 Aug 2026 16:34:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=B2=84=EC=A0=84=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20API=20=EC=88=98=EC=A0=95=20(#2326?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Version 업데이트 메소드 추가 * fix: 버전 업데이트 메소드 로직 수정 - 셔틀 버스 등 버전 업데이트 로직 추가 * refactor: 분리 로직 수정 * chore: 스웨거 파라미터 설명 수정 --- .../version/controller/AdminVersionApi.java | 2 +- .../version/service/AdminVersionService.java | 23 +++++++++++-------- .../koin/domain/version/model/Version.java | 6 +++++ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/version/controller/AdminVersionApi.java b/src/main/java/in/koreatech/koin/admin/version/controller/AdminVersionApi.java index 551c4b2faa..e5d0daad0f 100644 --- a/src/main/java/in/koreatech/koin/admin/version/controller/AdminVersionApi.java +++ b/src/main/java/in/koreatech/koin/admin/version/controller/AdminVersionApi.java @@ -81,7 +81,7 @@ ResponseEntity getVersion( @PutMapping("/{type}") @AdminActivityLogging(domain = VERSION) ResponseEntity updateVersion( - @Parameter(description = "android, ios, android_owner") @PathVariable("type") String type, + @Parameter(description = "android, ios, android_owner, timetable, shuttle_bus_timetable, city_bus_timetable, express_bus_timetable") @PathVariable("type") String type, @RequestBody @Valid AdminVersionUpdateRequest adminVersionUpdateRequest, @Auth(permit = {ADMIN}) Integer adminId ); diff --git a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java index 08d1440a71..2e7455fbd1 100644 --- a/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java +++ b/src/main/java/in/koreatech/koin/admin/version/service/AdminVersionService.java @@ -10,11 +10,10 @@ import in.koreatech.koin.admin.version.dto.AdminVersionResponse; import in.koreatech.koin.admin.version.dto.AdminVersionUpdateRequest; import in.koreatech.koin.admin.version.dto.AdminVersionsResponse; -import in.koreatech.koin.admin.version.exception.VersionNotSupportedException; import in.koreatech.koin.admin.version.repository.AdminVersionRepository; +import in.koreatech.koin.common.model.Criteria; import in.koreatech.koin.domain.version.model.Version; import in.koreatech.koin.domain.version.model.VersionType; -import in.koreatech.koin.common.model.Criteria; import lombok.RequiredArgsConstructor; @Service @@ -44,15 +43,19 @@ public AdminVersionResponse getVersion(String type) { @Transactional public void updateVersion(String type, AdminVersionUpdateRequest request) { VersionType versionType = VersionType.from(type); - if (!versionType.isPlatform()) { - throw VersionNotSupportedException.withDetail("type: " + versionType); - } - Version currentVersion = adminVersionRepository.getByTypeAndIsPrevious(versionType, false); - currentVersion.toPreviousVersion(); - - Version newVersion = Version.of(versionType, request); - adminVersionRepository.save(newVersion); + if (versionType.isPlatform()) { + currentVersion.toPreviousVersion(); + + Version newVersion = Version.of(versionType, request); + adminVersionRepository.save(newVersion); + } else { + currentVersion.update( + request.version(), + request.title(), + request.content() + ); + } } public AdminVersionHistoryResponse getHistory(String type, Integer page, Integer limit) { diff --git a/src/main/java/in/koreatech/koin/domain/version/model/Version.java b/src/main/java/in/koreatech/koin/domain/version/model/Version.java index 9b7e15b26d..6c9a0ea8d6 100644 --- a/src/main/java/in/koreatech/koin/domain/version/model/Version.java +++ b/src/main/java/in/koreatech/koin/domain/version/model/Version.java @@ -68,6 +68,12 @@ public void update(Clock clock) { version = generateVersionName(clock); } + public void update(String version, String title, String content) { + this.version = version; + this.title = title; + this.content = content; + } + private String generateVersionName(Clock clock) { String year = Integer.toString(LocalDate.now().getYear()); String padding = "0_"; From 410c55aec85514f4e4dc1ad3325e305f1a58e556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:58:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20AI=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=20=EC=9D=91=EB=8B=B5=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?(#2328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/controller/ArticleApiV2.java | 38 ++++++ .../controller/ArticleControllerV2.java | 31 +++++ .../article/dto/ArticleAiSummaryResponse.java | 73 ++++++++++++ .../article/dto/ArticleResponseV2.java | 110 ++++++++++++++++++ .../article/service/ArticleService.java | 30 ++++- .../summary/ArticleAiSummaryService.java | 25 +++- .../service/summary/ArticleSummaryView.java | 35 ++++++ .../domain/ArticleAiSummaryApiTest.java | 50 ++++++++ .../article/ArticleAiSummaryResponseTest.java | 45 +++++++ .../summary/ArticleAiSummaryServiceTest.java | 72 ++++++++++++ 10 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApiV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleControllerV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleAiSummaryResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponseV2.java create mode 100644 src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryView.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/community/article/ArticleAiSummaryResponseTest.java diff --git a/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApiV2.java b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApiV2.java new file mode 100644 index 0000000000..30036af30e --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleApiV2.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.community.article.controller; + +import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import in.koreatech.koin.domain.community.article.dto.ArticleResponseV2; +import in.koreatech.koin.global.ipaddress.IpAddress; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Articles V2: 게시글", description = "게시글 정보를 관리한다") +@RequestMapping("/v2/articles") +public interface ArticleApiV2 { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "게시글 단건 조회 V2", description = "게시글 원문과 AI 요약을 분리해 반환한다.") + @GetMapping("/{id}") + ResponseEntity getArticleV2( + @RequestParam(required = false) Integer boardId, + @Parameter(in = PATH) @PathVariable("id") Integer articleId, + @IpAddress String ipAddress + ); +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleControllerV2.java b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleControllerV2.java new file mode 100644 index 0000000000..d1d7423200 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/controller/ArticleControllerV2.java @@ -0,0 +1,31 @@ +package in.koreatech.koin.domain.community.article.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.community.article.dto.ArticleResponseV2; +import in.koreatech.koin.domain.community.article.service.ArticleService; +import in.koreatech.koin.global.ipaddress.IpAddress; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/articles") +public class ArticleControllerV2 implements ArticleApiV2 { + + private final ArticleService articleService; + + @GetMapping("/{id}") + public ResponseEntity getArticleV2( + @RequestParam(required = false) Integer boardId, + @PathVariable("id") Integer articleId, + @IpAddress String ipAddress + ) { + ArticleResponseV2 foundArticle = articleService.getArticleV2(boardId, articleId, ipAddress); + return ResponseEntity.ok().body(foundArticle); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleAiSummaryResponse.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleAiSummaryResponse.java new file mode 100644 index 0000000000..968b32cca7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleAiSummaryResponse.java @@ -0,0 +1,73 @@ +package in.koreatech.koin.domain.community.article.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryIcon; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record ArticleAiSummaryResponse( + + @Schema(description = "AI 요약 상태", example = "SUCCESS", requiredMode = REQUIRED) + Status status, + + @Schema(description = "AI 요약 항목", requiredMode = REQUIRED) + List items +) { + + private static final int MAX_SUMMARY_LINES = 3; + private static final int MAX_SUMMARY_LINE_LENGTH = 220; + + public static ArticleAiSummaryResponse from(ArticleSummaryView summaryView) { + List items = summaryView.summaryLines().stream() + .filter(line -> line != null && !line.isBlank()) + .filter(line -> line.length() <= MAX_SUMMARY_LINE_LENGTH) + .map(InnerArticleAiSummaryItemResponse::from) + .flatMap(Optional::stream) + .limit(MAX_SUMMARY_LINES) + .toList(); + Status status = summaryView.isSuccess() && items.isEmpty() + ? Status.UNAVAILABLE + : Status.valueOf(summaryView.status().name()); + return new ArticleAiSummaryResponse(status, items); + } + + public enum Status { + SUCCESS, + PENDING, + UNAVAILABLE + } + + @JsonNaming(SnakeCaseStrategy.class) + public record InnerArticleAiSummaryItemResponse( + + @Schema(description = "요약 항목 아이콘", example = "📅", requiredMode = REQUIRED) + String icon, + + @Schema(description = "요약 내용", example = "신청은 5월 20일까지 접수됩니다.", requiredMode = REQUIRED) + String text + ) { + + private static Optional from(String summaryLine) { + if (summaryLine == null || summaryLine.isBlank()) { + return Optional.empty(); + } + return Arrays.stream(ArticleSummaryIcon.values()) + .filter(icon -> summaryLine.startsWith(icon.getEmoji() + " ")) + .findFirst() + .map(icon -> new InnerArticleAiSummaryItemResponse( + icon.getEmoji(), + summaryLine.substring((icon.getEmoji() + " ").length()).trim() + )) + .filter(item -> !item.text().isBlank()); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponseV2.java b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponseV2.java new file mode 100644 index 0000000000..8aae17a0f6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/dto/ArticleResponseV2.java @@ -0,0 +1,110 @@ +package in.koreatech.koin.domain.community.article.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.article.model.ArticleAttachment; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record ArticleResponseV2( + + @Schema(description = "게시글 고유 ID", example = "2", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "게시판 고유 ID", example = "4", requiredMode = REQUIRED) + Integer boardId, + + @Schema(description = "제목", example = "제목", requiredMode = REQUIRED) + String title, + + @Schema(description = "게시글 원문", example = "내용", requiredMode = REQUIRED) + String content, + + @Schema(description = "AI 요약", requiredMode = REQUIRED) + ArticleAiSummaryResponse aiSummary, + + @Schema(description = "작성자", example = "닉네임", requiredMode = REQUIRED) + String author, + + @Schema(description = "조회수", example = "1", requiredMode = REQUIRED) + Integer hit, + + @Schema(description = "공지 원본 url", example = "https://portal.koreatech.ac.kr/ctt/bb/bulletin?b=14&ls=20&ln=1&dm=r&p=33248") + String url, + + @Schema(description = "첨부 파일") + List attachments, + + @Schema(description = "이전 게시글 ID", example = "1") + Integer prevId, + + @Schema(description = "다음 게시글 ID", example = "3") + Integer nextId, + + @Schema(description = "등록 일자", example = "2024-08-28", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd") LocalDate registeredAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt +) { + + public static ArticleResponseV2 from(Article article, String content, ArticleSummaryView summaryView) { + return new ArticleResponseV2( + article.getId(), + article.getBoard().getId(), + article.getTitle(), + content, + ArticleAiSummaryResponse.from(summaryView), + article.getAuthor(), + article.getTotalHit(), + article.getUrl(), + article.getAttachments().stream() + .map(InnerArticleAttachmentResponse::from) + .toList(), + article.getPrevId(), + article.getNextId(), + article.getRegisteredAt(), + article.getUpdatedAt() + ); + } + + @JsonNaming(SnakeCaseStrategy.class) + private record InnerArticleAttachmentResponse( + + @Schema(description = "파일 고유 ID", example = "1", requiredMode = REQUIRED) + Integer id, + + @Schema(description = "파일 이름", example = "이미지.png", requiredMode = REQUIRED) + String name, + + @Schema(description = "파일 url", requiredMode = REQUIRED) + String url, + + @Schema(description = "생성 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, + + @Schema(description = "수정 일자", example = "2023-01-04 12:00:01", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt + ) { + + private static InnerArticleAttachmentResponse from(ArticleAttachment attachment) { + return new InnerArticleAttachmentResponse( + attachment.getId(), + attachment.getName(), + attachment.getUrl(), + attachment.getCreatedAt(), + attachment.getUpdatedAt() + ); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java index 5a4f177510..036c5749b7 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/ArticleService.java @@ -19,6 +19,7 @@ import in.koreatech.koin.common.model.Criteria; import in.koreatech.koin.domain.community.article.dto.ArticleHotKeywordResponse; import in.koreatech.koin.domain.community.article.dto.ArticleResponse; +import in.koreatech.koin.domain.community.article.dto.ArticleResponseV2; import in.koreatech.koin.domain.community.article.dto.ArticlesResponse; import in.koreatech.koin.domain.community.article.dto.HotArticleItemResponse; import in.koreatech.koin.domain.community.article.exception.ArticleBoardMisMatchException; @@ -32,6 +33,7 @@ import in.koreatech.koin.domain.community.article.repository.redis.ArticleHitUserRepository; import in.koreatech.koin.domain.community.article.repository.redis.HotArticleRepository; import in.koreatech.koin.domain.community.article.service.summary.ArticleAiSummaryService; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; import in.koreatech.koin.global.exception.custom.KoinIllegalArgumentException; import in.koreatech.koin.infrastructure.s3.client.S3Client; import lombok.RequiredArgsConstructor; @@ -65,6 +67,25 @@ public class ArticleService { @Transactional public ArticleResponse getArticle(Integer boardId, Integer articleId, String publicIp) { + ArticleDetail articleDetail = getArticleDetail(boardId, articleId, publicIp); + String contentWithSummary = articleAiSummaryService.prependSummaryIfReady( + articleDetail.article(), + articleDetail.content() + ); + return ArticleResponse.from(articleDetail.article(), contentWithSummary); + } + + @Transactional + public ArticleResponseV2 getArticleV2(Integer boardId, Integer articleId, String publicIp) { + ArticleDetail articleDetail = getArticleDetail(boardId, articleId, publicIp); + ArticleSummaryView summaryView = articleAiSummaryService.getSummary( + articleDetail.article(), + articleDetail.content() + ); + return ArticleResponseV2.from(articleDetail.article(), articleDetail.content(), summaryView); + } + + private ArticleDetail getArticleDetail(Integer boardId, Integer articleId, String publicIp) { Article article = articleRepository.getById(articleId); String content = article.getContent(); String contentUrl = content == null ? null : content.trim(); @@ -76,8 +97,7 @@ public ArticleResponse getArticle(Integer boardId, Integer articleId, String pub articleHitUserRepository.save(ArticleHitUser.of(articleId, publicIp)); } setPrevNextArticle(boardId, article); - String contentWithSummary = articleAiSummaryService.prependSummaryIfReady(article, content); - return ArticleResponse.from(article, contentWithSummary); + return new ArticleDetail(article, content); } public ArticlesResponse getArticles(Integer boardId, Integer page, Integer limit, Integer userId) { @@ -213,4 +233,10 @@ private Board getBoard(Integer boardId, Article article) { } return boardRepository.getById(boardId); } + + private record ArticleDetail( + Article article, + String content + ) { + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryService.java b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryService.java index 926cd5f22e..970d45ee4b 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryService.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleAiSummaryService.java @@ -22,6 +22,7 @@ import in.koreatech.koin.domain.community.article.model.ArticleAiSummary; import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryLog; import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryLogType; +import in.koreatech.koin.domain.community.article.model.ArticleAiSummaryStatus; import in.koreatech.koin.domain.community.article.repository.ArticleAiSummaryLogRepository; import in.koreatech.koin.domain.community.article.repository.ArticleAiSummaryRepository; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; @@ -49,16 +50,29 @@ public class ArticleAiSummaryService { @Transactional public String prependSummaryIfReady(Article article, String content) { - if (article.getBoard().getId().equals(LOST_ITEM_BOARD_ID)) { + ArticleSummaryView summaryView = resolveSummary(article, content); + if (!summaryView.isSuccess()) { return content; } + return contentRenderer.prependSummary(content, summaryView.summaryLines()); + } + + @Transactional + public ArticleSummaryView getSummary(Article article, String content) { + return resolveSummary(article, content); + } + + private ArticleSummaryView resolveSummary(Article article, String content) { + if (article.getBoard().getId().equals(LOST_ITEM_BOARD_ID)) { + return ArticleSummaryView.unavailable(); + } ArticleSummarySourceSeed seed = ArticleSummarySourceSeed.from(article, content); String fingerprint = sourceReader.createFingerprint(seed); Optional optionalSummary = articleAiSummaryRepository.findByArticleId(article.getId()); if (optionalSummary.isEmpty()) { enqueueIfEnabled(article, fingerprint, article.getUpdatedAt()); - return content; + return canGenerate() ? ArticleSummaryView.pending() : ArticleSummaryView.unavailable(); } ArticleAiSummary summary = optionalSummary.get(); @@ -66,12 +80,15 @@ public String prependSummaryIfReady(Article article, String content) { if (canGenerate() && !summary.isProcessing() && isStale(summary, fingerprint)) { summary.prepareWait(fingerprint, article.getUpdatedAt(), properties.getModel(), properties.getPromptVersion()); } - return contentRenderer.prependSummary(content, summary.getSummaryLines()); + return ArticleSummaryView.success(summary.getSummaryLines()); } if (canGenerate() && !summary.isProcessing() && isStale(summary, fingerprint)) { summary.prepareWait(fingerprint, article.getUpdatedAt(), properties.getModel(), properties.getPromptVersion()); } - return content; + if (canGenerate() && (summary.getStatus() == ArticleAiSummaryStatus.WAIT || summary.isProcessing())) { + return ArticleSummaryView.pending(); + } + return ArticleSummaryView.unavailable(); } @Transactional diff --git a/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryView.java b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryView.java new file mode 100644 index 0000000000..02e0b3042f --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/community/article/service/summary/ArticleSummaryView.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.domain.community.article.service.summary; + +import java.util.List; + +public record ArticleSummaryView( + Status status, + List summaryLines +) { + + public ArticleSummaryView { + summaryLines = summaryLines == null ? List.of() : List.copyOf(summaryLines); + } + + public static ArticleSummaryView success(List summaryLines) { + return new ArticleSummaryView(Status.SUCCESS, summaryLines); + } + + public static ArticleSummaryView pending() { + return new ArticleSummaryView(Status.PENDING, List.of()); + } + + public static ArticleSummaryView unavailable() { + return new ArticleSummaryView(Status.UNAVAILABLE, List.of()); + } + + public boolean isSuccess() { + return status == Status.SUCCESS; + } + + public enum Status { + SUCCESS, + PENDING, + UNAVAILABLE + } +} diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/ArticleAiSummaryApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/ArticleAiSummaryApiTest.java index 339b81dbbe..fa3afa2d47 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/ArticleAiSummaryApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/ArticleAiSummaryApiTest.java @@ -110,6 +110,21 @@ void setUp() { assertThat(summary.getStatus()).isEqualTo(ArticleAiSummaryStatus.WAIT); } + @Test + void V2에서_요약이_없으면_원문과_생성_대기_상태를_분리해_반환한다() throws Exception { + mockMvc.perform( + get("/v2/articles/{articleId}", article.getId()) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("

내용

")) + .andExpect(jsonPath("$.ai_summary.status").value("PENDING")) + .andExpect(jsonPath("$.ai_summary.items").isEmpty()); + + ArticleAiSummary summary = articleAiSummaryRepository.findByArticleId(article.getId()).orElseThrow(); + assertThat(summary.getStatus()).isEqualTo(ArticleAiSummaryStatus.WAIT); + } + @Test void 요약이_성공되어_있으면_content_맨_앞에_요약을_붙인다() throws Exception { String content = "

내용

"; @@ -141,4 +156,39 @@ void setUp() { .andExpect(jsonPath("$.content").value(containsString(">신청은 5월 20일까지 접수됩니다."))) .andExpect(jsonPath("$.content").value(containsString("

내용

"))); } + + @Test + void V2에서_성공한_요약은_원문과_분리된_항목으로_반환한다() throws Exception { + String content = "

내용

"; + String fingerprint = sourceReader.createFingerprint(ArticleSummarySourceSeed.from(article, content)); + ArticleAiSummary summary = ArticleAiSummary.waiting( + article, + fingerprint, + article.getUpdatedAt(), + properties.getModel(), + properties.getPromptVersion() + ); + summary.completeSuccess( + List.of("📅 신청은 5월 20일까지 접수됩니다.", "🎯 재학생을 대상으로 모집합니다."), + fingerprint, + article.getUpdatedAt(), + properties.getModel(), + properties.getPromptVersion(), + LocalDateTime.now(clock) + ); + articleAiSummaryRepository.save(summary); + + mockMvc.perform( + get("/v2/articles/{articleId}", article.getId()) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value(content)) + .andExpect(jsonPath("$.ai_summary.status").value("SUCCESS")) + .andExpect(jsonPath("$.ai_summary.items.length()").value(2)) + .andExpect(jsonPath("$.ai_summary.items[0].icon").value("📅")) + .andExpect(jsonPath("$.ai_summary.items[0].text").value("신청은 5월 20일까지 접수됩니다.")) + .andExpect(jsonPath("$.ai_summary.items[1].icon").value("🎯")) + .andExpect(jsonPath("$.ai_summary.items[1].text").value("재학생을 대상으로 모집합니다.")); + } } diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/article/ArticleAiSummaryResponseTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/article/ArticleAiSummaryResponseTest.java new file mode 100644 index 0000000000..6b5bcce65f --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/article/ArticleAiSummaryResponseTest.java @@ -0,0 +1,45 @@ +package in.koreatech.koin.unit.domain.community.article; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.domain.community.article.dto.ArticleAiSummaryResponse; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; + +class ArticleAiSummaryResponseTest { + + @Test + void 저장된_요약_문장을_아이콘과_본문으로_분리한다() { + ArticleSummaryView summaryView = ArticleSummaryView.success(List.of( + "📅 신청은 5월 20일까지 접수됩니다.", + "🎯 재학생을 대상으로 모집합니다." + )); + + ArticleAiSummaryResponse response = ArticleAiSummaryResponse.from(summaryView); + + assertThat(response.status()).isEqualTo(ArticleAiSummaryResponse.Status.SUCCESS); + assertThat(response.items()).containsExactly( + new ArticleAiSummaryResponse.InnerArticleAiSummaryItemResponse( + "📅", + "신청은 5월 20일까지 접수됩니다." + ), + new ArticleAiSummaryResponse.InnerArticleAiSummaryItemResponse( + "🎯", + "재학생을 대상으로 모집합니다." + ) + ); + } + + @Test + void 표시할_수_없는_요약만_있으면_UNAVAILABLE을_반환한다() { + ArticleSummaryView summaryView = ArticleSummaryView.success(List.of("아이콘이 없는 요약")); + + ArticleAiSummaryResponse response = ArticleAiSummaryResponse.from(summaryView); + + assertThat(response.status()).isEqualTo(ArticleAiSummaryResponse.Status.UNAVAILABLE); + assertThat(response.items()).isEmpty(); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryServiceTest.java index 844da96ff2..8c3dafe66f 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/community/article/service/summary/ArticleAiSummaryServiceTest.java @@ -37,6 +37,7 @@ import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryContentRenderer; import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryFailureReasonSanitizer; import in.koreatech.koin.domain.community.article.service.summary.ArticleSummarySourceReader; +import in.koreatech.koin.domain.community.article.service.summary.ArticleSummaryView; import in.koreatech.koin.infrastructure.upstage.client.UpstageProperties; @ExtendWith(MockitoExtension.class) @@ -117,6 +118,77 @@ class ArticleAiSummaryServiceTest { verify(contentRenderer).prependSummary("본문", List.of("기존 요약입니다.")); } + @Test + void V2에서_모델이_바뀌어도_원문이_같으면_기존_요약을_반환한다() { + Clock clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneId.of("Asia/Seoul")); + ArticleAiSummaryService service = service(clock); + Article article = mock(Article.class); + Board board = mock(Board.class); + ArticleAiSummary summary = completedSummary(clock, article, "solar-open2", "v10"); + when(board.getId()).thenReturn(1); + when(article.getBoard()).thenReturn(board); + when(article.getId()).thenReturn(1); + when(article.getUpdatedAt()).thenReturn(LocalDateTime.now(clock)); + when(sourceReader.createFingerprint(any())).thenReturn("fingerprint"); + when(articleAiSummaryRepository.findByArticleId(1)).thenReturn(Optional.of(summary)); + + ArticleSummaryView summaryView = service.getSummary(article, "본문"); + + assertThat(summaryView.status()).isEqualTo(ArticleSummaryView.Status.SUCCESS); + assertThat(summaryView.summaryLines()).containsExactly("기존 요약입니다."); + assertThat(summary.getStatus()).isEqualTo(ArticleAiSummaryStatus.WAIT); + verify(contentRenderer, never()).prependSummary(any(), any()); + } + + @Test + void V2에서_요약이_없으면_PENDING을_반환하고_WAIT으로_등록한다() { + Clock clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneId.of("Asia/Seoul")); + ArticleAiSummaryService service = service(clock); + Article article = mock(Article.class); + Board board = mock(Board.class); + LocalDateTime updatedAt = LocalDateTime.now(clock); + when(board.getId()).thenReturn(1); + when(article.getBoard()).thenReturn(board); + when(article.getId()).thenReturn(1); + when(article.getUpdatedAt()).thenReturn(updatedAt); + when(sourceReader.createFingerprint(any())).thenReturn("fingerprint"); + when(articleAiSummaryRepository.findByArticleId(1)).thenReturn(Optional.empty()); + + ArticleSummaryView summaryView = service.getSummary(article, "본문"); + + assertThat(summaryView.status()).isEqualTo(ArticleSummaryView.Status.PENDING); + assertThat(summaryView.summaryLines()).isEmpty(); + verify(articleAiSummaryRepository).insertWaitIfAbsent( + 1, + "fingerprint", + updatedAt, + "solar-pro4", + "v11" + ); + } + + @Test + void V2에서_원문이_바뀌면_기존_요약을_제외하고_PENDING을_반환한다() { + Clock clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneId.of("Asia/Seoul")); + ArticleAiSummaryService service = service(clock); + Article article = mock(Article.class); + Board board = mock(Board.class); + ArticleAiSummary summary = completedSummary(clock, article, "solar-pro4", "v11"); + when(board.getId()).thenReturn(1); + when(article.getBoard()).thenReturn(board); + when(article.getId()).thenReturn(1); + when(article.getUpdatedAt()).thenReturn(LocalDateTime.now(clock)); + when(sourceReader.createFingerprint(any())).thenReturn("changed-fingerprint"); + when(articleAiSummaryRepository.findByArticleId(1)).thenReturn(Optional.of(summary)); + + ArticleSummaryView summaryView = service.getSummary(article, "변경된 본문"); + + assertThat(summaryView.status()).isEqualTo(ArticleSummaryView.Status.PENDING); + assertThat(summaryView.summaryLines()).isEmpty(); + assertThat(summary.getStatus()).isEqualTo(ArticleAiSummaryStatus.WAIT); + assertThat(summary.getSummaryLines()).isEmpty(); + } + @Test void 게시글_AI_요약_로그는_90일_이전_데이터를_삭제한다() { Clock clock = Clock.fixed(Instant.parse("2026-06-01T00:00:00Z"), ZoneId.of("Asia/Seoul"));