From 7a64e5cd565b098ba46ee31f09f6a85579ec31a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Wed, 19 Aug 2026 22:06:04 +0900 Subject: [PATCH] =?UTF-8?q?T-11=20=EC=BB=A4=EB=A6=AC=ED=81=98=EB=9F=BC=20?= =?UTF-8?q?=EC=A3=BC=EC=B0=A8/=ED=86=A0=ED=94=BD/=EC=84=B8=EB=B6=80?= =?UTF-8?q?=ED=95=AD=EB=AA=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주차 > 토픽 > 세부항목 3단 트리 CRUD + 각 단계 순서 변경. - CurriculumWeek/CurriculumTopic/CurriculumTopicDetail — 전부 하위 트리라 soft delete 없이 on delete cascade로 물리 삭제(INV-7) - POST /v1/admin/curriculums/{id}/weeks, PUT/DELETE /v1/admin/weeks/{id} — weekTo가 weekFrom보다 작으면 서비스 레이어에서 WEEK_RANGE_INVALID(400)를 먼저 던진다(AC-2.5). DB의 ck_week_range도 같은 제약을 갖고 있어 이중 방어다 - PATCH .../weeks/order, .../topics/order — DisplayOrders 재사용. 토픽 번호는 별도 컬럼이 아니라 display_order + 1로 렌더하므로 순서 변경이 곧 번호 재부여다(AC-2.6, ADR-005) - PUT /v1/admin/topics/{id}/details — 전체 교체. 빈 배열이면 전부 삭제되고 에러가 아니다(AC-2.9) - GET /v1/admin/curriculums/{id} — 3단 트리 전체(T-10에서 미룬 엔드포인트를 여기서 추가). 운영진 소수가 쓰는 편집 화면이라 N+1을 감수했다 — 공개 API인 T-12는 트래픽 특성이 달라 따로 최적화한다 테스트: AdminCurriculumTreeIntegrationTest(신규) — - AC-2.5 범위 역전 시 400 - 범위 주차 생성/수정 - AC-2.3 주차 삭제가 하위에만 cascade, 다른 주차는 영향 없음 - AC-2.6 토픽 순서 변경이 트리 조회에 반영 - AC-2.9 빈 배열 전송 시 전부 삭제(에러 아님) - 관리자 트리 조회는 빈 주차도 포함(공개 API의 제외 규칙과 다름 — T-12에서 명시적으로 다르게 구현할 지점) ./gradlew test 3회 연속 전체 통과(68개, 실패 0). Refs #23 --- .../controller/AdminCurriculumApi.java | 10 + .../controller/AdminCurriculumController.java | 10 + .../controller/AdminCurriculumWeekApi.java | 41 ++++ .../AdminCurriculumWeekController.java | 39 ++++ .../curriculum/controller/AdminTopicApi.java | 51 ++++ .../controller/AdminTopicController.java | 43 ++++ .../curriculum/controller/AdminWeekApi.java | 60 +++++ .../controller/AdminWeekController.java | 55 +++++ .../request/TopicDetailsReplaceRequest.java | 15 ++ .../controller/dto/request/TopicRequest.java | 11 + .../controller/dto/request/WeekRequest.java | 20 ++ .../response/AdminCurriculumTreeResponse.java | 38 +++ .../dto/response/CurriculumTopicResponse.java | 13 ++ .../dto/response/CurriculumWeekResponse.java | 14 ++ .../exception/CurriculumExceptionType.java | 3 + .../curriculum/model/CurriculumTopic.java | 56 +++++ .../model/CurriculumTopicDetail.java | 48 ++++ .../curriculum/model/CurriculumWeek.java | 61 +++++ .../CurriculumTopicDetailRepository.java | 12 + .../repository/CurriculumTopicRepository.java | 10 + .../repository/CurriculumWeekRepository.java | 10 + .../service/AdminCurriculumTreeService.java | 173 ++++++++++++++ .../AdminCurriculumTreeIntegrationTest.java | 220 ++++++++++++++++++ 23 files changed, 1013 insertions(+) create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumWeekApi.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumWeekController.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminTopicApi.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminTopicController.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminWeekApi.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminWeekController.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/TopicDetailsReplaceRequest.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/TopicRequest.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/WeekRequest.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/AdminCurriculumTreeResponse.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumTopicResponse.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumWeekResponse.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumTopic.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumTopicDetail.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumWeek.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicDetailRepository.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicRepository.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumWeekRepository.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumTreeIntegrationTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java index 9704240..76a6fce 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java @@ -2,6 +2,7 @@ import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumUpdateRequest; import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumTreeResponse; import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -19,6 +20,15 @@ @SecurityRequirement(name = "JWT") public interface AdminCurriculumApi { + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "주차 > 토픽 > 세부항목 3단 트리 전체 조회") + AdminCurriculumTreeResponse getTree(@PathVariable Long id); + @ApiResponses(value = { @ApiResponse(responseCode = "200"), @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java index 684c8b2..ffd1256 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java @@ -2,12 +2,15 @@ import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumUpdateRequest; import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumTreeResponse; import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumService; +import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumTreeService; import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; @@ -21,6 +24,13 @@ public class AdminCurriculumController implements AdminCurriculumApi { private final AdminCurriculumService adminCurriculumService; + private final AdminCurriculumTreeService adminCurriculumTreeService; + + @Override + @GetMapping("/{id}") + public AdminCurriculumTreeResponse getTree(@PathVariable Long id) { + return adminCurriculumTreeService.getTree(id); + } @Override @PutMapping("/{id}") diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumWeekApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumWeekApi.java new file mode 100644 index 0000000..4ea9dcd --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumWeekApi.java @@ -0,0 +1,41 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.WeekRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumWeekResponse; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import io.swagger.v3.oas.annotations.Operation; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "관리자 - 커리큘럼 주차 API") +@SecurityRequirement(name = "JWT") +public interface AdminCurriculumWeekApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "주차 추가", description = "weekTo가 없으면 단일 주차, 있으면 범위다. weekTo < weekFrom이면 400(AC-2.5).") + ResponseEntity createWeek(@PathVariable Long curriculumId, + @RequestBody @Valid WeekRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "주차 순서 변경") + ResponseEntity reorderWeeks(@PathVariable Long curriculumId, @RequestBody @Valid OrderRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumWeekController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumWeekController.java new file mode 100644 index 0000000..cdce7fc --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumWeekController.java @@ -0,0 +1,39 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.WeekRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumWeekResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumTreeService; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RestController; + +@RestController +@RequestMapping("/v1/admin/curriculums/{curriculumId}/weeks") +@RequiredArgsConstructor +public class AdminCurriculumWeekController implements AdminCurriculumWeekApi { + + private final AdminCurriculumTreeService adminCurriculumTreeService; + + @Override + @PostMapping + public ResponseEntity createWeek(@PathVariable Long curriculumId, + @Valid @RequestBody WeekRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(adminCurriculumTreeService.createWeek(curriculumId, request)); + } + + @Override + @PatchMapping("/order") + public ResponseEntity reorderWeeks(@PathVariable Long curriculumId, @Valid @RequestBody OrderRequest request) { + adminCurriculumTreeService.reorderWeeks(curriculumId, request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminTopicApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminTopicApi.java new file mode 100644 index 0000000..764143f --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminTopicApi.java @@ -0,0 +1,51 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.TopicDetailsReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.TopicRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumTopicResponse; +import io.swagger.v3.oas.annotations.Operation; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "관리자 - 커리큘럼 토픽 API") +@SecurityRequirement(name = "JWT") +public interface AdminTopicApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "토픽 제목 수정") + CurriculumTopicResponse updateTopic(@PathVariable Long id, @RequestBody @Valid TopicRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "토픽 삭제", description = "하위 세부항목이 cascade로 함께 삭제된다.") + ResponseEntity deleteTopic(@PathVariable Long id); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "세부 항목 전체 교체", description = "빈 배열이면 전부 삭제된다(AC-2.9, 에러가 아니다).") + List replaceDetails(@PathVariable Long id, @RequestBody @Valid TopicDetailsReplaceRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminTopicController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminTopicController.java new file mode 100644 index 0000000..b721f63 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminTopicController.java @@ -0,0 +1,43 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.TopicDetailsReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.TopicRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumTopicResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumTreeService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +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/topics") +@RequiredArgsConstructor +public class AdminTopicController implements AdminTopicApi { + + private final AdminCurriculumTreeService adminCurriculumTreeService; + + @Override + @PutMapping("/{id}") + public CurriculumTopicResponse updateTopic(@PathVariable Long id, @Valid @RequestBody TopicRequest request) { + return adminCurriculumTreeService.updateTopic(id, request); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity deleteTopic(@PathVariable Long id) { + adminCurriculumTreeService.deleteTopic(id); + return ResponseEntity.noContent().build(); + } + + @Override + @PutMapping("/{id}/details") + public List replaceDetails(@PathVariable Long id, @Valid @RequestBody TopicDetailsReplaceRequest request) { + return adminCurriculumTreeService.replaceDetails(id, request); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminWeekApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminWeekApi.java new file mode 100644 index 0000000..fc6398a --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminWeekApi.java @@ -0,0 +1,60 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.TopicRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.WeekRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumTopicResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumWeekResponse; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import io.swagger.v3.oas.annotations.Operation; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "관리자 - 커리큘럼 주차 API") +@SecurityRequirement(name = "JWT") +public interface AdminWeekApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "주차 라벨 수정") + CurriculumWeekResponse updateWeek(@PathVariable Long id, @RequestBody @Valid WeekRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "주차 삭제", description = "하위 토픽·세부항목이 cascade로 함께 삭제된다(AC-2.3).") + ResponseEntity deleteWeek(@PathVariable Long id); + + @ApiResponses(value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "토픽 추가") + ResponseEntity createTopic(@PathVariable Long id, @RequestBody @Valid TopicRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "토픽 순서 변경", description = "번호(1,2,3…)는 배열 순서로 자동 재부여된다(AC-2.6).") + ResponseEntity reorderTopics(@PathVariable Long id, @RequestBody @Valid OrderRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminWeekController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminWeekController.java new file mode 100644 index 0000000..06860be --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminWeekController.java @@ -0,0 +1,55 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.TopicRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.WeekRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumTopicResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumWeekResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumTreeService; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.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/weeks") +@RequiredArgsConstructor +public class AdminWeekController implements AdminWeekApi { + + private final AdminCurriculumTreeService adminCurriculumTreeService; + + @Override + @PutMapping("/{id}") + public CurriculumWeekResponse updateWeek(@PathVariable Long id, @Valid @RequestBody WeekRequest request) { + return adminCurriculumTreeService.updateWeek(id, request); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity deleteWeek(@PathVariable Long id) { + adminCurriculumTreeService.deleteWeek(id); + return ResponseEntity.noContent().build(); + } + + @Override + @PostMapping("/{id}/topics") + public ResponseEntity createTopic(@PathVariable Long id, + @Valid @RequestBody TopicRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminCurriculumTreeService.createTopic(id, request)); + } + + @Override + @PatchMapping("/{id}/topics/order") + public ResponseEntity reorderTopics(@PathVariable Long id, @Valid @RequestBody OrderRequest request) { + adminCurriculumTreeService.reorderTopics(id, request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/TopicDetailsReplaceRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/TopicDetailsReplaceRequest.java new file mode 100644 index 0000000..30a4b1a --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/TopicDetailsReplaceRequest.java @@ -0,0 +1,15 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +/** + * 세부 항목 전체 교체. 빈 배열이면 전부 삭제된다(AC-2.9, 에러가 아니다). + */ +public record TopicDetailsReplaceRequest( + @NotNull + List<@NotBlank @Size(max = 300) String> contents +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/TopicRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/TopicRequest.java new file mode 100644 index 0000000..8aa3614 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/TopicRequest.java @@ -0,0 +1,11 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TopicRequest( + @NotBlank + @Size(max = 200) + String title +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/WeekRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/WeekRequest.java new file mode 100644 index 0000000..ec2afea --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/WeekRequest.java @@ -0,0 +1,20 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +/** + * 주차 생성/수정 공용. weekTo가 null이면 단일 주차, 있으면 범위(예: 14~17주차)다. + * weekTo < weekFrom은 서비스 레이어에서 걸러 WEEK_RANGE_INVALID(400)를 던진다 — + * DB의 ck_week_range도 같은 제약을 갖고 있지만, 더 명확한 메시지를 먼저 준다. + */ +public record WeekRequest( + @NotNull + @Min(1) + @Max(99) + Integer weekFrom, + + Integer weekTo +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/AdminCurriculumTreeResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/AdminCurriculumTreeResponse.java new file mode 100644 index 0000000..9a1402c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/AdminCurriculumTreeResponse.java @@ -0,0 +1,38 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; +import java.util.List; + +/** + * 커리큘럼 편집 화면 — 주차 > 토픽 > 세부항목 3단 트리 전체. + * 편집 화면(운영진 소수, 낮은 트래픽) 전용이라 N+1을 감수한다 — 공개 API(T-12)는 + * 트래픽 특성이 달라 join fetch/배치 조회로 따로 최적화한다. + */ +public record AdminCurriculumTreeResponse( + Long id, + String name, + boolean isPublished, + List weeks +) { + public static AdminCurriculumTreeResponse of(Curriculum curriculum, List weeks) { + return new AdminCurriculumTreeResponse(curriculum.getId(), curriculum.getName(), curriculum.isPublished(), + weeks); + } + + public record WeekNode( + Long id, + Integer weekFrom, + Integer weekTo, + int displayOrder, + List topics + ) { + } + + public record TopicNode( + Long id, + String title, + int displayOrder, + List details + ) { + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumTopicResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumTopicResponse.java new file mode 100644 index 0000000..c92e794 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumTopicResponse.java @@ -0,0 +1,13 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumTopic; + +public record CurriculumTopicResponse( + Long id, + String title, + int displayOrder +) { + public static CurriculumTopicResponse from(CurriculumTopic topic) { + return new CurriculumTopicResponse(topic.getId(), topic.getTitle(), topic.getDisplayOrder()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumWeekResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumWeekResponse.java new file mode 100644 index 0000000..c0876d9 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumWeekResponse.java @@ -0,0 +1,14 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumWeek; + +public record CurriculumWeekResponse( + Long id, + Integer weekFrom, + Integer weekTo, + int displayOrder +) { + public static CurriculumWeekResponse from(CurriculumWeek week) { + return new CurriculumWeekResponse(week.getId(), week.getWeekFrom(), week.getWeekTo(), week.getDisplayOrder()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java index 5bf398e..7dd0c3d 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java @@ -6,6 +6,9 @@ public enum CurriculumExceptionType implements BcsdExceptionType { CURRICULUM_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 커리큘럼입니다."), + WEEK_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 주차입니다."), + TOPIC_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 토픽입니다."), + WEEK_RANGE_INVALID(HttpStatus.BAD_REQUEST, "주차 범위가 올바르지 않습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumTopic.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumTopic.java new file mode 100644 index 0000000..6f2bc38 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumTopic.java @@ -0,0 +1,56 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.model; + +import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 주차의 토픽. 랜딩 번호(1,2,3…)는 별도 컬럼이 아니라 display_order + 1로 렌더한다(ADR-005) — + * 순서를 바꾸면 번호가 자동으로 재부여된다(AC-2.6). + */ +@Getter +@Entity +@Table(name = "curriculum_topic") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CurriculumTopic extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "week_id", nullable = false, updatable = false) + private CurriculumWeek week; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Builder + private CurriculumTopic(CurriculumWeek week, String title, int displayOrder) { + this.week = week; + this.title = title; + this.displayOrder = displayOrder; + } + + public void updateTitle(String title) { + this.title = title; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumTopicDetail.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumTopicDetail.java new file mode 100644 index 0000000..6f29acc --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumTopicDetail.java @@ -0,0 +1,48 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.model; + +import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 토픽의 세부 항목. 개별 CRUD가 아니라 전체 교체(T-11)로만 관리한다 — 에디터가 + * "Enter로 연속 입력"하는 텍스트 목록이라 문자열 배열 하나를 그대로 반영하는 편이 맞다. + */ +@Getter +@Entity +@Table(name = "curriculum_topic_detail") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CurriculumTopicDetail extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "topic_id", nullable = false, updatable = false) + private CurriculumTopic topic; + + @Column(name = "content", nullable = false) + private String content; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Builder + private CurriculumTopicDetail(CurriculumTopic topic, String content, int displayOrder) { + this.topic = topic; + this.content = content; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumWeek.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumWeek.java new file mode 100644 index 0000000..993117c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/CurriculumWeek.java @@ -0,0 +1,61 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.model; + +import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 커리큘럼의 주차. 단일 숫자(weekTo=null) 또는 범위(예: 14~17주차)로 표현한다. + * 하위 트리라 soft delete를 쓰지 않고 on delete cascade로 물리 삭제한다(INV-7). + */ +@Getter +@Entity +@Table(name = "curriculum_week") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CurriculumWeek extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "curriculum_id", nullable = false, updatable = false) + private Curriculum curriculum; + + @Column(name = "week_from", nullable = false) + private int weekFrom; + + @Column(name = "week_to") + private Integer weekTo; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Builder + private CurriculumWeek(Curriculum curriculum, int weekFrom, Integer weekTo, int displayOrder) { + this.curriculum = curriculum; + this.weekFrom = weekFrom; + this.weekTo = weekTo; + this.displayOrder = displayOrder; + } + + public void updateLabel(int weekFrom, Integer weekTo) { + this.weekFrom = weekFrom; + this.weekTo = weekTo; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicDetailRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicDetailRepository.java new file mode 100644 index 0000000..e565988 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicDetailRepository.java @@ -0,0 +1,12 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.repository; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumTopicDetail; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CurriculumTopicDetailRepository extends JpaRepository { + + List findAllByTopic_IdOrderByDisplayOrderAsc(Long topicId); + + void deleteAllByTopic_Id(Long topicId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicRepository.java new file mode 100644 index 0000000..e3129a2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicRepository.java @@ -0,0 +1,10 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.repository; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumTopic; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CurriculumTopicRepository extends JpaRepository { + + List findAllByWeek_IdOrderByDisplayOrderAsc(Long weekId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumWeekRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumWeekRepository.java new file mode 100644 index 0000000..cea5203 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumWeekRepository.java @@ -0,0 +1,10 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.repository; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumWeek; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CurriculumWeekRepository extends JpaRepository { + + List findAllByCurriculum_IdOrderByDisplayOrderAsc(Long curriculumId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java new file mode 100644 index 0000000..8466918 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java @@ -0,0 +1,173 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.service; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.TopicDetailsReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.TopicRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.WeekRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumTreeResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumTreeResponse.TopicNode; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumTreeResponse.WeekNode; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumTopicResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumWeekResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.exception.CurriculumException; +import com.bcsdlab.bcsdinternalapiv2.curriculum.exception.CurriculumExceptionType; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumTopic; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumTopicDetail; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumWeek; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumTopicDetailRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumTopicRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumWeekRepository; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminCurriculumTreeService { + + private final CurriculumRepository curriculumRepository; + private final CurriculumWeekRepository curriculumWeekRepository; + private final CurriculumTopicRepository curriculumTopicRepository; + private final CurriculumTopicDetailRepository curriculumTopicDetailRepository; + + public AdminCurriculumTreeResponse getTree(Long curriculumId) { + Curriculum curriculum = curriculumRepository.findById(curriculumId) + .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.CURRICULUM_NOT_FOUND)); + + List weeks = curriculumWeekRepository + .findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculumId).stream() + .map(week -> { + List topics = curriculumTopicRepository + .findAllByWeek_IdOrderByDisplayOrderAsc(week.getId()).stream() + .map(topic -> { + List details = curriculumTopicDetailRepository + .findAllByTopic_IdOrderByDisplayOrderAsc(topic.getId()).stream() + .map(CurriculumTopicDetail::getContent) + .toList(); + return new TopicNode(topic.getId(), topic.getTitle(), topic.getDisplayOrder(), + details); + }) + .toList(); + return new WeekNode(week.getId(), week.getWeekFrom(), week.getWeekTo(), week.getDisplayOrder(), + topics); + }) + .toList(); + + return AdminCurriculumTreeResponse.of(curriculum, weeks); + } + + @Transactional + public CurriculumWeekResponse createWeek(Long curriculumId, WeekRequest request) { + validateRange(request); + Curriculum curriculum = curriculumRepository.findById(curriculumId) + .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.CURRICULUM_NOT_FOUND)); + + int displayOrder = curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculumId).size(); + CurriculumWeek week = curriculumWeekRepository.save(CurriculumWeek.builder() + .curriculum(curriculum) + .weekFrom(request.weekFrom()) + .weekTo(request.weekTo()) + .displayOrder(displayOrder) + .build()); + return CurriculumWeekResponse.from(week); + } + + @Transactional + public CurriculumWeekResponse updateWeek(Long weekId, WeekRequest request) { + validateRange(request); + CurriculumWeek week = findWeekOrThrow(weekId); + week.updateLabel(request.weekFrom(), request.weekTo()); + return CurriculumWeekResponse.from(week); + } + + @Transactional + public void deleteWeek(Long weekId) { + curriculumWeekRepository.delete(findWeekOrThrow(weekId)); + } + + @Transactional + public void reorderWeeks(Long curriculumId, OrderRequest request) { + List weeks = curriculumWeekRepository + .findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculumId); + Map byId = weeks.stream() + .collect(Collectors.toMap(CurriculumWeek::getId, week -> week)); + + Map newOrders = DisplayOrders.reassign(request.ids(), byId.keySet()); + newOrders.forEach((id, order) -> byId.get(id).updateDisplayOrder(order)); + } + + @Transactional + public CurriculumTopicResponse createTopic(Long weekId, TopicRequest request) { + CurriculumWeek week = findWeekOrThrow(weekId); + int displayOrder = curriculumTopicRepository.findAllByWeek_IdOrderByDisplayOrderAsc(weekId).size(); + CurriculumTopic topic = curriculumTopicRepository.save(CurriculumTopic.builder() + .week(week) + .title(request.title()) + .displayOrder(displayOrder) + .build()); + return CurriculumTopicResponse.from(topic); + } + + @Transactional + public CurriculumTopicResponse updateTopic(Long topicId, TopicRequest request) { + CurriculumTopic topic = findTopicOrThrow(topicId); + topic.updateTitle(request.title()); + return CurriculumTopicResponse.from(topic); + } + + @Transactional + public void deleteTopic(Long topicId) { + curriculumTopicRepository.delete(findTopicOrThrow(topicId)); + } + + @Transactional + public void reorderTopics(Long weekId, OrderRequest request) { + List topics = curriculumTopicRepository.findAllByWeek_IdOrderByDisplayOrderAsc(weekId); + Map byId = topics.stream() + .collect(Collectors.toMap(CurriculumTopic::getId, topic -> topic)); + + Map newOrders = DisplayOrders.reassign(request.ids(), byId.keySet()); + newOrders.forEach((id, order) -> byId.get(id).updateDisplayOrder(order)); + } + + @Transactional + public List replaceDetails(Long topicId, TopicDetailsReplaceRequest request) { + CurriculumTopic topic = findTopicOrThrow(topicId); + curriculumTopicDetailRepository.deleteAllByTopic_Id(topicId); + + List contents = request.contents(); + List saved = new ArrayList<>(contents.size()); + for (int i = 0; i < contents.size(); i++) { + saved.add(curriculumTopicDetailRepository.save(CurriculumTopicDetail.builder() + .topic(topic) + .content(contents.get(i)) + .displayOrder(i) + .build())); + } + return saved.stream().map(CurriculumTopicDetail::getContent).toList(); + } + + private void validateRange(WeekRequest request) { + if (request.weekTo() != null && request.weekTo() < request.weekFrom()) { + throw new CurriculumException(CurriculumExceptionType.WEEK_RANGE_INVALID); + } + } + + private CurriculumWeek findWeekOrThrow(Long weekId) { + return curriculumWeekRepository.findById(weekId) + .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.WEEK_NOT_FOUND)); + } + + private CurriculumTopic findTopicOrThrow(Long topicId) { + return curriculumTopicRepository.findById(topicId) + .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.TOPIC_NOT_FOUND)); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumTreeIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumTreeIntegrationTest.java new file mode 100644 index 0000000..7887685 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumTreeIntegrationTest.java @@ -0,0 +1,220 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumTopic; +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.CurriculumWeek; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumTopicDetailRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumTopicRepository; +import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumWeekRepository; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; + +class AdminCurriculumTreeIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private CurriculumRepository curriculumRepository; + + @Autowired + private CurriculumWeekRepository curriculumWeekRepository; + + @Autowired + private CurriculumTopicRepository curriculumTopicRepository; + + @Autowired + private CurriculumTopicDetailRepository curriculumTopicDetailRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private Curriculum curriculum; + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + trackPageRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + TrackPage trackPage = trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).build()); + curriculum = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("비기너").published(true).displayOrder(0).build()); + + Member admin = memberRepository.save(Member.builder() + .studentNumber("20231111") + .password(passwordEncoder.encode(RAW_PASSWORD)) + .name("관리자") + .track(backend) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email("admin@bcsd.club") + .status(MemberStatus.ACTIVE) + .build()); + String body = mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentNumber\":\"20231111\",\"password\":\"%s\",\"rememberMe\":false}" + .formatted(RAW_PASSWORD))) + .andReturn().getResponse().getContentAsString(); + adminToken = objectMapper.readTree(body).get("accessToken").asText(); + memberRepository.updateRole(admin.getId(), MemberRole.ADMIN); + } + + @Test + @DisplayName("AC-2.5 weekTo가 weekFrom보다 작으면 400이다") + void 주차_범위가_거꾸로면_400() throws Exception { + mockMvc.perform(post("/v1/admin/curriculums/" + curriculum.getId() + "/weeks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"weekFrom\":5,\"weekTo\":3}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("범위 주차를 만들고 라벨을 수정할 수 있다") + void 범위_주차_생성과_수정() throws Exception { + String body = mockMvc.perform(post("/v1/admin/curriculums/" + curriculum.getId() + "/weeks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"weekFrom\":13,\"weekTo\":16}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.weekFrom").value(13)) + .andExpect(jsonPath("$.weekTo").value(16)) + .andReturn().getResponse().getContentAsString(); + long weekId = objectMapper.readTree(body).get("id").asLong(); + + mockMvc.perform(put("/v1/admin/weeks/" + weekId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"weekFrom\":14,\"weekTo\":17}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weekFrom").value(14)) + .andExpect(jsonPath("$.weekTo").value(17)); + } + + @Test + @DisplayName("AC-2.3 주차를 삭제하면 하위 토픽·세부항목이 cascade로 삭제되고 다른 주차는 영향받지 않는다") + void 주차_삭제는_하위_트리에만_cascade된다() throws Exception { + CurriculumWeek week1 = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(curriculum).weekFrom(1).displayOrder(0).build()); + CurriculumWeek week2 = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(curriculum).weekFrom(2).displayOrder(1).build()); + CurriculumTopic topic1 = curriculumTopicRepository.save( + CurriculumTopic.builder().week(week1).title("토픽1").displayOrder(0).build()); + curriculumTopicRepository.save(CurriculumTopic.builder().week(week2).title("토픽2").displayOrder(0).build()); + + mockMvc.perform(delete("/v1/admin/weeks/" + week1.getId()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + assertThat(curriculumWeekRepository.findById(week1.getId())).isEmpty(); + assertThat(curriculumTopicRepository.findById(topic1.getId())).isEmpty(); + assertThat(curriculumWeekRepository.findById(week2.getId())).isPresent(); + assertThat(curriculumTopicRepository.findAllByWeek_IdOrderByDisplayOrderAsc(week2.getId())).hasSize(1); + } + + @Test + @DisplayName("AC-2.6 토픽 순서를 바꾸면 3단 트리 조회에서 순서가 바뀐다") + void 토픽_순서_변경이_트리_조회에_반영된다() throws Exception { + CurriculumWeek week = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(curriculum).weekFrom(1).displayOrder(0).build()); + CurriculumTopic first = curriculumTopicRepository.save( + CurriculumTopic.builder().week(week).title("첫번째").displayOrder(0).build()); + CurriculumTopic second = curriculumTopicRepository.save( + CurriculumTopic.builder().week(week).title("두번째").displayOrder(1).build()); + + mockMvc.perform(patch("/v1/admin/weeks/" + week.getId() + "/topics/order") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\":[%d,%d]}".formatted(second.getId(), first.getId()))) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/admin/curriculums/" + curriculum.getId()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weeks[0].topics[0].title").value("두번째")) + .andExpect(jsonPath("$.weeks[0].topics[1].title").value("첫번째")); + } + + @Test + @DisplayName("AC-2.9 세부 항목 빈 배열은 에러가 아니라 전부 삭제한다") + void 빈_배열_전송_시_세부항목_전부_삭제() throws Exception { + CurriculumWeek week = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(curriculum).weekFrom(1).displayOrder(0).build()); + CurriculumTopic topic = curriculumTopicRepository.save( + CurriculumTopic.builder().week(week).title("JS 비동기").displayOrder(0).build()); + + mockMvc.perform(put("/v1/admin/topics/" + topic.getId() + "/details") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"contents\":[\"callback\",\"Promise\"]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + + mockMvc.perform(put("/v1/admin/topics/" + topic.getId() + "/details") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"contents\":[]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + + assertThat(curriculumTopicDetailRepository.findAllByTopic_IdOrderByDisplayOrderAsc(topic.getId())).isEmpty(); + } + + @Test + @DisplayName("3단 트리 조회에 토픽이 없는 주차도 포함된다 (공개 API의 제외 규칙과 다르다 — T-12에서 적용)") + void 관리자_트리_조회는_빈_주차도_포함한다() throws Exception { + curriculumWeekRepository.save(CurriculumWeek.builder().curriculum(curriculum).weekFrom(1).displayOrder(0) + .build()); + + mockMvc.perform(get("/v1/admin/curriculums/" + curriculum.getId()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.weeks.length()").value(1)) + .andExpect(jsonPath("$.weeks[0].topics.length()").value(0)); + } +}