diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c2af24 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# BCSD Internal API V2 + +BCSD 동아리 인터널(내부 관리) 서비스 백엔드. Spring Boot 4 / Java 21 / PostgreSQL 16. + +## 로컬 실행 + +### 요구 사항 +- JDK 21 +- Docker (로컬 Postgres 컨테이너용) + +### 절차 + +1. `.env` 생성 + ``` + cp .env.example .env + ``` + `.env`를 열어 `JWT_SECRET`을 32바이트 이상 임의 문자열로 채운다. + ``` + openssl rand -base64 32 + ``` + `.env`는 `.gitignore`에 포함되어 있다. + +2. 로컬 Postgres 기동 + ``` + docker compose up -d + ``` + 이미 로컬에 5432 포트를 쓰는 Postgres가 있다면(Postgres.app, `brew services` 등) 충돌한다. + `docker compose ps`로 컨테이너가 healthy인지 확인하고, 충돌 시: + ``` + COMPOSE_POSTGRES_PORT=5433 docker compose up -d + ``` + 와 함께 `.env`에 `DB_PORT=5433`을 추가한다. + +3. 애플리케이션 실행 + ``` + ./gradlew bootRun + ``` + `developmentOnly` 의존성인 `springboot4-dotenv`가 `.env`를 자동으로 읽는다. + 별도 `--spring.profiles.active` 지정은 필요 없다 — `application.yml`의 프로퍼티 기본값이 + 이미 로컬 개발을 기준으로 되어 있다(`DB_HOST:localhost`, `DB_PORT:5432` 등). + +4. 확인 + ``` + curl http://localhost:8080/health + ``` + `OK`(200)가 반환되면 정상이다. 부팅 로그에서 Flyway 마이그레이션이 전부 적용됐는지도 확인한다. + +### 테스트 실행 + +``` +./gradlew test +``` + +Testcontainers가 테스트 실행 시 Postgres 컨테이너를 자동으로 띄우므로 Docker가 실행 중이어야 한다. +로컬 Postgres(2번 단계)와는 별개다 — 테스트는 매번 격리된 컨테이너를 쓴다. + +### 흔히 겪는 문제 + +- **`Cannot find a Java installation on your machine ... {languageVersion=21}`** + 이 프로젝트는 JDK 21 툴체인을 요구한다. JDK 21을 설치한 뒤 `~/.gradle/gradle.properties` + (저장소가 아니라 사용자 홈, 커밋하지 않는다)에 다음을 추가한다. + ``` + org.gradle.java.installations.paths= + ``` + +- **`FATAL: role "postgres" does not exist`로 Flyway 마이그레이션이 실패한다** + 로컬에 이미 떠 있는 다른 Postgres(홈브루 서비스 등)가 5432를 선점하고 있다는 신호다. + 위 2번 단계의 `COMPOSE_POSTGRES_PORT` 대안을 쓴다. + +- **`JWT_SECRET`이 비어 있으면 부팅이 즉시 실패한다.** + `.env`에 32바이트 이상 값을 채웠는지 확인한다(`app.jwt.secret: ${JWT_SECRET}`에 기본값이 없다). + +## 배포 + +`main` push → GitHub Actions → ghcr.io 이미지 빌드 → 서버 SSH 접속 후 기존 컨테이너 종료·재시작(순차 재시작, 수초 다운타임). 서버가 KONECT 운영 서버를 공유하고 API 컨테이너 메모리 한도가 256MiB로 빠듯하므로, 배포 후 `docker stats`와 `free -h`로 확인한다. diff --git a/build.gradle.kts b/build.gradle.kts index 4398bc0..02db550 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,10 @@ repositories { dependencies { implementation(platform("software.amazon.awssdk:bom:2.53.2")) implementation("software.amazon.awssdk:ses") + // software.amazon.awssdk:s3에 S3Presigner가 포함되어 있어 별도 s3-presigner 아티팩트가 없다 + // (AWS SDK가 이 버전대에서 통합함 — Maven Central에 s3-presigner 좌표 자체가 존재하지 않는다). + implementation("software.amazon.awssdk:s3") + implementation("org.jsoup:jsoup:1.18.3") implementation("org.springframework.retry:spring-retry:2.0.13") implementation("org.aspectj:aspectjweaver:1.9.25") implementation("org.springframework.boot:spring-boot-starter-data-jpa") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3fb8ace --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + postgres: + image: postgres:16-alpine + container_name: bcsd-internal-api-v2-postgres + restart: unless-stopped + ports: + # 로컬에 이미 5432를 쓰는 Postgres가 있다면(Postgres.app, brew services 등) + # COMPOSE_POSTGRES_PORT=5433 docker compose up -d 로 띄우고 .env의 DB_PORT를 맞춘다. + - "${COMPOSE_POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_DB: bcsd_internal_api_v2 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - bcsd-internal-api-v2-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d bcsd_internal_api_v2"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + bcsd-internal-api-v2-pgdata: diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityApi.java new file mode 100644 index 0000000..df5d330 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityApi.java @@ -0,0 +1,28 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityTimelineResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; + +@Tag(name = "활동 API (홈페이지 공개)") +public interface ActivityApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", description = "존재하지 않거나 숨겨진 카테고리"), + }) + @Operation(summary = "연도 그룹 활동 타임라인", + description = "연도 내림차순 → 월 내림차순 → display_order 순으로, 공개된 활동만 반환합니다.") + List getTimeline(String category); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", description = "존재하지 않거나 숨겨진 활동"), + }) + @Operation(summary = "활동 상세") + ActivityDetailResponse getActivity(Long id); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryApi.java new file mode 100644 index 0000000..7e3a62c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryApi.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityCategoryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; + +@Tag(name = "활동 API (홈페이지 공개)") +public interface ActivityCategoryApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + }) + @Operation(summary = "공개 활동 카테고리 목록", description = "탭 노출 순서(display_order)대로 공개된 카테고리만 반환합니다.") + List getCategories(); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryController.java new file mode 100644 index 0000000..5099e63 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryController.java @@ -0,0 +1,23 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.service.ActivityCategoryService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/activity-categories") +@RequiredArgsConstructor +public class ActivityCategoryController implements ActivityCategoryApi { + + private final ActivityCategoryService activityCategoryService; + + @Override + @GetMapping + public List getCategories() { + return activityCategoryService.getCategories(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityController.java new file mode 100644 index 0000000..dd52ad2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityController.java @@ -0,0 +1,32 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityTimelineResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.service.ActivityService; +import java.util.List; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/v1/activities") +@RequiredArgsConstructor +public class ActivityController implements ActivityApi { + + private final ActivityService activityService; + + @Override + @GetMapping + public List getTimeline(@RequestParam String category) { + return activityService.getTimeline(category); + } + + @Override + @GetMapping("/{id}") + public ActivityDetailResponse getActivity(@PathVariable Long id) { + return activityService.getActivity(id); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityApi.java new file mode 100644 index 0000000..e0d655d --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityApi.java @@ -0,0 +1,110 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityImagesReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivitySummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "관리자 - 활동 API") +@SecurityRequirement(name = "JWT") +public interface AdminActivityApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "활동 목록", description = "숨김 포함. categoryId/year/published로 선택적으로 필터링한다.") + Page getActivities(@RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Boolean published, + Pageable pageable); + + @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 = "활동 상세 조회") + AdminActivityDetailResponse getActivity(@PathVariable Long id); + + @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 = "본문은 저장 전 정제된다(ADR-008). 생성 직후 공개 상태다.") + ResponseEntity createActivity(@RequestBody @Valid ActivityCreateRequest request); + + @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 = "활동 수정") + AdminActivityDetailResponse updateActivity(@PathVariable Long id, + @RequestBody @Valid ActivityUpdateRequest 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 = "soft delete — 데이터는 보존된다.") + ResponseEntity deleteActivity(@PathVariable Long id); + + @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 = "공개/숨김") + ResponseEntity publish(@PathVariable Long id, @RequestBody @Valid PublishRequest 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 reorder(@Parameter(required = true) @RequestParam Long categoryId, + @Parameter(required = true) @RequestParam int year, + @Parameter(required = true) @RequestParam int month, + @RequestBody @Valid OrderRequest request); + + @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 = "활동 사진 전체 교체", description = "첫 번째 항목이 목록 썸네일이다(INV-12).") + List replaceImages(@PathVariable Long id, @RequestBody @Valid ActivityImagesReplaceRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryApi.java new file mode 100644 index 0000000..3397bf1 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryApi.java @@ -0,0 +1,80 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +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 AdminActivityCategoryApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "활동 카테고리 목록", description = "숨김 포함 전체, display_order 순.") + List getCategories(); + + @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 = "409", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "활동 카테고리 생성") + ResponseEntity createCategory( + @RequestBody @Valid ActivityCategoryCreateRequest request); + + @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 = "이름·헤드라인·히어로 이미지 수정", description = "slug는 불변이다.") + AdminActivityCategoryResponse updateCategory(@PathVariable Long id, + @RequestBody @Valid ActivityCategoryUpdateRequest 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))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "활동 카테고리 삭제", description = "soft delete. 활동이 남아 있으면 409다(AC-3.8).") + ResponseEntity deleteCategory(@PathVariable Long id); + + @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 = "공개/숨김") + ResponseEntity publish(@PathVariable Long id, @RequestBody @Valid PublishRequest 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 reorder(@RequestBody @Valid OrderRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryController.java new file mode 100644 index 0000000..5a8b24a --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryController.java @@ -0,0 +1,71 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.service.AdminActivityCategoryService; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import jakarta.validation.Valid; +import java.util.List; +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.GetMapping; +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/activity-categories") +@RequiredArgsConstructor +public class AdminActivityCategoryController implements AdminActivityCategoryApi { + + private final AdminActivityCategoryService adminActivityCategoryService; + + @Override + @GetMapping + public List getCategories() { + return adminActivityCategoryService.getCategories(); + } + + @Override + @PostMapping + public ResponseEntity createCategory( + @Valid @RequestBody ActivityCategoryCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminActivityCategoryService.createCategory(request)); + } + + @Override + @PutMapping("/{id}") + public AdminActivityCategoryResponse updateCategory(@PathVariable Long id, + @Valid @RequestBody ActivityCategoryUpdateRequest request) { + return adminActivityCategoryService.updateCategory(id, request); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity deleteCategory(@PathVariable Long id) { + adminActivityCategoryService.deleteCategory(id); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/{id}/publish") + public ResponseEntity publish(@PathVariable Long id, @Valid @RequestBody PublishRequest request) { + adminActivityCategoryService.publish(id, request); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/order") + public ResponseEntity reorder(@Valid @RequestBody OrderRequest request) { + adminActivityCategoryService.reorder(request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityController.java new file mode 100644 index 0000000..ff8bd9c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityController.java @@ -0,0 +1,93 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityImagesReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivitySummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.service.AdminActivityService; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +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.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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/activities") +@RequiredArgsConstructor +public class AdminActivityController implements AdminActivityApi { + + private final AdminActivityService adminActivityService; + + @Override + @GetMapping + public Page getActivities(@RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Boolean published, + Pageable pageable) { + return adminActivityService.getActivities(categoryId, year, published, pageable); + } + + @Override + @GetMapping("/{id}") + public AdminActivityDetailResponse getActivity(@PathVariable Long id) { + return adminActivityService.getActivity(id); + } + + @Override + @PostMapping + public ResponseEntity createActivity( + @Valid @RequestBody ActivityCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminActivityService.createActivity(request)); + } + + @Override + @PutMapping("/{id}") + public AdminActivityDetailResponse updateActivity(@PathVariable Long id, + @Valid @RequestBody ActivityUpdateRequest request) { + return adminActivityService.updateActivity(id, request); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity deleteActivity(@PathVariable Long id) { + adminActivityService.deleteActivity(id); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/{id}/publish") + public ResponseEntity publish(@PathVariable Long id, @Valid @RequestBody PublishRequest request) { + adminActivityService.publish(id, request); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/order") + public ResponseEntity reorder(@RequestParam Long categoryId, @RequestParam int year, + @RequestParam int month, @Valid @RequestBody OrderRequest request) { + adminActivityService.reorder(categoryId, year, month, request); + return ResponseEntity.noContent().build(); + } + + @Override + @PutMapping("/{id}/images") + public List replaceImages(@PathVariable Long id, + @Valid @RequestBody ActivityImagesReplaceRequest request) { + return adminActivityService.replaceImages(id, request); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryCreateRequest.java new file mode 100644 index 0000000..b5f08ff --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryCreateRequest.java @@ -0,0 +1,21 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record ActivityCategoryCreateRequest( + @NotBlank + @Pattern(regexp = "^[a-z0-9]+(-[a-z0-9]+)*$", message = "slug는 소문자·숫자·하이픈만 사용할 수 있습니다.") + String slug, + + @NotBlank + @Size(max = 30) + String name, + + @Size(max = 200) + String headline, + + String heroImageUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryUpdateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryUpdateRequest.java new file mode 100644 index 0000000..76359f1 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryUpdateRequest.java @@ -0,0 +1,16 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ActivityCategoryUpdateRequest( + @NotBlank + @Size(max = 30) + String name, + + @Size(max = 200) + String headline, + + String heroImageUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCreateRequest.java new file mode 100644 index 0000000..a18dc4b --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCreateRequest.java @@ -0,0 +1,39 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * 공개/숨김은 별도 PATCH .../publish로만 다룬다(트랙 페이지·커리큘럼과 동일한 규약). + * 사진은 PUT .../images 전체 교체로만 다룬다 — 항목이 여러 개고 "드래그 후 저장" 화면이라 + * study-points/tech-stacks와 같은 패턴을 쓴다. + */ +public record ActivityCreateRequest( + @NotNull Long categoryId, + + @NotNull + @Min(2000) + @Max(2100) + Integer year, + + @NotNull + @Min(1) + @Max(12) + Integer month, + + @NotBlank + @Size(max = 80) + String title, + + @NotBlank + @Size(max = 200) + String summary, + + String content, + + String externalUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityImagesReplaceRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityImagesReplaceRequest.java new file mode 100644 index 0000000..cf1a768 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityImagesReplaceRequest.java @@ -0,0 +1,14 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * 활동 사진 전체 교체. 배열의 첫 번째가 목록 썸네일이다(INV-12). + */ +public record ActivityImagesReplaceRequest( + @NotNull + List<@NotBlank String> imageUrls +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityUpdateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityUpdateRequest.java new file mode 100644 index 0000000..27916bb --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityUpdateRequest.java @@ -0,0 +1,36 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * categoryId는 수정 대상이 아니다 — 카테고리를 옮기는 요구가 없고, 잘못 만들었으면 + * 삭제 후 다시 만드는 편이 트리(사진 포함)를 옮기는 것보다 단순하다. + */ +public record ActivityUpdateRequest( + @NotNull + @Min(2000) + @Max(2100) + Integer year, + + @NotNull + @Min(1) + @Max(12) + Integer month, + + @NotBlank + @Size(max = 80) + String title, + + @NotBlank + @Size(max = 200) + String summary, + + String content, + + String externalUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityCategoryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityCategoryResponse.java new file mode 100644 index 0000000..d844856 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityCategoryResponse.java @@ -0,0 +1,15 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; + +public record ActivityCategoryResponse( + String slug, + String name, + String headline, + String heroImageUrl +) { + public static ActivityCategoryResponse from(ActivityCategory category) { + return new ActivityCategoryResponse( + category.getSlug(), category.getName(), category.getHeadline(), category.getHeroImageUrl()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityDetailResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityDetailResponse.java new file mode 100644 index 0000000..ed18c58 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityDetailResponse.java @@ -0,0 +1,30 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import java.util.List; + +public record ActivityDetailResponse( + Long id, + String categorySlug, + int year, + int month, + String title, + String summary, + String content, + List images, + String externalUrl +) { + public static ActivityDetailResponse of(Activity activity, List images) { + return new ActivityDetailResponse( + activity.getId(), + activity.getCategory().getSlug(), + activity.getYear(), + activity.getMonth(), + activity.getTitle(), + activity.getSummary(), + activity.getContent(), + images, + activity.getExternalUrl() + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityListItemResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityListItemResponse.java new file mode 100644 index 0000000..9fa12c0 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityListItemResponse.java @@ -0,0 +1,28 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import java.util.List; + +public record ActivityListItemResponse( + Long id, + int month, + String title, + String summary, + String thumbnailUrl, + List images, + String externalUrl, + boolean hasDetail +) { + public static ActivityListItemResponse of(Activity activity, List images) { + return new ActivityListItemResponse( + activity.getId(), + activity.getMonth(), + activity.getTitle(), + activity.getSummary(), + images.isEmpty() ? null : images.get(0), + images, + activity.getExternalUrl(), + activity.hasDetail() + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityTimelineResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityTimelineResponse.java new file mode 100644 index 0000000..86110ef --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityTimelineResponse.java @@ -0,0 +1,9 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import java.util.List; + +public record ActivityTimelineResponse( + int year, + List activities +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityCategoryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityCategoryResponse.java new file mode 100644 index 0000000..63332e2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityCategoryResponse.java @@ -0,0 +1,23 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; + +/** + * 활동 건수(activity 잔존 여부 판단에 쓰는 그 수)는 아직 없다 — Activity가 생기는 + * T-15에서 이 레코드에 필드를 추가한다. + */ +public record AdminActivityCategoryResponse( + Long id, + String slug, + String name, + String headline, + String heroImageUrl, + boolean isPublished, + int displayOrder +) { + public static AdminActivityCategoryResponse from(ActivityCategory category) { + return new AdminActivityCategoryResponse( + category.getId(), category.getSlug(), category.getName(), category.getHeadline(), + category.getHeroImageUrl(), category.isPublished(), category.getDisplayOrder()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityDetailResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityDetailResponse.java new file mode 100644 index 0000000..0d29664 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityDetailResponse.java @@ -0,0 +1,34 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import java.util.List; + +public record AdminActivityDetailResponse( + Long id, + Long categoryId, + Integer year, + Integer month, + String title, + String summary, + String content, + String externalUrl, + boolean isPublished, + int displayOrder, + List imageUrls +) { + public static AdminActivityDetailResponse of(Activity activity, List imageUrls) { + return new AdminActivityDetailResponse( + activity.getId(), + activity.getCategory().getId(), + activity.getYear(), + activity.getMonth(), + activity.getTitle(), + activity.getSummary(), + activity.getContent(), + activity.getExternalUrl(), + activity.isPublished(), + activity.getDisplayOrder(), + imageUrls + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivitySummaryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivitySummaryResponse.java new file mode 100644 index 0000000..8d10237 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivitySummaryResponse.java @@ -0,0 +1,19 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; + +public record AdminActivitySummaryResponse( + Long id, + Integer year, + Integer month, + String title, + String summary, + boolean isPublished, + int displayOrder +) { + public static AdminActivitySummaryResponse from(Activity activity) { + return new AdminActivitySummaryResponse( + activity.getId(), activity.getYear(), activity.getMonth(), activity.getTitle(), + activity.getSummary(), activity.isPublished(), activity.getDisplayOrder()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityException.java new file mode 100644 index 0000000..d31d347 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityException.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; + +public class ActivityException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public ActivityException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java new file mode 100644 index 0000000..7e8d9d8 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java @@ -0,0 +1,55 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; +import org.springframework.http.HttpStatus; + +public enum ActivityExceptionType implements BcsdExceptionType { + + ACTIVITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동입니다."), + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동 카테고리입니다."), + CATEGORY_SLUG_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 카테고리 주소입니다."), + CATEGORY_HAS_ACTIVITIES(HttpStatus.CONFLICT, "활동이 남아 있는 카테고리는 삭제할 수 없습니다."), + ; + + private final HttpStatus status; + private final String message; + + ActivityExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedActivityExceptionType(this, detailMessage); + } + + private record DetailedActivityExceptionType(ActivityExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedActivityExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/Activity.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/Activity.java new file mode 100644 index 0000000..bb76874 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/Activity.java @@ -0,0 +1,97 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.model; + +import com.bcsdlab.bcsdinternalapiv2.global.SoftDeletableEntity; +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; +import org.hibernate.annotations.SQLRestriction; + +/** + * 활동 타임라인의 한 항목. 최상위 엔티티라 soft delete를 쓴다(ADR-006). year/month는 + * date가 아니라 정수 두 개다 — 일(day) 개념이 없고 랜딩의 연도 그룹핑에 그대로 맞는다(ADR-007). + */ +@Getter +@Entity +@Table(name = "activity") +@SQLRestriction("deleted_at is null") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Activity extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false, updatable = false) + private ActivityCategory category; + + @Column(name = "year", nullable = false) + private int year; + + @Column(name = "month", nullable = false) + private int month; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "summary", nullable = false) + private String summary; + + @Column(name = "content", columnDefinition = "text") + private String content; + + @Column(name = "external_url") + private String externalUrl; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "is_published", nullable = false) + private boolean published; + + @Builder + private Activity(ActivityCategory category, int year, int month, String title, String summary, String content, + String externalUrl, int displayOrder, boolean published) { + this.category = category; + this.year = year; + this.month = month; + this.title = title; + this.summary = summary; + this.content = content; + this.externalUrl = externalUrl; + this.displayOrder = displayOrder; + this.published = published; + } + + public void updateContent(int year, int month, String title, String summary, String content, + String externalUrl) { + this.year = year; + this.month = month; + this.title = title; + this.summary = summary; + this.content = content; + this.externalUrl = externalUrl; + } + + public void updatePublished(boolean published) { + this.published = published; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } + + public boolean hasDetail() { + return content != null && !content.isBlank(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityCategory.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityCategory.java new file mode 100644 index 0000000..7462349 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityCategory.java @@ -0,0 +1,74 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.model; + +import com.bcsdlab.bcsdinternalapiv2.global.SoftDeletableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; + +/** + * 활동 탭(EVENT/GAME/KOIN 등). 최상위 엔티티라 soft delete를 쓴다(ADR-006). + * 삭제 시 "활동이 남아 있으면 거부"(AC-3.8)는 Activity 엔티티가 생기는 T-15에서 + * 함께 구현한다 — 지금은 Activity가 없어 검증할 대상이 없다. + */ +@Getter +@Entity +@Table(name = "activity_category") +@SQLRestriction("deleted_at is null") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ActivityCategory extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "slug", nullable = false, updatable = false) + private String slug; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "headline") + private String headline; + + @Column(name = "hero_image_url") + private String heroImageUrl; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "is_published", nullable = false) + private boolean published; + + @Builder + private ActivityCategory(String slug, String name, String headline, String heroImageUrl, int displayOrder, + boolean published) { + this.slug = slug; + this.name = name; + this.headline = headline; + this.heroImageUrl = heroImageUrl; + this.displayOrder = displayOrder; + this.published = published; + } + + public void updateHeader(String name, String headline, String heroImageUrl) { + this.name = name; + this.headline = headline; + this.heroImageUrl = heroImageUrl; + } + + public void updatePublished(boolean published) { + this.published = published; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityImage.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityImage.java new file mode 100644 index 0000000..9bf706b --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityImage.java @@ -0,0 +1,60 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +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 java.time.Instant; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * 활동 사진. 하위 트리라 soft delete 없이 on delete cascade로 물리 삭제하고, 전체 + * 교체(T-15)로만 관리한다. display_order=0이 목록 썸네일이다(INV-12). + * + *

{@code activity_image} 테이블에는 {@code updated_at}이 없다 — 사진은 개별 수정 없이 + * 항상 전체 교체(삭제 후 재생성)로만 다루므로 생성 시각만 있으면 된다. 그래서 + * {@link com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity}를 쓰지 않고 직접 매핑한다. + */ +@Getter +@Entity +@Table(name = "activity_image") +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ActivityImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "activity_id", nullable = false, updatable = false) + private Activity activity; + + @Column(name = "image_url", nullable = false) + private String imageUrl; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Builder + private ActivityImage(Activity activity, String imageUrl, int displayOrder) { + this.activity = activity; + this.imageUrl = imageUrl; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java new file mode 100644 index 0000000..fde229f --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.repository; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityCategoryRepository extends JpaRepository { + + List findAllByOrderByDisplayOrderAsc(); + + List findAllByPublishedTrueOrderByDisplayOrderAsc(); + + Optional findBySlugAndPublishedTrue(String slug); + + boolean existsBySlug(String slug); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityImageRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityImageRepository.java new file mode 100644 index 0000000..f2aeec5 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityImageRepository.java @@ -0,0 +1,14 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.repository; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityImage; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityImageRepository extends JpaRepository { + + List findAllByActivity_IdOrderByDisplayOrderAsc(Long activityId); + + List findAllByActivity_IdInOrderByDisplayOrderAsc(List activityIds); + + void deleteAllByActivity_Id(Long activityId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java new file mode 100644 index 0000000..a254c1f --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.repository; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface ActivityRepository extends JpaRepository, JpaSpecificationExecutor { + + boolean existsByCategory_Id(Long categoryId); + + List findAllByCategory_IdAndYearAndMonthOrderByDisplayOrderAsc(Long categoryId, int year, int month); + + List findAllByCategory_IdAndPublishedTrueOrderByYearDescMonthDescDisplayOrderAsc(Long categoryId); + + Optional findByIdAndPublishedTrue(Long id); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityCategoryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityCategoryService.java new file mode 100644 index 0000000..f031605 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityCategoryService.java @@ -0,0 +1,22 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.service; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActivityCategoryService { + + private final ActivityCategoryRepository activityCategoryRepository; + + public List getCategories() { + return activityCategoryRepository.findAllByPublishedTrueOrderByDisplayOrderAsc().stream() + .map(ActivityCategoryResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityService.java new file mode 100644 index 0000000..eed6dc3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityService.java @@ -0,0 +1,73 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.service; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityListItemResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityTimelineResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityException; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityExceptionType; +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityImage; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityImageRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityRepository; +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; + +/** + * 활동 타임라인(FR-5.1)이 쓰는 공개 조회. 연도 그룹 개수와 무관하게 항상 쿼리 3개 + * (활동 목록, 이미지 IN 절 배치 조회 각 1회 + 카테고리 조회 1회)로 끝낸다 — 홈페이지 빌드가 + * 매번 호출하는 공개 경로라 {@link com.bcsdlab.bcsdinternalapiv2.curriculum.service.CurriculumQueryService}와 + * 같은 이유로 N+1을 명시적으로 피한다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActivityService { + + private final ActivityRepository activityRepository; + private final ActivityImageRepository activityImageRepository; + private final ActivityCategoryRepository activityCategoryRepository; + + public List getTimeline(String categorySlug) { + ActivityCategory category = activityCategoryRepository.findBySlugAndPublishedTrue(categorySlug) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.CATEGORY_NOT_FOUND)); + + List activities = activityRepository + .findAllByCategory_IdAndPublishedTrueOrderByYearDescMonthDescDisplayOrderAsc(category.getId()); + List years = activities.stream().map(Activity::getYear).distinct().toList(); + Map> activitiesByYear = activities.stream() + .collect(Collectors.groupingBy(Activity::getYear)); + + Map> imagesByActivityId = imagesByActivityId(activities); + + return years.stream() + .map(year -> new ActivityTimelineResponse(year, activitiesByYear.get(year).stream() + .map(activity -> ActivityListItemResponse.of( + activity, imagesByActivityId.getOrDefault(activity.getId(), List.of()))) + .toList())) + .toList(); + } + + public ActivityDetailResponse getActivity(Long id) { + Activity activity = activityRepository.findByIdAndPublishedTrue(id) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.ACTIVITY_NOT_FOUND)); + List images = activityImageRepository.findAllByActivity_IdOrderByDisplayOrderAsc(id).stream() + .map(ActivityImage::getImageUrl) + .toList(); + return ActivityDetailResponse.of(activity, images); + } + + private Map> imagesByActivityId(List activities) { + List activityIds = activities.stream().map(Activity::getId).toList(); + List images = activityIds.isEmpty() + ? List.of() : activityImageRepository.findAllByActivity_IdInOrderByDisplayOrderAsc(activityIds); + return images.stream() + .collect(Collectors.groupingBy(image -> image.getActivity().getId(), + Collectors.mapping(ActivityImage::getImageUrl, Collectors.toList()))); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java new file mode 100644 index 0000000..9cd168d --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java @@ -0,0 +1,90 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.service; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityException; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityExceptionType; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityRepository; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; +import java.time.Instant; +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 AdminActivityCategoryService { + + private final ActivityCategoryRepository activityCategoryRepository; + private final ActivityRepository activityRepository; + + public List getCategories() { + return activityCategoryRepository.findAllByOrderByDisplayOrderAsc().stream() + .map(AdminActivityCategoryResponse::from) + .toList(); + } + + @Transactional + public AdminActivityCategoryResponse createCategory(ActivityCategoryCreateRequest request) { + if (activityCategoryRepository.existsBySlug(request.slug())) { + throw new ActivityException(ActivityExceptionType.CATEGORY_SLUG_DUPLICATED); + } + + int displayOrder = activityCategoryRepository.findAllByOrderByDisplayOrderAsc().size(); + ActivityCategory category = activityCategoryRepository.save(ActivityCategory.builder() + .slug(request.slug()) + .name(request.name()) + .headline(request.headline()) + .heroImageUrl(request.heroImageUrl()) + .displayOrder(displayOrder) + .published(true) + .build()); + return AdminActivityCategoryResponse.from(category); + } + + @Transactional + public AdminActivityCategoryResponse updateCategory(Long id, ActivityCategoryUpdateRequest request) { + ActivityCategory category = findOrThrow(id); + category.updateHeader(request.name(), request.headline(), request.heroImageUrl()); + return AdminActivityCategoryResponse.from(category); + } + + @Transactional + public void deleteCategory(Long id) { + // AC-3.8: soft delete라 DB의 activity FK 제약(RESTRICT)이 이걸 막아주지 않는다 — + // 행이 물리적으로 남아 있으니 FK 위반이 나지 않는다. 애플리케이션에서 직접 검사한다. + if (activityRepository.existsByCategory_Id(id)) { + throw new ActivityException(ActivityExceptionType.CATEGORY_HAS_ACTIVITIES); + } + findOrThrow(id).delete(Instant.now()); + } + + @Transactional + public void publish(Long id, PublishRequest request) { + findOrThrow(id).updatePublished(request.isPublished()); + } + + @Transactional + public void reorder(OrderRequest request) { + List categories = activityCategoryRepository.findAllByOrderByDisplayOrderAsc(); + Map byId = categories.stream() + .collect(Collectors.toMap(ActivityCategory::getId, category -> category)); + + Map newOrders = DisplayOrders.reassign(request.ids(), byId.keySet()); + newOrders.forEach((id, order) -> byId.get(id).updateDisplayOrder(order)); + } + + private ActivityCategory findOrThrow(Long id) { + return activityCategoryRepository.findById(id) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.CATEGORY_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityService.java new file mode 100644 index 0000000..255917c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityService.java @@ -0,0 +1,140 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.service; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityImagesReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivitySummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityException; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityExceptionType; +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityImage; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityImageRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.util.ActivityContentSanitizer; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminActivityService { + + private final ActivityRepository activityRepository; + private final ActivityImageRepository activityImageRepository; + private final ActivityCategoryRepository activityCategoryRepository; + + public Page getActivities(Long categoryId, Integer year, Boolean published, + Pageable pageable) { + List> specs = new ArrayList<>(); + if (categoryId != null) { + specs.add((root, query, cb) -> cb.equal(root.get("category").get("id"), categoryId)); + } + if (year != null) { + specs.add((root, query, cb) -> cb.equal(root.get("year"), year)); + } + if (published != null) { + specs.add((root, query, cb) -> cb.equal(root.get("published"), published)); + } + return activityRepository.findAll(Specification.allOf(specs), pageable) + .map(AdminActivitySummaryResponse::from); + } + + public AdminActivityDetailResponse getActivity(Long id) { + Activity activity = findOrThrow(id); + return AdminActivityDetailResponse.of(activity, imageUrls(id)); + } + + @Transactional + public AdminActivityDetailResponse createActivity(ActivityCreateRequest request) { + ActivityCategory category = activityCategoryRepository.findById(request.categoryId()) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.CATEGORY_NOT_FOUND)); + + int displayOrder = activityRepository.findAllByCategory_IdAndYearAndMonthOrderByDisplayOrderAsc( + request.categoryId(), request.year(), request.month()).size(); + + Activity activity = activityRepository.save(Activity.builder() + .category(category) + .year(request.year()) + .month(request.month()) + .title(request.title()) + .summary(request.summary()) + .content(ActivityContentSanitizer.sanitize(request.content())) + .externalUrl(request.externalUrl()) + .displayOrder(displayOrder) + .published(true) + .build()); + return AdminActivityDetailResponse.of(activity, List.of()); + } + + @Transactional + public AdminActivityDetailResponse updateActivity(Long id, ActivityUpdateRequest request) { + Activity activity = findOrThrow(id); + activity.updateContent(request.year(), request.month(), request.title(), request.summary(), + ActivityContentSanitizer.sanitize(request.content()), request.externalUrl()); + return AdminActivityDetailResponse.of(activity, imageUrls(id)); + } + + @Transactional + public void deleteActivity(Long id) { + findOrThrow(id).delete(Instant.now()); + } + + @Transactional + public void publish(Long id, PublishRequest request) { + findOrThrow(id).updatePublished(request.isPublished()); + } + + @Transactional + public void reorder(Long categoryId, int year, int month, OrderRequest request) { + List activities = activityRepository + .findAllByCategory_IdAndYearAndMonthOrderByDisplayOrderAsc(categoryId, year, month); + Map byId = activities.stream() + .collect(Collectors.toMap(Activity::getId, activity -> activity)); + + Map newOrders = DisplayOrders.reassign(request.ids(), byId.keySet()); + newOrders.forEach((id, order) -> byId.get(id).updateDisplayOrder(order)); + } + + @Transactional + public List replaceImages(Long id, ActivityImagesReplaceRequest request) { + Activity activity = findOrThrow(id); + activityImageRepository.deleteAllByActivity_Id(id); + + List urls = request.imageUrls(); + List saved = new ArrayList<>(urls.size()); + for (int i = 0; i < urls.size(); i++) { + saved.add(activityImageRepository.save(ActivityImage.builder() + .activity(activity) + .imageUrl(urls.get(i)) + .displayOrder(i) + .build())); + } + return saved.stream().map(ActivityImage::getImageUrl).toList(); + } + + private List imageUrls(Long activityId) { + return activityImageRepository.findAllByActivity_IdOrderByDisplayOrderAsc(activityId).stream() + .map(ActivityImage::getImageUrl) + .toList(); + } + + private Activity findOrThrow(Long id) { + return activityRepository.findById(id) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.ACTIVITY_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/util/ActivityContentSanitizer.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/util/ActivityContentSanitizer.java new file mode 100644 index 0000000..e6e50fc --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/util/ActivityContentSanitizer.java @@ -0,0 +1,57 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.util; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Set; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.safety.Safelist; + +/** + * 활동 본문(리치텍스트)을 저장 시점에 정제한다(ADR-008). 렌더 시점에는 추가 정제를 하지 + * 않는다 — 저장이 유일한 관문이다. + * + *

허용 태그는 ADR-008이 정한 목록 그대로다. {@code img[src]}는 jsoup의 Safelist만으로는 + * 호스트 단위 제한이 안 되므로, safelist로 태그/속성을 거른 뒤 별도로 이미지 호스트를 + * 검사해서 허용 목록 밖이면 태그째 제거한다(INV-10). + */ +public final class ActivityContentSanitizer { + + private static final Set ALLOWED_IMAGE_HOSTS = Set.of("image.bcsdlab.com", "static.koreatech.in"); + + private static final Safelist SAFELIST = new Safelist() + .addTags("p", "br", "strong", "em", "u", "s", "ul", "ol", "li", "blockquote", + "h2", "h3", "code", "pre", "a", "img") + .addAttributes("a", "href", "target", "rel") + .addAttributes("img", "src", "alt", "width") + .addProtocols("a", "href", "http", "https") + .addProtocols("img", "src", "http", "https"); + + private ActivityContentSanitizer() { + } + + public static String sanitize(String rawHtml) { + if (rawHtml == null) { + return null; + } + + String cleaned = Jsoup.clean(rawHtml, SAFELIST); + Document document = Jsoup.parseBodyFragment(cleaned); + for (Element img : document.select("img")) { + if (!isAllowedImageHost(img.attr("src"))) { + img.remove(); + } + } + return document.body().html(); + } + + private static boolean isAllowedImageHost(String src) { + try { + String host = new URI(src).getHost(); + return host != null && ALLOWED_IMAGE_HOSTS.contains(host); + } catch (URISyntaxException e) { + return false; + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/controller/dto/response/LoginResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/controller/dto/response/LoginResponse.java index f03d5d6..f63a444 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/controller/dto/response/LoginResponse.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/controller/dto/response/LoginResponse.java @@ -24,7 +24,7 @@ public static MemberSummary from(Member member) { member.getId(), member.getName(), member.getStudentNumber(), - member.getTrack().name(), + member.getTrack().getCode(), member.getGeneration(), member.getMemberType().name(), member.getUniversity() diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java new file mode 100644 index 0000000..76a6fce --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumApi.java @@ -0,0 +1,59 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +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; +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 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))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "커리큘럼 세트 이름 수정") + AdminCurriculumSummaryResponse updateCurriculum(@PathVariable Long id, + @RequestBody @Valid CurriculumUpdateRequest 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 = "soft delete — 하위 주차·토픽·세부항목은 cascade로 물리 삭제된다.") + ResponseEntity deleteCurriculum(@PathVariable Long id); + + @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 = "공개로 지정하면 같은 트랙의 기존 공개 세트가 자동으로 비공개된다(AC-2.1).") + ResponseEntity publish(@PathVariable Long id, @RequestBody @Valid PublishRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java new file mode 100644 index 0000000..ffd1256 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumController.java @@ -0,0 +1,55 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +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; +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") +@RequiredArgsConstructor +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}") + public AdminCurriculumSummaryResponse updateCurriculum(@PathVariable Long id, + @Valid @RequestBody CurriculumUpdateRequest request) { + return adminCurriculumService.updateCurriculum(id, request); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity deleteCurriculum(@PathVariable Long id) { + adminCurriculumService.deleteCurriculum(id); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/{id}/publish") + public ResponseEntity publish(@PathVariable Long id, @Valid @RequestBody PublishRequest request) { + adminCurriculumService.publish(id, request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java new file mode 100644 index 0000000..cf3a75f --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetApi.java @@ -0,0 +1,42 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +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 AdminCurriculumSetApi { + + @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 = "커리큘럼 세트 목록") + List getCurriculums(@PathVariable Long trackPageId); + + @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 = "커리큘럼 세트 생성", + description = "생성 직후에는 항상 비공개다. sourceCurriculumId를 주면 다른 세트(다른 트랙도 가능)를 " + + "트리 전체까지 복제한다(AC-2.8).") + ResponseEntity createCurriculum(@PathVariable Long trackPageId, + @RequestBody @Valid CurriculumCreateRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetController.java new file mode 100644 index 0000000..8146989 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/AdminCurriculumSetController.java @@ -0,0 +1,38 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.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/track-pages/{trackPageId}/curriculums") +@RequiredArgsConstructor +public class AdminCurriculumSetController implements AdminCurriculumSetApi { + + private final AdminCurriculumService adminCurriculumService; + + @Override + @GetMapping + public List getCurriculums(@PathVariable Long trackPageId) { + return adminCurriculumService.getCurriculums(trackPageId); + } + + @Override + @PostMapping + public ResponseEntity createCurriculum( + @PathVariable Long trackPageId, @Valid @RequestBody CurriculumCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(adminCurriculumService.createCurriculum(trackPageId, request)); + } +} 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/CurriculumCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumCreateRequest.java new file mode 100644 index 0000000..c9914a9 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumCreateRequest.java @@ -0,0 +1,20 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request; + +import jakarta.validation.constraints.Size; + +/** + * sourceCurriculumId를 주면 그 세트를 트리 전체(주차·토픽·세부항목)까지 복제한다(T-13, + * FR-2.9). 그 경우 name을 비워 두면 원본 이름을 그대로 쓴다 — 복제 직후 화면에서 이름을 + * 바꿀 수 있으므로 별도 입력을 강제하지 않는다. sourceCurriculumId가 없으면 name이 + * 필수다(서비스 레이어에서 검증). + */ +public record CurriculumCreateRequest( + @Size(max = 50) + String name, + + Long sourceCurriculumId +) { + public boolean isClone() { + return sourceCurriculumId != null; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumUpdateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumUpdateRequest.java new file mode 100644 index 0000000..44a404d --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/request/CurriculumUpdateRequest.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 CurriculumUpdateRequest( + @NotBlank + @Size(max = 50) + String name +) { +} 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/AdminCurriculumSummaryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/AdminCurriculumSummaryResponse.java new file mode 100644 index 0000000..f037766 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/AdminCurriculumSummaryResponse.java @@ -0,0 +1,19 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; + +/** + * 세트 목록용 요약. 주차·토픽 수는 아직 없다 — 그 개념이 생기는 T-11에서 이 레코드에 + * 필드를 추가한다. + */ +public record AdminCurriculumSummaryResponse( + Long id, + String name, + boolean isPublished, + int displayOrder +) { + public static AdminCurriculumSummaryResponse from(Curriculum curriculum) { + return new AdminCurriculumSummaryResponse( + curriculum.getId(), curriculum.getName(), curriculum.isPublished(), curriculum.getDisplayOrder()); + } +} 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/CurriculumResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumResponse.java new file mode 100644 index 0000000..6b1879a --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/controller/dto/response/CurriculumResponse.java @@ -0,0 +1,25 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response; + +import java.util.List; + +/** + * 공개 트랙 상세(GET /v1/tracks/{slug})에 담기는 커리큘럼. 공개 세트가 없으면 + * null이다(AC-2.2). 토픽이 0개인 주차는 weeks에서 제외한다(AC-2.7). + */ +public record CurriculumResponse( + String name, + List weeks +) { + public record WeekResponse( + Integer weekFrom, + Integer weekTo, + List topics + ) { + } + + public record TopicResponse( + String title, + 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/CurriculumException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumException.java new file mode 100644 index 0000000..720fb2c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumException.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; + +public class CurriculumException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public CurriculumException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java new file mode 100644 index 0000000..b5613d3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java @@ -0,0 +1,56 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; +import org.springframework.http.HttpStatus; + +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, "주차 범위가 올바르지 않습니다."), + NAME_REQUIRED(HttpStatus.BAD_REQUEST, "세트 이름은 필수입니다."), + ; + + private final HttpStatus status; + private final String message; + + CurriculumExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedCurriculumExceptionType(this, detailMessage); + } + + private record DetailedCurriculumExceptionType(CurriculumExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedCurriculumExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/Curriculum.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/Curriculum.java new file mode 100644 index 0000000..e04381f --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/model/Curriculum.java @@ -0,0 +1,63 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.model; + +import com.bcsdlab.bcsdinternalapiv2.global.SoftDeletableEntity; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +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; +import org.hibernate.annotations.SQLRestriction; + +/** + * 트랙 페이지의 커리큘럼 세트("비기너", "심화"). 트랙당 공개 세트는 하나뿐이다 + * (INV-2, {@code uq_curriculum_published} partial unique index). + */ +@Getter +@Entity +@Table(name = "curriculum") +@SQLRestriction("deleted_at is null") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Curriculum extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "track_page_id", nullable = false, updatable = false) + private TrackPage trackPage; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "is_published", nullable = false) + private boolean published; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Builder + private Curriculum(TrackPage trackPage, String name, boolean published, int displayOrder) { + this.trackPage = trackPage; + this.name = name; + this.published = published; + this.displayOrder = displayOrder; + } + + public void rename(String name) { + this.name = name; + } + + public void updatePublished(boolean published) { + this.published = published; + } +} 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/CurriculumRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumRepository.java new file mode 100644 index 0000000..c946efb --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumRepository.java @@ -0,0 +1,28 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.repository; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +public interface CurriculumRepository extends JpaRepository { + + List findAllByTrackPage_IdOrderByDisplayOrderAsc(Long trackPageId); + + Optional findByTrackPage_IdAndPublishedTrue(Long trackPageId); + + /** + * AC-2.1: 새 세트를 공개하기 전에, 같은 트랙의 다른 공개 세트를 먼저 비공개로 내린다. + * 벌크 UPDATE로 즉시 실행되므로, 뒤이어 대상 엔티티를 공개로 바꿔도 + * uq_curriculum_published(partial unique index)에 걸리지 않는다. + */ + @Modifying + @Transactional + @Query("update Curriculum c set c.published = false " + + "where c.trackPage.id = :trackPageId and c.id <> :excludeId and c.published = true") + int unpublishOthers(@Param("trackPageId") Long trackPageId, @Param("excludeId") Long excludeId); +} 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..236cf12 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicDetailRepository.java @@ -0,0 +1,14 @@ +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); + + List findAllByTopic_IdInOrderByDisplayOrderAsc(List topicIds); + + 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..61e87c3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/repository/CurriculumTopicRepository.java @@ -0,0 +1,12 @@ +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); + + List findAllByWeek_IdInOrderByDisplayOrderAsc(List weekIds); +} 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/AdminCurriculumService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java new file mode 100644 index 0000000..eee5089 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java @@ -0,0 +1,93 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.service; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse; +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.repository.CurriculumRepository; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminCurriculumService { + + private final CurriculumRepository curriculumRepository; + private final TrackPageRepository trackPageRepository; + private final AdminCurriculumTreeService adminCurriculumTreeService; + + public List getCurriculums(Long trackPageId) { + return curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).stream() + .map(AdminCurriculumSummaryResponse::from) + .toList(); + } + + @Transactional + public AdminCurriculumSummaryResponse createCurriculum(Long trackPageId, CurriculumCreateRequest request) { + TrackPage trackPage = trackPageRepository.findById(trackPageId) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_PAGE_NOT_FOUND)); + + String name = request.name(); + Curriculum source = null; + if (request.isClone()) { + source = findOrThrow(request.sourceCurriculumId()); + if (name == null || name.isBlank()) { + name = source.getName(); + } + } else if (name == null || name.isBlank()) { + throw new CurriculumException(CurriculumExceptionType.NAME_REQUIRED); + } + + int displayOrder = curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size(); + Curriculum curriculum = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage) + .name(name) + .published(false) + .displayOrder(displayOrder) + .build()); + + if (source != null) { + adminCurriculumTreeService.cloneTree(source, curriculum); + } + return AdminCurriculumSummaryResponse.from(curriculum); + } + + @Transactional + public AdminCurriculumSummaryResponse updateCurriculum(Long id, CurriculumUpdateRequest request) { + Curriculum curriculum = findOrThrow(id); + curriculum.rename(request.name()); + return AdminCurriculumSummaryResponse.from(curriculum); + } + + @Transactional + public void deleteCurriculum(Long id) { + findOrThrow(id).delete(Instant.now()); + } + + @Transactional + public void publish(Long id, PublishRequest request) { + Curriculum curriculum = findOrThrow(id); + if (request.isPublished()) { + // AC-2.1: 벌크 UPDATE로 다른 공개 세트를 먼저 내려야, 뒤이은 dirty-check 반영 시 + // uq_curriculum_published(partial unique index)에 걸리지 않는다. + curriculumRepository.unpublishOthers(curriculum.getTrackPage().getId(), id); + } + curriculum.updatePublished(request.isPublished()); + } + + private Curriculum findOrThrow(Long id) { + return curriculumRepository.findById(id) + .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.CURRICULUM_NOT_FOUND)); + } +} 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..4277eff --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java @@ -0,0 +1,208 @@ +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(); + } + + /** + * source의 주차 > 토픽 > 세부항목을 전부 target 아래로 복제한다(AC-2.8). id는 + * 새로 발급되지만 개수·순서(display_order)는 원본과 동일하게 유지한다. + */ + @Transactional + public void cloneTree(Curriculum source, Curriculum target) { + for (CurriculumWeek sourceWeek : curriculumWeekRepository + .findAllByCurriculum_IdOrderByDisplayOrderAsc(source.getId())) { + CurriculumWeek clonedWeek = curriculumWeekRepository.save(CurriculumWeek.builder() + .curriculum(target) + .weekFrom(sourceWeek.getWeekFrom()) + .weekTo(sourceWeek.getWeekTo()) + .displayOrder(sourceWeek.getDisplayOrder()) + .build()); + + for (CurriculumTopic sourceTopic : curriculumTopicRepository + .findAllByWeek_IdOrderByDisplayOrderAsc(sourceWeek.getId())) { + CurriculumTopic clonedTopic = curriculumTopicRepository.save(CurriculumTopic.builder() + .week(clonedWeek) + .title(sourceTopic.getTitle()) + .displayOrder(sourceTopic.getDisplayOrder()) + .build()); + + for (CurriculumTopicDetail sourceDetail : curriculumTopicDetailRepository + .findAllByTopic_IdOrderByDisplayOrderAsc(sourceTopic.getId())) { + curriculumTopicDetailRepository.save(CurriculumTopicDetail.builder() + .topic(clonedTopic) + .content(sourceDetail.getContent()) + .displayOrder(sourceDetail.getDisplayOrder()) + .build()); + } + } + } + } + + 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/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/CurriculumQueryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/CurriculumQueryService.java new file mode 100644 index 0000000..06fb791 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/CurriculumQueryService.java @@ -0,0 +1,76 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.service; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumResponse.TopicResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumResponse.WeekResponse; +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 java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 공개 트랙 상세(ADR-013)가 쓰는 커리큘럼 조회. 트리 깊이와 무관하게 항상 쿼리 3개 + * (주차/토픽/세부항목 각 1회, IN 절 배치 조회)로 끝낸다 — 관리자용 3단 트리 조회(T-11)는 + * 편집 화면 트래픽이라 N+1을 감수했지만, 이건 홈페이지 빌드가 매번 호출하는 공개 + * 경로라 명시적으로 N+1을 피했다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CurriculumQueryService { + + private final CurriculumRepository curriculumRepository; + private final CurriculumWeekRepository curriculumWeekRepository; + private final CurriculumTopicRepository curriculumTopicRepository; + private final CurriculumTopicDetailRepository curriculumTopicDetailRepository; + + public Optional getPublishedCurriculum(Long trackPageId) { + Curriculum curriculum = curriculumRepository.findByTrackPage_IdAndPublishedTrue(trackPageId).orElse(null); + if (curriculum == null) { + return Optional.empty(); + } + + List weeks = curriculumWeekRepository + .findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculum.getId()); + List weekIds = weeks.stream().map(CurriculumWeek::getId).toList(); + + List topics = weekIds.isEmpty() + ? List.of() : curriculumTopicRepository.findAllByWeek_IdInOrderByDisplayOrderAsc(weekIds); + Map> topicsByWeekId = topics.stream() + .collect(Collectors.groupingBy(topic -> topic.getWeek().getId())); + + List topicIds = topics.stream().map(CurriculumTopic::getId).toList(); + List details = topicIds.isEmpty() + ? List.of() : curriculumTopicDetailRepository.findAllByTopic_IdInOrderByDisplayOrderAsc(topicIds); + Map> detailsByTopicId = details.stream() + .collect(Collectors.groupingBy(detail -> detail.getTopic().getId())); + + List weekResponses = weeks.stream() + .map(week -> { + List topicResponses = topicsByWeekId + .getOrDefault(week.getId(), List.of()).stream() + .map(topic -> new TopicResponse( + topic.getTitle(), + detailsByTopicId.getOrDefault(topic.getId(), List.of()).stream() + .map(CurriculumTopicDetail::getContent) + .toList())) + .toList(); + return new WeekResponse(week.getWeekFrom(), week.getWeekTo(), topicResponses); + }) + .filter(week -> !week.topics().isEmpty()) // AC-2.7: 토픽 0개인 주차는 제외 + .toList(); + + return Optional.of(new CurriculumResponse(curriculum.getName(), weekResponses)); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java new file mode 100644 index 0000000..cbd24a2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java @@ -0,0 +1,31 @@ +package com.bcsdlab.bcsdinternalapiv2.global; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import lombok.Getter; + +/** + * 최상위 엔티티(트랙 페이지·커리큘럼·활동 카테고리·활동 등)에만 적용하는 soft delete 규약. + * 하위 트리(주차·토픽·세부항목·이미지)는 이 클래스를 쓰지 않고 {@code on delete cascade}로 + * 물리 삭제한다. + * + *

중요: {@code @SQLRestriction("deleted_at is null")}은 이 매핑된 슈퍼클래스가 아니라 + * 이 클래스를 상속하는 각 {@code @Entity} 클래스에 직접 붙여야 한다. Hibernate의 + * {@code @SQLRestriction}은 매핑된 슈퍼클래스로부터 상속되지 않는다. + */ +@Getter +@MappedSuperclass +public abstract class SoftDeletableEntity extends BaseTimeEntity { + + @Column(name = "deleted_at") + private Instant deletedAt; + + public boolean isDeleted() { + return deletedAt != null; + } + + public void delete(Instant now) { + this.deletedAt = now; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java new file mode 100644 index 0000000..5472386 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java @@ -0,0 +1,29 @@ +package com.bcsdlab.bcsdinternalapiv2.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@RequiredArgsConstructor +public class S3Config { + + private final S3Properties s3Properties; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(s3Properties.region())) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(s3Properties.region())) + .build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java new file mode 100644 index 0000000..da2cda1 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java @@ -0,0 +1,7 @@ +package com.bcsdlab.bcsdinternalapiv2.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.s3") +public record S3Properties(String region, String bucket, String publicBaseUrl) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java index c30f7c1..84465c6 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java @@ -40,6 +40,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/v1/auth/login", "/v1/auth/reissue", "/v1/auth/logout") .permitAll() .requestMatchers("/v1/auth/password/**").permitAll() + .requestMatchers("/v1/tracks/**").permitAll() + .requestMatchers("/v1/activity-categories/**").permitAll() + .requestMatchers("/v1/activities/**").permitAll() .requestMatchers("/v1/members/me/initial-setup") .hasAnyAuthority("SCOPE_PRE_ACTIVATION", "SCOPE_FULL") .requestMatchers("/v1/admin/**").access(AuthorizationManagers.allOf( diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/controller/dto/request/OrderRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/controller/dto/request/OrderRequest.java new file mode 100644 index 0000000..af33f33 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/controller/dto/request/OrderRequest.java @@ -0,0 +1,11 @@ +package com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * 순서 변경 전용 규약: {@code PATCH .../order}. 배열 인덱스가 새 display_order다. + * {@link com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders}와 짝을 이룬다. + */ +public record OrderRequest(@NotNull List ids) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/controller/dto/request/PublishRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/controller/dto/request/PublishRequest.java new file mode 100644 index 0000000..7d6c974 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/controller/dto/request/PublishRequest.java @@ -0,0 +1,8 @@ +package com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request; + +/** + * 공개/숨김 전용 규약: {@code PATCH .../{id}/publish}. 트랙 페이지·커리큘럼 세트· + * 활동 카테고리·활동이 전부 이 형태를 공유한다(05-api-spec.md 공통 규약). + */ +public record PublishRequest(boolean isPublished) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalException.java new file mode 100644 index 0000000..55786ed --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalException.java @@ -0,0 +1,15 @@ +package com.bcsdlab.bcsdinternalapiv2.global.exception; + +public class GlobalException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public GlobalException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionType.java new file mode 100644 index 0000000..1e901e3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionType.java @@ -0,0 +1,56 @@ +package com.bcsdlab.bcsdinternalapiv2.global.exception; + +import org.springframework.http.HttpStatus; + +/** + * 특정 도메인에 속하지 않고 여러 도메인이 공유하는 예외 타입. + * 예: 정렬 순서 변경 요청 검증({@link com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders})은 + * 트랙·커리큘럼·활동 등 모든 도메인에서 동일한 규칙과 동일한 HTTP 상태를 쓴다. + */ +public enum GlobalExceptionType implements BcsdExceptionType { + + ORDER_IDS_MISMATCH(HttpStatus.BAD_REQUEST, "순서 변경 대상이 기존 항목과 일치하지 않습니다."), + ; + + private final HttpStatus status; + private final String message; + + GlobalExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedGlobalExceptionType(this, detailMessage); + } + + private record DetailedGlobalExceptionType(GlobalExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedGlobalExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java new file mode 100644 index 0000000..9067176 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java @@ -0,0 +1,48 @@ +package com.bcsdlab.bcsdinternalapiv2.global.util; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.GlobalException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.GlobalExceptionType; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * {@code display_order} 재부여 순수 함수. 특정 엔티티 타입에 의존하지 않는다 — + * 트랙·커리큘럼 주차/토픽·활동 등 정렬 가능한 모든 도메인이 공유한다. + * + *

요청된 id 순서가 대상 id 집합과 정확히 일치하지 않으면(누락·추가·중복) + * {@link GlobalException}을 던지고 아무 값도 반환하지 않는다 — 호출자가 반환값을 받기 전에는 + * 어떤 엔티티도 변경하지 않으므로 "부분 적용" 상태가 존재하지 않는다. + */ +public final class DisplayOrders { + + private DisplayOrders() { + } + + /** + * @param requestedIds 새 순서대로 나열된 id 목록 (배열 인덱스가 곧 새 display_order) + * @param existingIds 현재 그 부모 아래 존재하는 전체 id 집합 + * @return id → 새 display_order(0-base) 매핑 + * @throws GlobalException requestedIds에 중복이 있거나, existingIds와 집합이 다를 때 + */ + public static Map reassign(List requestedIds, Collection existingIds) { + Set requestedSet = new HashSet<>(requestedIds); + if (requestedSet.size() != requestedIds.size()) { + throw new GlobalException(GlobalExceptionType.ORDER_IDS_MISMATCH); + } + + Set existingSet = new HashSet<>(existingIds); + if (!requestedSet.equals(existingSet)) { + throw new GlobalException(GlobalExceptionType.ORDER_IDS_MISMATCH); + } + + Map result = new LinkedHashMap<>(); + for (int i = 0; i < requestedIds.size(); i++) { + result.put(requestedIds.get(i), i); + } + return result; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageApi.java new file mode 100644 index 0000000..f9b63d4 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageApi.java @@ -0,0 +1,38 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller; + +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.PresignedUrlResponse; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Tag(name = "관리자 미디어 API") +public interface AdminImageApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "400", description = "5MB 초과(AC-4.1) 또는 허용 외 확장자(AC-4.2)"), + }) + @Operation(summary = "presigned URL 발급", description = "브라우저가 반환된 uploadUrl로 S3에 직접 PUT한다.") + PresignedUrlResponse issuePresignedUrl(@Valid PresignedUrlRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 이미지"), + }) + @Operation(summary = "업로드 완료 등록", description = "S3 PUT이 끝난 뒤 호출해야 라이브러리 목록에 노출된다(AC-4.3, AC-4.4).") + ImageCompleteResponse complete(Long id); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + }) + @Operation(summary = "미디어 라이브러리 목록", description = "complete된 이미지만 반환한다(AC-4.4).") + Page getImages(ImagePurpose purpose, Pageable pageable); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageController.java new file mode 100644 index 0000000..d1ccc55 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/AdminImageController.java @@ -0,0 +1,45 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller; + +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.PresignedUrlResponse; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import com.bcsdlab.bcsdinternalapiv2.media.service.AdminImageService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/images") +@RequiredArgsConstructor +public class AdminImageController implements AdminImageApi { + + private final AdminImageService adminImageService; + + @Override + @PostMapping("/presigned-url") + public PresignedUrlResponse issuePresignedUrl(@Valid @RequestBody PresignedUrlRequest request) { + return adminImageService.issuePresignedUrl(request); + } + + @Override + @PostMapping("/{id}/complete") + public ImageCompleteResponse complete(@PathVariable Long id) { + return adminImageService.complete(id); + } + + @Override + @GetMapping + public Page getImages(@RequestParam ImagePurpose purpose, Pageable pageable) { + return adminImageService.getImages(purpose, pageable); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/request/PresignedUrlRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/request/PresignedUrlRequest.java new file mode 100644 index 0000000..eb587a5 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/request/PresignedUrlRequest.java @@ -0,0 +1,27 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request; + +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +/** + * 확장자·용량 검증은 여기서 끝낸다(AC-4.1, AC-4.2). 서비스는 값을 그대로 신뢰한다. + */ +public record PresignedUrlRequest( + @NotBlank + @Pattern(regexp = "(?i).+\\.(png|jpe?g|webp|svg)$", message = "허용되지 않는 확장자입니다.") + String fileName, + + @NotBlank + String contentType, + + @NotNull + @Max(5L * 1024 * 1024) + Long byteSize, + + @NotNull + ImagePurpose purpose +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageAssetResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageAssetResponse.java new file mode 100644 index 0000000..626bfed --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageAssetResponse.java @@ -0,0 +1,16 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset; +import java.time.Instant; + +public record ImageAssetResponse( + Long id, + String url, + String originalName, + Instant createdAt +) { + public static ImageAssetResponse from(ImageAsset imageAsset) { + return new ImageAssetResponse( + imageAsset.getId(), imageAsset.getUrl(), imageAsset.getOriginalName(), imageAsset.getCreatedAt()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageCompleteResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageCompleteResponse.java new file mode 100644 index 0000000..462facc --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/ImageCompleteResponse.java @@ -0,0 +1,6 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response; + +public record ImageCompleteResponse( + String url +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/PresignedUrlResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/PresignedUrlResponse.java new file mode 100644 index 0000000..bfff5ce --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/controller/dto/response/PresignedUrlResponse.java @@ -0,0 +1,8 @@ +package com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response; + +public record PresignedUrlResponse( + Long imageId, + String uploadUrl, + String publicUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaException.java new file mode 100644 index 0000000..a690c61 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaException.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.media.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; + +public class MediaException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public MediaException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaExceptionType.java new file mode 100644 index 0000000..39ba6a3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaExceptionType.java @@ -0,0 +1,52 @@ +package com.bcsdlab.bcsdinternalapiv2.media.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; +import org.springframework.http.HttpStatus; + +public enum MediaExceptionType implements BcsdExceptionType { + + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 이미지입니다."), + ; + + private final HttpStatus status; + private final String message; + + MediaExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedMediaExceptionType(this, detailMessage); + } + + private record DetailedMediaExceptionType(MediaExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedMediaExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImageAsset.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImageAsset.java new file mode 100644 index 0000000..366a919 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImageAsset.java @@ -0,0 +1,85 @@ +package com.bcsdlab.bcsdinternalapiv2.media.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * 미디어 라이브러리 한 항목(ADR-009). 콘텐츠 테이블과 FK로 묶지 않는다 — 이미지 행 삭제가 + * 콘텐츠를 깨뜨리지 않게 하기 위해서다. {@code is_confirmed}가 true인(=업로드가 + * {@code complete}로 끝난) 행만 라이브러리 목록에 보인다(AC-4.3, AC-4.4). + * + *

{@code image_asset} 테이블에는 {@code updated_at}이 없다 — 업로드 후 수정 없이 + * 생성/확정만 하므로 {@link com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity}를 쓰지 + * 않고 직접 매핑한다. soft delete도 쓰지 않는다(라이브러리 항목은 물리 삭제 대상이 아니다). + */ +@Getter +@Entity +@Table(name = "image_asset") +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ImageAsset { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "s3_key", nullable = false, updatable = false, unique = true) + private String s3Key; + + @Column(name = "url", nullable = false, updatable = false) + private String url; + + @Column(name = "original_name", nullable = false, updatable = false) + private String originalName; + + @Column(name = "content_type", nullable = false, updatable = false) + private String contentType; + + @Column(name = "byte_size", nullable = false, updatable = false) + private long byteSize; + + @Enumerated(EnumType.STRING) + @Column(name = "purpose", nullable = false, updatable = false) + private ImagePurpose purpose; + + @Column(name = "is_confirmed", nullable = false) + private boolean confirmed; + + @Column(name = "uploaded_by", updatable = false) + private Long uploadedBy; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Builder + private ImageAsset(String s3Key, String url, String originalName, String contentType, long byteSize, + ImagePurpose purpose, Long uploadedBy) { + this.s3Key = s3Key; + this.url = url; + this.originalName = originalName; + this.contentType = contentType; + this.byteSize = byteSize; + this.purpose = purpose; + this.confirmed = false; + this.uploadedBy = uploadedBy; + } + + public void confirm() { + this.confirmed = true; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImagePurpose.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImagePurpose.java new file mode 100644 index 0000000..0db7dd2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/model/ImagePurpose.java @@ -0,0 +1,10 @@ +package com.bcsdlab.bcsdinternalapiv2.media.model; + +public enum ImagePurpose { + TRACK_HERO, + STUDY_ICON, + TECH_ICON, + ACTIVITY, + ACTIVITY_CONTENT, + ETC +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/repository/ImageAssetRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/repository/ImageAssetRepository.java new file mode 100644 index 0000000..ffc9889 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/repository/ImageAssetRepository.java @@ -0,0 +1,12 @@ +package com.bcsdlab.bcsdinternalapiv2.media.repository; + +import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageAssetRepository extends JpaRepository { + + Page findAllByPurposeAndConfirmedTrueOrderByCreatedAtDesc(ImagePurpose purpose, Pageable pageable); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java new file mode 100644 index 0000000..b6ebe06 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/service/AdminImageService.java @@ -0,0 +1,93 @@ +package com.bcsdlab.bcsdinternalapiv2.media.service; + +import com.bcsdlab.bcsdinternalapiv2.global.config.S3Properties; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.request.PresignedUrlRequest; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageAssetResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.ImageCompleteResponse; +import com.bcsdlab.bcsdinternalapiv2.media.controller.dto.response.PresignedUrlResponse; +import com.bcsdlab.bcsdinternalapiv2.media.exception.MediaException; +import com.bcsdlab.bcsdinternalapiv2.media.exception.MediaExceptionType; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImageAsset; +import com.bcsdlab.bcsdinternalapiv2.media.model.ImagePurpose; +import com.bcsdlab.bcsdinternalapiv2.media.repository.ImageAssetRepository; +import java.time.Duration; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +/** + * presigned URL 발급 → 브라우저가 S3에 직접 PUT → complete 등록(ADR-009). 서버는 이미지 + * 바이트를 경유하지 않는다 — API 컨테이너 메모리 한도(256MiB) 때문에 서버 측 이미지 + * 변환·버퍼링을 도입하지 않는다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminImageService { + + private static final Duration UPLOAD_URL_VALIDITY = Duration.ofMinutes(10); + + private final ImageAssetRepository imageAssetRepository; + private final S3Presigner s3Presigner; + private final S3Properties s3Properties; + + @Transactional + public PresignedUrlResponse issuePresignedUrl(PresignedUrlRequest request) { + String s3Key = "%s/%s.%s".formatted( + request.purpose().name().toLowerCase(), UUID.randomUUID(), extensionOf(request.fileName())); + String publicUrl = s3Properties.publicBaseUrl() + "/" + s3Key; + + ImageAsset image = imageAssetRepository.save(ImageAsset.builder() + .s3Key(s3Key) + .url(publicUrl) + .originalName(request.fileName()) + .contentType(request.contentType()) + .byteSize(request.byteSize()) + .purpose(request.purpose()) + .build()); + + String uploadUrl = presignPutUrl(s3Key, request.contentType()); + return new PresignedUrlResponse(image.getId(), uploadUrl, publicUrl); + } + + @Transactional + public ImageCompleteResponse complete(Long id) { + ImageAsset image = findOrThrow(id); + image.confirm(); + return new ImageCompleteResponse(image.getUrl()); + } + + public Page getImages(ImagePurpose purpose, Pageable pageable) { + return imageAssetRepository.findAllByPurposeAndConfirmedTrueOrderByCreatedAtDesc(purpose, pageable) + .map(ImageAssetResponse::from); + } + + private String presignPutUrl(String s3Key, String contentType) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Properties.bucket()) + .key(s3Key) + .contentType(contentType) + .build(); + PresignedPutObjectRequest presigned = s3Presigner.presignPutObject(PutObjectPresignRequest.builder() + .signatureDuration(UPLOAD_URL_VALIDITY) + .putObjectRequest(putObjectRequest) + .build()); + return presigned.url().toString(); + } + + private String extensionOf(String fileName) { + return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + } + + private ImageAsset findOrThrow(Long id) { + return imageAssetRepository.findById(id) + .orElseThrow(() -> new MediaException(MediaExceptionType.IMAGE_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/dto/response/MemberResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/dto/response/MemberResponse.java index 62c060c..b536ec9 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/dto/response/MemberResponse.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/dto/response/MemberResponse.java @@ -20,7 +20,7 @@ public static MemberResponse from(Member member) { member.getId(), member.getName(), member.getStudentNumber(), - member.getTrack().name(), + member.getTrack().getCode(), member.getGeneration(), member.getMemberType().name(), member.getUniversity(), diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/model/Member.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/model/Member.java index 9b36c3a..fec817a 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/model/Member.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/model/Member.java @@ -1,13 +1,17 @@ package com.bcsdlab.bcsdinternalapiv2.member.model; import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +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 java.time.Instant; import java.time.temporal.ChronoUnit; @@ -38,9 +42,9 @@ public class Member extends BaseTimeEntity { @Column(name = "name", nullable = false, updatable = false) private String name; - @Enumerated(EnumType.STRING) - @Column(name = "track", nullable = false, updatable = false) - private Track track; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "track_id", nullable = false, updatable = false) + private TrackMaster track; @Column(name = "generation", nullable = false, updatable = false) private String generation; @@ -84,8 +88,13 @@ public class Member extends BaseTimeEntity { @Column(name = "welcome_mail_sent_at") private Instant welcomeMailSentAt; + // 홈페이지 "함께 할 멤버들"에만 쓴다(T-18). 쓰기 경로를 만들지 않는다(INV-13) — + // 명부 관리 책임은 auth/member 담당자에게 그대로 있다. + @Column(name = "profile_image_url") + private String profileImageUrl; + @Builder - private Member(String studentNumber, String password, String name, Track track, String generation, + private Member(String studentNumber, String password, String name, TrackMaster track, String generation, MemberType memberType, String university, String email, String phoneNumber, String githubId, MemberStatus status, MemberRole role, Instant passwordChangedAt) { this.studentNumber = studentNumber; diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/AdminMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/AdminMemberService.java index 588bff4..c692a23 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/AdminMemberService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/AdminMemberService.java @@ -10,6 +10,10 @@ import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.response.AdminMemberCreateResponse; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; import com.bcsdlab.bcsdinternalapiv2.global.mail.MailSender; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import java.security.SecureRandom; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -34,6 +38,7 @@ public class AdminMemberService { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private final MemberRepository memberRepository; + private final TrackMasterRepository trackMasterRepository; private final PasswordEncoder passwordEncoder; private final LoginPageProperties loginPageProperties; private final ApplicationEventPublisher eventPublisher; @@ -53,6 +58,8 @@ public AdminMemberCreateResponse createMember(AdminMemberCreateRequest request) String normalizedPhone = isBlank(request.phoneNumber()) ? null : PhoneNumberNormalizer.normalize(request.phoneNumber()); String normalizedGithubId = GithubIdNormalizer.normalize(request.githubId()); + TrackMaster track = trackMasterRepository.findByCode(request.track().name()) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_NOT_FOUND)); String temporaryPassword = generateTemporaryPassword(); Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); @@ -61,7 +68,7 @@ public AdminMemberCreateResponse createMember(AdminMemberCreateRequest request) .studentNumber(request.studentNumber()) .password(passwordEncoder.encode(temporaryPassword)) .name(request.name()) - .track(request.track()) + .track(track) .generation(request.generation()) .memberType(request.memberType()) .university(request.university()) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTechStackApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTechStackApi.java new file mode 100644 index 0000000..def0412 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTechStackApi.java @@ -0,0 +1,37 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TechStackCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TechStackResponse; +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.RequestBody; + +@Tag(name = "관리자 - 기술스택 마스터 API") +@SecurityRequirement(name = "JWT") +public interface AdminTechStackApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "기술스택 마스터 목록") + List getTechStacks(); + + @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 = "409", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "기술스택 마스터 생성") + ResponseEntity createTechStack(@RequestBody @Valid TechStackCreateRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTechStackController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTechStackController.java new file mode 100644 index 0000000..d8b56f3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTechStackController.java @@ -0,0 +1,35 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TechStackCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TechStackResponse; +import com.bcsdlab.bcsdinternalapiv2.track.service.AdminTechStackService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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/tech-stacks") +@RequiredArgsConstructor +public class AdminTechStackController implements AdminTechStackApi { + + private final AdminTechStackService adminTechStackService; + + @Override + @GetMapping + public List getTechStacks() { + return adminTechStackService.getTechStacks(); + } + + @Override + @PostMapping + public ResponseEntity createTechStack(@Valid @RequestBody TechStackCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminTechStackService.createTechStack(request)); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackApi.java new file mode 100644 index 0000000..ab539a9 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackApi.java @@ -0,0 +1,48 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackMasterCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackMasterUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackMasterResponse; +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 AdminTrackApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "트랙 마스터 목록", description = "부원 소속용 트랙 마스터. 홈페이지 프로필 유무를 함께 반환합니다.") + List getTracks(); + + @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 = "409", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "트랙 마스터 생성") + ResponseEntity createTrack(@RequestBody @Valid TrackMasterCreateRequest request); + + @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 = "트랙 마스터 수정", description = "이름과 활성 여부만 수정할 수 있습니다. code는 불변입니다.") + TrackMasterResponse updateTrack(@PathVariable Long id, @RequestBody @Valid TrackMasterUpdateRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackController.java new file mode 100644 index 0000000..42b5557 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackController.java @@ -0,0 +1,44 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackMasterCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackMasterUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackMasterResponse; +import com.bcsdlab.bcsdinternalapiv2.track.service.AdminTrackMasterService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.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/tracks") +@RequiredArgsConstructor +public class AdminTrackController implements AdminTrackApi { + + private final AdminTrackMasterService adminTrackMasterService; + + @Override + @GetMapping + public List getTracks() { + return adminTrackMasterService.getTracks(); + } + + @Override + @PostMapping + public ResponseEntity createTrack(@Valid @RequestBody TrackMasterCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminTrackMasterService.createTrack(request)); + } + + @Override + @PutMapping("/{id}") + public TrackMasterResponse updateTrack(@PathVariable Long id, @Valid @RequestBody TrackMasterUpdateRequest request) { + return adminTrackMasterService.updateTrack(id, request); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageApi.java new file mode 100644 index 0000000..18c2b20 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageApi.java @@ -0,0 +1,127 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.SlugChangeRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackPageCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackPageUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.StudyPointsReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TechStacksReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.AdminTrackPageDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.AdminTrackPageSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.StudyPointResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TechStackResponse; +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 AdminTrackPageApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "트랙 페이지 목록", description = "숨김 포함 전체, display_order 순.") + List getTrackPages(); + + @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 = "트랙 페이지 편집 화면 조회") + AdminTrackPageDetailResponse getTrackPage(@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))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "트랙 페이지 생성", description = "slug는 displayName에서 자동 생성됩니다(AC-1.1).") + ResponseEntity createTrackPage(@RequestBody @Valid TrackPageCreateRequest request); + + @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 = "트랙 페이지 헤더 수정") + AdminTrackPageDetailResponse updateTrackPage(@PathVariable Long id, + @RequestBody @Valid TrackPageUpdateRequest request); + + @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))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "slug 수동 변경", description = "리다이렉트를 지원하지 않습니다 — 변경 전 URL은 더 이상 유효하지 않습니다.") + AdminTrackPageDetailResponse changeSlug(@PathVariable Long id, @RequestBody @Valid SlugChangeRequest 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 = "공개/숨김") + ResponseEntity publish(@PathVariable Long id, @RequestBody @Valid PublishRequest 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 = "ids는 전체 트랙 페이지 id 집합과 정확히 일치해야 합니다(AC-1.5).") + ResponseEntity reorder(@RequestBody @Valid OrderRequest 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 = "soft delete — 데이터는 보존됩니다.") + ResponseEntity deleteTrackPage(@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 = "WHAT WE STUDY 카드 전체 교체", description = "최대 4개(INV-14). 배열 순서가 display_order다.") + List replaceStudyPoints(@PathVariable Long id, + @RequestBody @Valid StudyPointsReplaceRequest request); + + @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 = "배열 순서가 display_order다.") + List replaceTechStacks(@PathVariable Long id, + @RequestBody @Valid TechStacksReplaceRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageController.java new file mode 100644 index 0000000..cc45708 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageController.java @@ -0,0 +1,104 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.SlugChangeRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.StudyPointsReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TechStacksReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackPageCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackPageUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.AdminTrackPageDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.AdminTrackPageSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.StudyPointResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TechStackResponse; +import com.bcsdlab.bcsdinternalapiv2.track.service.AdminTrackPageService; +import jakarta.validation.Valid; +import java.util.List; +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.GetMapping; +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/track-pages") +@RequiredArgsConstructor +public class AdminTrackPageController implements AdminTrackPageApi { + + private final AdminTrackPageService adminTrackPageService; + + @Override + @GetMapping + public List getTrackPages() { + return adminTrackPageService.getTrackPages(); + } + + @Override + @GetMapping("/{id}") + public AdminTrackPageDetailResponse getTrackPage(@PathVariable Long id) { + return adminTrackPageService.getTrackPage(id); + } + + @Override + @PostMapping + public ResponseEntity createTrackPage( + @Valid @RequestBody TrackPageCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminTrackPageService.createTrackPage(request)); + } + + @Override + @PutMapping("/{id}") + public AdminTrackPageDetailResponse updateTrackPage(@PathVariable Long id, + @Valid @RequestBody TrackPageUpdateRequest request) { + return adminTrackPageService.updateTrackPage(id, request); + } + + @Override + @PatchMapping("/{id}/slug") + public AdminTrackPageDetailResponse changeSlug(@PathVariable Long id, + @Valid @RequestBody SlugChangeRequest request) { + return adminTrackPageService.changeSlug(id, request); + } + + @Override + @PatchMapping("/{id}/publish") + public ResponseEntity publish(@PathVariable Long id, @Valid @RequestBody PublishRequest request) { + adminTrackPageService.publish(id, request); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/order") + public ResponseEntity reorder(@Valid @RequestBody OrderRequest request) { + adminTrackPageService.reorder(request); + return ResponseEntity.noContent().build(); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity deleteTrackPage(@PathVariable Long id) { + adminTrackPageService.deleteTrackPage(id); + return ResponseEntity.noContent().build(); + } + + @Override + @PutMapping("/{id}/study-points") + public List replaceStudyPoints(@PathVariable Long id, + @Valid @RequestBody StudyPointsReplaceRequest request) { + return adminTrackPageService.replaceStudyPoints(id, request); + } + + @Override + @PutMapping("/{id}/tech-stacks") + public List replaceTechStacks(@PathVariable Long id, + @Valid @RequestBody TechStacksReplaceRequest request) { + return adminTrackPageService.replaceTechStacks(id, request); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageMemberApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageMemberApi.java new file mode 100644 index 0000000..6768c89 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageMemberApi.java @@ -0,0 +1,55 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.MemberVisibilityRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackPageMembersAttachRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.AdminTrackPageMemberResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.ResponseEntity; + +@Tag(name = "관리자 트랙 멤버 API") +public interface AdminTrackPageMemberApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 트랙 페이지"), + }) + @Operation(summary = "트랙에 배정된 부원 목록", description = "명부 join(이름·등급·프로필 사진). 부원 검색은 GET /members?name=를 그대로 쓴다.") + List getMembers(Long trackPageId); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 트랙 페이지 또는 부원"), + @ApiResponse(responseCode = "409", description = "이미 배정된 부원(INV-8)"), + }) + @Operation(summary = "부원 배정") + List attachMembers(Long trackPageId, + @Valid TrackPageMembersAttachRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "404", description = "배정되지 않은 부원"), + }) + @Operation(summary = "배정 해제") + ResponseEntity detachMember(Long trackPageId, Long memberId); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "404", description = "배정되지 않은 부원"), + }) + @Operation(summary = "숨김/공개 전환") + ResponseEntity updateVisibility(Long trackPageId, Long memberId, + @Valid MemberVisibilityRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", description = "id 집합 불일치(INV-4)"), + }) + @Operation(summary = "순서 변경") + ResponseEntity reorder(Long trackPageId, @Valid OrderRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageMemberController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageMemberController.java new file mode 100644 index 0000000..2f31a7a --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/AdminTrackPageMemberController.java @@ -0,0 +1,62 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.MemberVisibilityRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackPageMembersAttachRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.AdminTrackPageMemberResponse; +import com.bcsdlab.bcsdinternalapiv2.track.service.AdminTrackPageMemberService; +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.GetMapping; +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/track-pages/{trackPageId}/members") +@RequiredArgsConstructor +public class AdminTrackPageMemberController implements AdminTrackPageMemberApi { + + private final AdminTrackPageMemberService adminTrackPageMemberService; + + @Override + @GetMapping + public List getMembers(@PathVariable Long trackPageId) { + return adminTrackPageMemberService.getMembers(trackPageId); + } + + @Override + @PostMapping + public List attachMembers(@PathVariable Long trackPageId, + @Valid @RequestBody TrackPageMembersAttachRequest request) { + return adminTrackPageMemberService.attachMembers(trackPageId, request); + } + + @Override + @DeleteMapping("/{memberId}") + public ResponseEntity detachMember(@PathVariable Long trackPageId, @PathVariable Long memberId) { + adminTrackPageMemberService.detachMember(trackPageId, memberId); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/{memberId}/visibility") + public ResponseEntity updateVisibility(@PathVariable Long trackPageId, @PathVariable Long memberId, + @Valid @RequestBody MemberVisibilityRequest request) { + adminTrackPageMemberService.updateVisibility(trackPageId, memberId, request); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/order") + public ResponseEntity reorder(@PathVariable Long trackPageId, @Valid @RequestBody OrderRequest request) { + adminTrackPageMemberService.reorder(trackPageId, request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackApi.java new file mode 100644 index 0000000..437cc6c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackApi.java @@ -0,0 +1,32 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackSummaryResponse; +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.tags.Tag; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "트랙 API (홈페이지 공개)") +public interface TrackApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + }) + @Operation(summary = "공개 트랙 목록", description = "홈페이지 노출 순서(display_order)대로, 공개된 트랙만 반환합니다.") + @GetMapping + List getTracks(); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "트랙 페이지 상세", description = "숨김 처리되었거나 존재하지 않는 트랙은 구분 없이 404를 반환합니다.") + @GetMapping("/{slug}") + TrackDetailResponse getTrack(@PathVariable String slug); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackController.java new file mode 100644 index 0000000..6f4b05e --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackController.java @@ -0,0 +1,31 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackService; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.RestController; + +@RestController +@RequestMapping("/v1/tracks") +@RequiredArgsConstructor +public class TrackController implements TrackApi { + + private final TrackService trackService; + + @Override + @GetMapping + public List getTracks() { + return trackService.getTracks(); + } + + @Override + @GetMapping("/{slug}") + public TrackDetailResponse getTrack(@PathVariable String slug) { + return trackService.getTrack(slug); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/MemberVisibilityRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/MemberVisibilityRequest.java new file mode 100644 index 0000000..861aaff --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/MemberVisibilityRequest.java @@ -0,0 +1,4 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +public record MemberVisibilityRequest(boolean isVisible) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/SlugChangeRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/SlugChangeRequest.java new file mode 100644 index 0000000..abc4aba --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/SlugChangeRequest.java @@ -0,0 +1,11 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record SlugChangeRequest( + @NotBlank + @Pattern(regexp = "^[a-z0-9]+(-[a-z0-9]+)*$", message = "slug는 소문자·숫자·하이픈만 사용할 수 있습니다.") + String slug +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/StudyPointRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/StudyPointRequest.java new file mode 100644 index 0000000..68b0328 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/StudyPointRequest.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record StudyPointRequest( + @NotBlank + @Size(max = 60) + String title, + + @NotBlank + @Size(max = 200) + String description, + + String iconImageUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/StudyPointsReplaceRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/StudyPointsReplaceRequest.java new file mode 100644 index 0000000..1fef6b9 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/StudyPointsReplaceRequest.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +/** + * WHAT WE STUDY 카드 전체 교체. 배열 순서가 곧 display_order다(INV-14: 최대 4개). + */ +public record StudyPointsReplaceRequest( + @NotNull + @Size(max = 4, message = "WHAT WE STUDY 카드는 최대 4개까지 등록할 수 있습니다.") + @Valid + List studyPoints +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TechStackCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TechStackCreateRequest.java new file mode 100644 index 0000000..4d3a7e0 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TechStackCreateRequest.java @@ -0,0 +1,9 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record TechStackCreateRequest( + @NotBlank String name, + @NotBlank String iconUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TechStacksReplaceRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TechStacksReplaceRequest.java new file mode 100644 index 0000000..33598e2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TechStacksReplaceRequest.java @@ -0,0 +1,12 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * 트랙 페이지에 부착할 기술스택 전체 교체. 배열 순서가 곧 display_order다. + */ +public record TechStacksReplaceRequest( + @NotNull List techStackIds +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackMasterCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackMasterCreateRequest.java new file mode 100644 index 0000000..442ad5c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackMasterCreateRequest.java @@ -0,0 +1,9 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record TrackMasterCreateRequest( + @NotBlank String code, + @NotBlank String name +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackMasterUpdateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackMasterUpdateRequest.java new file mode 100644 index 0000000..d623279 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackMasterUpdateRequest.java @@ -0,0 +1,9 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record TrackMasterUpdateRequest( + @NotBlank String name, + boolean isActive +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackPageCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackPageCreateRequest.java new file mode 100644 index 0000000..3b82939 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackPageCreateRequest.java @@ -0,0 +1,22 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record TrackPageCreateRequest( + @NotNull Long trackId, + + @NotBlank + @Size(max = 50) + String displayName, + + @NotBlank + @Size(max = 120) + String tagline, + + String heroImageUrl, + String ogImageUrl, + String seoDescription +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackPageMembersAttachRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackPageMembersAttachRequest.java new file mode 100644 index 0000000..96549e3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackPageMembersAttachRequest.java @@ -0,0 +1,9 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record TrackPageMembersAttachRequest( + @NotEmpty List memberIds +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackPageUpdateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackPageUpdateRequest.java new file mode 100644 index 0000000..9cb27a0 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/request/TrackPageUpdateRequest.java @@ -0,0 +1,19 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record TrackPageUpdateRequest( + @NotBlank + @Size(max = 50) + String displayName, + + @NotBlank + @Size(max = 120) + String tagline, + + String heroImageUrl, + String ogImageUrl, + String seoDescription +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/AdminTrackPageDetailResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/AdminTrackPageDetailResponse.java new file mode 100644 index 0000000..7b8f20e --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/AdminTrackPageDetailResponse.java @@ -0,0 +1,36 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; + +/** + * 편집 화면 헤더 탭. studyPoints(T-08)·techStacks(T-09)·members(T-18)는 이 레코드에 + * 아직 없다 — 해당 도메인 티켓이 필드를 추가한다. + */ +public record AdminTrackPageDetailResponse( + Long id, + Long trackId, + String trackCode, + String slug, + String displayName, + String tagline, + String heroImageUrl, + String ogImageUrl, + String seoDescription, + boolean isPublished, + int displayOrder +) { + public static AdminTrackPageDetailResponse from(TrackPage trackPage) { + return new AdminTrackPageDetailResponse( + trackPage.getId(), + trackPage.getTrack().getId(), + trackPage.getTrack().getCode(), + trackPage.getSlug(), + trackPage.getDisplayName(), + trackPage.getTagline(), + trackPage.getHeroImageUrl(), + trackPage.getOgImageUrl(), + trackPage.getSeoDescription(), + trackPage.isPublished(), + trackPage.getDisplayOrder()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/AdminTrackPageMemberResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/AdminTrackPageMemberResponse.java new file mode 100644 index 0000000..ed35f15 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/AdminTrackPageMemberResponse.java @@ -0,0 +1,31 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPageMember; + +/** + * 편집 화면(관리자)이 쓰는 배정 목록. 저트래픽 편집 화면이라 부원 조회의 N+1을 + * 감수한다(T-11 관리자 커리큘럼 트리와 같은 이유). + */ +public record AdminTrackPageMemberResponse( + Long id, + Long memberId, + String name, + String memberType, + String profileImageUrl, + boolean isVisible, + int displayOrder +) { + public static AdminTrackPageMemberResponse from(TrackPageMember trackPageMember) { + Member member = trackPageMember.getMember(); + return new AdminTrackPageMemberResponse( + trackPageMember.getId(), + member.getId(), + member.getName(), + member.getMemberType().name(), + member.getProfileImageUrl(), + trackPageMember.isVisible(), + trackPageMember.getDisplayOrder() + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/AdminTrackPageSummaryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/AdminTrackPageSummaryResponse.java new file mode 100644 index 0000000..3631690 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/AdminTrackPageSummaryResponse.java @@ -0,0 +1,21 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; + +/** + * 트랙 목록 화면용 요약. 멤버 수는 아직 없다 — track_page_member가 구현되는 T-18에서 + * 이 레코드에 필드를 추가한다. + */ +public record AdminTrackPageSummaryResponse( + Long id, + String slug, + String displayName, + boolean isPublished, + int displayOrder +) { + public static AdminTrackPageSummaryResponse from(TrackPage trackPage) { + return new AdminTrackPageSummaryResponse( + trackPage.getId(), trackPage.getSlug(), trackPage.getDisplayName(), + trackPage.isPublished(), trackPage.getDisplayOrder()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/StudyPointResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/StudyPointResponse.java new file mode 100644 index 0000000..c251ca2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/StudyPointResponse.java @@ -0,0 +1,14 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackStudyPoint; + +public record StudyPointResponse( + String title, + String description, + String iconImageUrl +) { + public static StudyPointResponse from(TrackStudyPoint studyPoint) { + return new StudyPointResponse(studyPoint.getTitle(), studyPoint.getDescription(), + studyPoint.getIconImageUrl()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TechStackResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TechStackResponse.java new file mode 100644 index 0000000..81c7123 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TechStackResponse.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TechStack; + +/** + * 관리자용 — id를 포함해 선택·해제 UI가 특정 항목을 가리킬 수 있게 한다. + * 공개 응답에는 {@link TechStackSummaryResponse}(id 없음)를 쓴다. + */ +public record TechStackResponse( + Long id, + String name, + String iconUrl +) { + public static TechStackResponse from(TechStack techStack) { + return new TechStackResponse(techStack.getId(), techStack.getName(), techStack.getIconUrl()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TechStackSummaryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TechStackSummaryResponse.java new file mode 100644 index 0000000..fb7b299 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TechStackSummaryResponse.java @@ -0,0 +1,12 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TechStack; + +public record TechStackSummaryResponse( + String name, + String iconUrl +) { + public static TechStackSummaryResponse from(TechStack techStack) { + return new TechStackSummaryResponse(techStack.getName(), techStack.getIconUrl()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackDetailResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackDetailResponse.java new file mode 100644 index 0000000..a706394 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackDetailResponse.java @@ -0,0 +1,35 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumResponse; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import java.util.List; + +public record TrackDetailResponse( + String slug, + String name, + String tagline, + String heroImageUrl, + String ogImageUrl, + String seoDescription, + List studyPoints, + List techStacks, + CurriculumResponse curriculum, + List members +) { + public static TrackDetailResponse of(TrackPage trackPage, List studyPoints, + List techStacks, CurriculumResponse curriculum, + List members) { + return new TrackDetailResponse( + trackPage.getSlug(), + trackPage.getDisplayName(), + trackPage.getTagline(), + trackPage.getHeroImageUrl(), + trackPage.getOgImageUrl(), + trackPage.getSeoDescription(), + studyPoints, + techStacks, + curriculum, + members + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackMasterResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackMasterResponse.java new file mode 100644 index 0000000..2e0e34a --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackMasterResponse.java @@ -0,0 +1,16 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; + +public record TrackMasterResponse( + Long id, + String code, + String name, + boolean isActive, + boolean hasTrackPage +) { + public static TrackMasterResponse of(TrackMaster track, boolean hasTrackPage) { + return new TrackMasterResponse(track.getId(), track.getCode(), track.getName(), track.isActive(), + hasTrackPage); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackMemberResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackMemberResponse.java new file mode 100644 index 0000000..b3011a3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackMemberResponse.java @@ -0,0 +1,13 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; + +public record TrackMemberResponse( + String name, + String memberType, + String profileImageUrl +) { + public static TrackMemberResponse from(Member member) { + return new TrackMemberResponse(member.getName(), member.getMemberType().name(), member.getProfileImageUrl()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackSummaryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackSummaryResponse.java new file mode 100644 index 0000000..4907fd0 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackSummaryResponse.java @@ -0,0 +1,12 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; + +public record TrackSummaryResponse( + String slug, + String name +) { + public static TrackSummaryResponse from(TrackPage trackPage) { + return new TrackSummaryResponse(trackPage.getSlug(), trackPage.getDisplayName()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackException.java new file mode 100644 index 0000000..8edc484 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackException.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.track.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; + +public class TrackException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public TrackException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackExceptionType.java new file mode 100644 index 0000000..d4b931f --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackExceptionType.java @@ -0,0 +1,61 @@ +package com.bcsdlab.bcsdinternalapiv2.track.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; +import org.springframework.http.HttpStatus; + +public enum TrackExceptionType implements BcsdExceptionType { + + TRACK_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 트랙입니다."), + TRACK_CODE_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 트랙 코드입니다."), + TRACK_PAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 트랙 페이지입니다."), + TRACK_PAGE_SLUG_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 주소입니다."), + TRACK_PAGE_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 홈페이지 프로필이 있는 트랙입니다."), + TECH_STACK_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 기술스택입니다."), + TECH_STACK_NAME_DUPLICATED(HttpStatus.CONFLICT, "이미 등록된 기술스택입니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 부원입니다."), + MEMBER_ALREADY_ASSIGNED(HttpStatus.CONFLICT, "이미 이 트랙에 배정된 부원입니다."), + TRACK_PAGE_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "이 트랙에 배정되지 않은 부원입니다."), + ; + + private final HttpStatus status; + private final String message; + + TrackExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedTrackExceptionType(this, detailMessage); + } + + private record DetailedTrackExceptionType(TrackExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedTrackExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TechStack.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TechStack.java new file mode 100644 index 0000000..270ed53 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TechStack.java @@ -0,0 +1,39 @@ +package com.bcsdlab.bcsdinternalapiv2.track.model; + +import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 기술스택 마스터. 여러 트랙 페이지가 공유한다(React, Java 등 재사용). + */ +@Getter +@Entity +@Table(name = "tech_stack") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TechStack extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "icon_url", nullable = false) + private String iconUrl; + + @Builder + private TechStack(String name, String iconUrl) { + this.name = name; + this.iconUrl = iconUrl; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackMaster.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackMaster.java new file mode 100644 index 0000000..75b8314 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackMaster.java @@ -0,0 +1,54 @@ +package com.bcsdlab.bcsdinternalapiv2.track.model; + +import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 인터널 공용 트랙 마스터. 부원의 소속(member.track_id)이 참조한다. + * + *

홈페이지 노출 프로필({@code track_page}, T-05/T-06)과 의도적으로 분리되어 있다 — 이 클래스는 + * "부원이 어디에 속하는가"만 다룬다. 클래스명을 {@code Track}이 아니라 {@code TrackMaster}로 둔 이유는 + * {@link com.bcsdlab.bcsdinternalapiv2.member.model.Track}(API 요청/응답에 쓰는 코드 enum)과 + * 이름이 겹치면 같은 파일에서 두 타입을 함께 참조해야 하는 곳(예: 요청 DTO → 엔티티 변환)에서 + * import 충돌이 나기 때문이다. + */ +@Getter +@Entity +@Table(name = "track") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackMaster extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "code", nullable = false, updatable = false) + private String code; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "is_active", nullable = false) + private boolean active; + + @Builder + private TrackMaster(String code, String name) { + this.code = code; + this.name = name; + this.active = true; + } + + public void update(String name, boolean active) { + this.name = name; + this.active = active; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPage.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPage.java new file mode 100644 index 0000000..5b781b8 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPage.java @@ -0,0 +1,99 @@ +package com.bcsdlab.bcsdinternalapiv2.track.model; + +import com.bcsdlab.bcsdinternalapiv2.global.SoftDeletableEntity; +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; +import org.hibernate.annotations.SQLRestriction; + +/** + * 홈페이지에 노출되는 트랙 프로필. 트랙 마스터({@link TrackMaster}, "부원이 어디에 속하는가")와 + * 의도적으로 분리되어 있다 — 이 엔티티는 "홈페이지에 어떻게 보이는가"만 다룬다(ADR-002/003). + * + *

{@code @SQLRestriction}은 {@link SoftDeletableEntity}(매핑된 슈퍼클래스)가 아니라 + * 이 클래스에 직접 붙어 있다 — Hibernate가 상속시키지 않기 때문이다. + */ +@Getter +@Entity +@Table(name = "track_page") +@SQLRestriction("deleted_at is null") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackPage extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "track_id", nullable = false, updatable = false) + private TrackMaster track; + + @Column(name = "slug", nullable = false) + private String slug; + + @Column(name = "display_name", nullable = false) + private String displayName; + + @Column(name = "tagline", nullable = false) + private String tagline; + + @Column(name = "hero_image_url") + private String heroImageUrl; + + @Column(name = "og_image_url") + private String ogImageUrl; + + @Column(name = "seo_description") + private String seoDescription; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "is_published", nullable = false) + private boolean published; + + @Builder + private TrackPage(TrackMaster track, String slug, String displayName, String tagline, String heroImageUrl, + String ogImageUrl, String seoDescription, int displayOrder, boolean published) { + this.track = track; + this.slug = slug; + this.displayName = displayName; + this.tagline = tagline; + this.heroImageUrl = heroImageUrl; + this.ogImageUrl = ogImageUrl; + this.seoDescription = seoDescription; + this.displayOrder = displayOrder; + this.published = published; + } + + public void updateHeader(String displayName, String tagline, String heroImageUrl, String ogImageUrl, + String seoDescription) { + this.displayName = displayName; + this.tagline = tagline; + this.heroImageUrl = heroImageUrl; + this.ogImageUrl = ogImageUrl; + this.seoDescription = seoDescription; + } + + public void changeSlug(String slug) { + this.slug = slug; + } + + public void updatePublished(boolean published) { + this.published = published; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPageMember.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPageMember.java new file mode 100644 index 0000000..8ca5b94 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPageMember.java @@ -0,0 +1,64 @@ +package com.bcsdlab.bcsdinternalapiv2.track.model; + +import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +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; + +/** + * 트랙 페이지에 배정된 부원 한 명(ADR-012). {@code member}는 이미 구현된 명부를 그대로 + * 참조한다 — 포트 추상화 없이 {@code MemberRepository}를 직접 조회한다. 이름·등급·프로필 + * 사진에 대한 쓰기 경로는 만들지 않는다(INV-13). 하위 항목이라 soft delete를 쓰지 않고 + * {@code track_page} 삭제 시 DB의 on delete cascade로 함께 지워진다. + */ +@Getter +@Entity +@Table(name = "track_page_member") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackPageMember extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "track_page_id", nullable = false, updatable = false) + private TrackPage trackPage; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false, updatable = false) + private Member member; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "is_visible", nullable = false) + private boolean visible; + + @Builder + private TrackPageMember(TrackPage trackPage, Member member, int displayOrder, boolean visible) { + this.trackPage = trackPage; + this.member = member; + this.displayOrder = displayOrder; + this.visible = visible; + } + + public void updateVisible(boolean visible) { + this.visible = visible; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPageTechStack.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPageTechStack.java new file mode 100644 index 0000000..32aa5a5 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPageTechStack.java @@ -0,0 +1,46 @@ +package com.bcsdlab.bcsdinternalapiv2.track.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 트랙 페이지 ↔ 기술스택 다대다 조인 + 표시 순서. 전체 교체(T-09)로만 관리한다. + */ +@Getter +@Entity +@Table(name = "track_page_tech_stack") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackPageTechStack { + + @EmbeddedId + private TrackPageTechStackId id; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("trackPageId") + @JoinColumn(name = "track_page_id") + private TrackPage trackPage; + + @ManyToOne(fetch = FetchType.LAZY) + @MapsId("techStackId") + @JoinColumn(name = "tech_stack_id") + private TechStack techStack; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + public TrackPageTechStack(TrackPage trackPage, TechStack techStack, int displayOrder) { + this.id = new TrackPageTechStackId(trackPage.getId(), techStack.getId()); + this.trackPage = trackPage; + this.techStack = techStack; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPageTechStackId.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPageTechStackId.java new file mode 100644 index 0000000..c2ceded --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPageTechStackId.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.track.model; + +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class TrackPageTechStackId implements Serializable { + + private Long trackPageId; + private Long techStackId; +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackStudyPoint.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackStudyPoint.java new file mode 100644 index 0000000..0cb34f5 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackStudyPoint.java @@ -0,0 +1,58 @@ +package com.bcsdlab.bcsdinternalapiv2.track.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; + +/** + * 트랙 페이지의 "WHAT WE STUDY" 카드. 개별 CRUD가 아니라 전체 교체(T-08)로만 관리한다 — + * 항목이 최대 4개이고 화면이 "추가·삭제·드래그 후 저장" 한 덩어리이기 때문이다. + * 하위 트리라 soft delete를 쓰지 않고 {@code on delete cascade}로 물리 삭제한다. + */ +@Getter +@Entity +@Table(name = "track_study_point") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackStudyPoint extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "track_page_id", nullable = false, updatable = false) + private TrackPage trackPage; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description", nullable = false) + private String description; + + @Column(name = "icon_image_url") + private String iconImageUrl; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Builder + private TrackStudyPoint(TrackPage trackPage, String title, String description, String iconImageUrl, + int displayOrder) { + this.trackPage = trackPage; + this.title = title; + this.description = description; + this.iconImageUrl = iconImageUrl; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TechStackRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TechStackRepository.java new file mode 100644 index 0000000..d010a47 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TechStackRepository.java @@ -0,0 +1,9 @@ +package com.bcsdlab.bcsdinternalapiv2.track.repository; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TechStack; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TechStackRepository extends JpaRepository { + + boolean existsByName(String name); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackMasterRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackMasterRepository.java new file mode 100644 index 0000000..a9dbf92 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackMasterRepository.java @@ -0,0 +1,12 @@ +package com.bcsdlab.bcsdinternalapiv2.track.repository; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TrackMasterRepository extends JpaRepository { + + Optional findByCode(String code); + + boolean existsByCode(String code); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageMemberRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageMemberRepository.java new file mode 100644 index 0000000..cff211b --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageMemberRepository.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.track.repository; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPageMember; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TrackPageMemberRepository extends JpaRepository { + + List findAllByTrackPage_IdOrderByDisplayOrderAsc(Long trackPageId); + + List findAllByTrackPage_IdAndVisibleTrueOrderByDisplayOrderAsc(Long trackPageId); + + boolean existsByTrackPage_IdAndMember_Id(Long trackPageId, Long memberId); + + Optional findByTrackPage_IdAndMember_Id(Long trackPageId, Long memberId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageRepository.java new file mode 100644 index 0000000..604e4cd --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageRepository.java @@ -0,0 +1,19 @@ +package com.bcsdlab.bcsdinternalapiv2.track.repository; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TrackPageRepository extends JpaRepository { + + List findAllByPublishedTrueOrderByDisplayOrderAsc(); + + List findAllByOrderByDisplayOrderAsc(); + + Optional findBySlugAndPublishedTrue(String slug); + + boolean existsBySlug(String slug); + + boolean existsByTrack_Id(Long trackId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageTechStackRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageTechStackRepository.java new file mode 100644 index 0000000..c989c98 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageTechStackRepository.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.track.repository; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPageTechStack; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPageTechStackId; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface TrackPageTechStackRepository extends JpaRepository { + + @Query("select tpts from TrackPageTechStack tpts join fetch tpts.techStack " + + "where tpts.trackPage.id = :trackPageId order by tpts.displayOrder asc") + List findAllByTrackPageIdOrderByDisplayOrderAsc(@Param("trackPageId") Long trackPageId); + + void deleteAllByTrackPage_Id(Long trackPageId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackStudyPointRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackStudyPointRepository.java new file mode 100644 index 0000000..585dc8c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackStudyPointRepository.java @@ -0,0 +1,12 @@ +package com.bcsdlab.bcsdinternalapiv2.track.repository; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackStudyPoint; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TrackStudyPointRepository extends JpaRepository { + + List findAllByTrackPage_IdOrderByDisplayOrderAsc(Long trackPageId); + + void deleteAllByTrackPage_Id(Long trackPageId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTechStackService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTechStackService.java new file mode 100644 index 0000000..75802b1 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTechStackService.java @@ -0,0 +1,37 @@ +package com.bcsdlab.bcsdinternalapiv2.track.service; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TechStackCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TechStackResponse; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TechStack; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TechStackRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminTechStackService { + + private final TechStackRepository techStackRepository; + + public List getTechStacks() { + return techStackRepository.findAll().stream() + .map(TechStackResponse::from) + .toList(); + } + + @Transactional + public TechStackResponse createTechStack(TechStackCreateRequest request) { + if (techStackRepository.existsByName(request.name())) { + throw new TrackException(TrackExceptionType.TECH_STACK_NAME_DUPLICATED); + } + + TechStack techStack = techStackRepository.save( + TechStack.builder().name(request.name()).iconUrl(request.iconUrl()).build()); + return TechStackResponse.from(techStack); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackMasterService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackMasterService.java new file mode 100644 index 0000000..72bb01d --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackMasterService.java @@ -0,0 +1,49 @@ +package com.bcsdlab.bcsdinternalapiv2.track.service; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackMasterCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackMasterUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackMasterResponse; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminTrackMasterService { + + private final TrackMasterRepository trackMasterRepository; + private final TrackPageRepository trackPageRepository; + + public List getTracks() { + return trackMasterRepository.findAll().stream() + .map(track -> TrackMasterResponse.of(track, trackPageRepository.existsByTrack_Id(track.getId()))) + .toList(); + } + + @Transactional + public TrackMasterResponse createTrack(TrackMasterCreateRequest request) { + if (trackMasterRepository.existsByCode(request.code())) { + throw new TrackException(TrackExceptionType.TRACK_CODE_DUPLICATED); + } + + TrackMaster track = trackMasterRepository.save( + TrackMaster.builder().code(request.code()).name(request.name()).build()); + return TrackMasterResponse.of(track, false); + } + + @Transactional + public TrackMasterResponse updateTrack(Long trackId, TrackMasterUpdateRequest request) { + TrackMaster track = trackMasterRepository.findById(trackId) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_NOT_FOUND)); + + track.update(request.name(), request.isActive()); + return TrackMasterResponse.of(track, trackPageRepository.existsByTrack_Id(track.getId())); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java new file mode 100644 index 0000000..682badd --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java @@ -0,0 +1,110 @@ +package com.bcsdlab.bcsdinternalapiv2.track.service; + +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.MemberVisibilityRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackPageMembersAttachRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.AdminTrackPageMemberResponse; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPageMember; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageMemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import java.util.HashSet; +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; + +/** + * 트랙 페이지에 부원을 배정한다(ADR-012). 명부는 이미 구현되어 있으므로 포트 추상화 없이 + * {@code MemberRepository}를 직접 조회한다. 이름·등급·프로필 사진에 대한 쓰기 경로는 + * 만들지 않는다(INV-13) — 이 서비스는 배정·순서·숨김만 다룬다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminTrackPageMemberService { + + private final TrackPageRepository trackPageRepository; + private final TrackPageMemberRepository trackPageMemberRepository; + private final MemberRepository memberRepository; + + public List getMembers(Long trackPageId) { + findTrackPageOrThrow(trackPageId); + return trackPageMemberRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).stream() + .map(AdminTrackPageMemberResponse::from) + .toList(); + } + + @Transactional + public List attachMembers(Long trackPageId, TrackPageMembersAttachRequest request) { + TrackPage trackPage = findTrackPageOrThrow(trackPageId); + + List memberIds = request.memberIds(); + if (new HashSet<>(memberIds).size() != memberIds.size()) { + throw new TrackException(TrackExceptionType.MEMBER_ALREADY_ASSIGNED); + } + Map byId = memberRepository.findAllById(memberIds).stream() + .collect(Collectors.toMap(Member::getId, member -> member)); + if (byId.size() != memberIds.size()) { + throw new TrackException(TrackExceptionType.MEMBER_NOT_FOUND); + } + for (Long memberId : memberIds) { + if (trackPageMemberRepository.existsByTrackPage_IdAndMember_Id(trackPageId, memberId)) { + throw new TrackException(TrackExceptionType.MEMBER_ALREADY_ASSIGNED); + } + } + + int nextOrder = trackPageMemberRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size(); + for (Long memberId : memberIds) { + trackPageMemberRepository.save(TrackPageMember.builder() + .trackPage(trackPage) + .member(byId.get(memberId)) + .displayOrder(nextOrder++) + .visible(true) + .build()); + } + return getMembers(trackPageId); + } + + @Transactional + public void detachMember(Long trackPageId, Long memberId) { + findTrackPageOrThrow(trackPageId); + TrackPageMember assignment = trackPageMemberRepository + .findByTrackPage_IdAndMember_Id(trackPageId, memberId) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_PAGE_MEMBER_NOT_FOUND)); + trackPageMemberRepository.delete(assignment); + } + + @Transactional + public void updateVisibility(Long trackPageId, Long memberId, MemberVisibilityRequest request) { + findTrackPageOrThrow(trackPageId); + TrackPageMember assignment = trackPageMemberRepository + .findByTrackPage_IdAndMember_Id(trackPageId, memberId) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_PAGE_MEMBER_NOT_FOUND)); + assignment.updateVisible(request.isVisible()); + } + + @Transactional + public void reorder(Long trackPageId, OrderRequest request) { + findTrackPageOrThrow(trackPageId); + List assignments = trackPageMemberRepository + .findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId); + Map byId = assignments.stream() + .collect(Collectors.toMap(TrackPageMember::getId, assignment -> assignment)); + + Map newOrders = DisplayOrders.reassign(request.ids(), byId.keySet()); + newOrders.forEach((id, order) -> byId.get(id).updateDisplayOrder(order)); + } + + private TrackPage findTrackPageOrThrow(Long id) { + return trackPageRepository.findById(id) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_PAGE_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java new file mode 100644 index 0000000..e72ed3b --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java @@ -0,0 +1,172 @@ +package com.bcsdlab.bcsdinternalapiv2.track.service; + +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.SlugChangeRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.StudyPointRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.StudyPointsReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TechStacksReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackPageCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.request.TrackPageUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.AdminTrackPageDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.AdminTrackPageSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.StudyPointResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TechStackResponse; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TechStack; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPageTechStack; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackStudyPoint; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TechStackRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageTechStackRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackStudyPointRepository; +import com.bcsdlab.bcsdinternalapiv2.track.util.SlugGenerator; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +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 AdminTrackPageService { + + private final TrackPageRepository trackPageRepository; + private final TrackMasterRepository trackMasterRepository; + private final TrackStudyPointRepository trackStudyPointRepository; + private final TechStackRepository techStackRepository; + private final TrackPageTechStackRepository trackPageTechStackRepository; + + public List getTrackPages() { + return trackPageRepository.findAllByOrderByDisplayOrderAsc().stream() + .map(AdminTrackPageSummaryResponse::from) + .toList(); + } + + public AdminTrackPageDetailResponse getTrackPage(Long id) { + return AdminTrackPageDetailResponse.from(findTrackPageOrThrow(id)); + } + + @Transactional + public AdminTrackPageDetailResponse createTrackPage(TrackPageCreateRequest request) { + TrackMaster track = trackMasterRepository.findById(request.trackId()) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_NOT_FOUND)); + if (trackPageRepository.existsByTrack_Id(track.getId())) { + throw new TrackException(TrackExceptionType.TRACK_PAGE_ALREADY_EXISTS); + } + + String slug = SlugGenerator.from(request.displayName()); + if (trackPageRepository.existsBySlug(slug)) { + throw new TrackException(TrackExceptionType.TRACK_PAGE_SLUG_DUPLICATED); + } + + TrackPage trackPage = TrackPage.builder() + .track(track) + .slug(slug) + .displayName(request.displayName()) + .tagline(request.tagline()) + .heroImageUrl(request.heroImageUrl()) + .ogImageUrl(request.ogImageUrl()) + .seoDescription(request.seoDescription()) + .displayOrder((int) trackPageRepository.count()) + .published(true) + .build(); + + return AdminTrackPageDetailResponse.from(trackPageRepository.save(trackPage)); + } + + @Transactional + public AdminTrackPageDetailResponse updateTrackPage(Long id, TrackPageUpdateRequest request) { + TrackPage trackPage = findTrackPageOrThrow(id); + trackPage.updateHeader(request.displayName(), request.tagline(), request.heroImageUrl(), + request.ogImageUrl(), request.seoDescription()); + return AdminTrackPageDetailResponse.from(trackPage); + } + + @Transactional + public AdminTrackPageDetailResponse changeSlug(Long id, SlugChangeRequest request) { + TrackPage trackPage = findTrackPageOrThrow(id); + if (!trackPage.getSlug().equals(request.slug()) && trackPageRepository.existsBySlug(request.slug())) { + throw new TrackException(TrackExceptionType.TRACK_PAGE_SLUG_DUPLICATED); + } + + trackPage.changeSlug(request.slug()); + return AdminTrackPageDetailResponse.from(trackPage); + } + + @Transactional + public void publish(Long id, PublishRequest request) { + findTrackPageOrThrow(id).updatePublished(request.isPublished()); + } + + @Transactional + public void reorder(OrderRequest request) { + List trackPages = trackPageRepository.findAll(); + Map byId = trackPages.stream() + .collect(Collectors.toMap(TrackPage::getId, trackPage -> trackPage)); + + Map newOrders = DisplayOrders.reassign(request.ids(), byId.keySet()); + newOrders.forEach((trackPageId, order) -> byId.get(trackPageId).updateDisplayOrder(order)); + } + + @Transactional + public void deleteTrackPage(Long id) { + findTrackPageOrThrow(id).delete(Instant.now()); + } + + @Transactional + public List replaceStudyPoints(Long id, StudyPointsReplaceRequest request) { + TrackPage trackPage = findTrackPageOrThrow(id); + trackStudyPointRepository.deleteAllByTrackPage_Id(id); + + List items = request.studyPoints(); + List saved = new ArrayList<>(items.size()); + for (int i = 0; i < items.size(); i++) { + StudyPointRequest item = items.get(i); + saved.add(trackStudyPointRepository.save(TrackStudyPoint.builder() + .trackPage(trackPage) + .title(item.title()) + .description(item.description()) + .iconImageUrl(item.iconImageUrl()) + .displayOrder(i) + .build())); + } + return saved.stream().map(StudyPointResponse::from).toList(); + } + + @Transactional + public List replaceTechStacks(Long id, TechStacksReplaceRequest request) { + TrackPage trackPage = findTrackPageOrThrow(id); + List techStackIds = request.techStackIds(); + + Map byId = techStackRepository.findAllById(techStackIds).stream() + .collect(Collectors.toMap(TechStack::getId, techStack -> techStack)); + if (byId.size() != new HashSet<>(techStackIds).size()) { + throw new TrackException(TrackExceptionType.TECH_STACK_NOT_FOUND); + } + + trackPageTechStackRepository.deleteAllByTrackPage_Id(id); + + List saved = new ArrayList<>(techStackIds.size()); + for (int i = 0; i < techStackIds.size(); i++) { + saved.add(trackPageTechStackRepository.save( + new TrackPageTechStack(trackPage, byId.get(techStackIds.get(i)), i))); + } + return saved.stream().map(tpts -> TechStackResponse.from(tpts.getTechStack())).toList(); + } + + private TrackPage findTrackPageOrThrow(Long id) { + return trackPageRepository.findById(id) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_PAGE_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackService.java new file mode 100644 index 0000000..08d8a5b --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackService.java @@ -0,0 +1,86 @@ +package com.bcsdlab.bcsdinternalapiv2.track.service; + +import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.CurriculumResponse; +import com.bcsdlab.bcsdinternalapiv2.curriculum.service.CurriculumQueryService; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.StudyPointResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TechStackSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackMemberResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPageMember; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageMemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageTechStackRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackStudyPointRepository; +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 TrackService { + + private final TrackPageRepository trackPageRepository; + private final TrackStudyPointRepository trackStudyPointRepository; + private final TrackPageTechStackRepository trackPageTechStackRepository; + private final TrackPageMemberRepository trackPageMemberRepository; + private final MemberRepository memberRepository; + private final CurriculumQueryService curriculumQueryService; + + public List getTracks() { + return trackPageRepository.findAllByPublishedTrueOrderByDisplayOrderAsc().stream() + .map(TrackSummaryResponse::from) + .toList(); + } + + public TrackDetailResponse getTrack(String slug) { + TrackPage trackPage = trackPageRepository.findBySlugAndPublishedTrue(slug) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_NOT_FOUND)); + + List studyPoints = trackStudyPointRepository + .findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPage.getId()).stream() + .map(StudyPointResponse::from) + .toList(); + + List techStacks = trackPageTechStackRepository + .findAllByTrackPageIdOrderByDisplayOrderAsc(trackPage.getId()).stream() + .map(tpts -> TechStackSummaryResponse.from(tpts.getTechStack())) + .toList(); + + CurriculumResponse curriculum = curriculumQueryService.getPublishedCurriculum(trackPage.getId()) + .orElse(null); + + List members = publishedMembers(trackPage.getId()); + + return TrackDetailResponse.of(trackPage, studyPoints, techStacks, curriculum, members); + } + + /** + * 홈페이지 빌드가 매번 호출하는 공개 경로라 커리큘럼 조회와 같은 이유로 N+1을 피한다 — + * 배정 목록 1쿼리 + 부원 IN절 배치 조회 1쿼리로 끝낸다. + */ + private List publishedMembers(Long trackPageId) { + List assignments = trackPageMemberRepository + .findAllByTrackPage_IdAndVisibleTrueOrderByDisplayOrderAsc(trackPageId); + List memberIds = assignments.stream().map(a -> a.getMember().getId()).toList(); + Map membersById = memberIds.isEmpty() + ? Map.of() + : memberRepository.findAllById(memberIds).stream() + .collect(Collectors.toMap(Member::getId, member -> member)); + + return assignments.stream() + .map(a -> membersById.get(a.getMember().getId())) + .filter(member -> member != null && member.isActive()) + .map(TrackMemberResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/util/SlugGenerator.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/util/SlugGenerator.java new file mode 100644 index 0000000..f7892c3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/util/SlugGenerator.java @@ -0,0 +1,17 @@ +package com.bcsdlab.bcsdinternalapiv2.track.util; + +/** + * 트랙 표시명(영문, 예: "Data Analyst")에서 URL slug를 파생한다(AC-1.1). + * 소문자로 바꾸고, 영숫자가 아닌 문자는 하이픈으로 뭉친 뒤 양끝 하이픈을 지운다. + */ +public final class SlugGenerator { + + private SlugGenerator() { + } + + public static String from(String displayName) { + return displayName.toLowerCase() + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", ""); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 02140c0..7c0592f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,3 +45,7 @@ app: transport: ${MAIL_TRANSPORT:sendmail} from: ${MAIL_FROM:no-reply@bcsdlab.com} ses-region: ${AWS_SES_REGION:us-west-2} + s3: + region: ${AWS_S3_REGION:us-west-2} + bucket: ${AWS_S3_BUCKET:bcsd-internal} + public-base-url: ${AWS_S3_PUBLIC_BASE_URL:https://image.bcsdlab.com} diff --git a/src/main/resources/db/migration/V10__add_member_profile_image_url.sql b/src/main/resources/db/migration/V10__add_member_profile_image_url.sql new file mode 100644 index 0000000..74809e9 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_member_profile_image_url.sql @@ -0,0 +1,4 @@ +-- 홈페이지 "함께 할 멤버들" 섹션에 프로필 사진이 필요한데 현재 member 스키마에 없다(T-18). +-- 컬럼과 조회만 추가하고 쓰기 API는 만들지 않는다 — 명부 관리 책임은 그대로 auth/member +-- 담당자에게 있다(INV-13). +ALTER TABLE member ADD COLUMN profile_image_url VARCHAR(500); diff --git a/src/main/resources/db/migration/V7__create_track.sql b/src/main/resources/db/migration/V7__create_track.sql new file mode 100644 index 0000000..fef5a96 --- /dev/null +++ b/src/main/resources/db/migration/V7__create_track.sql @@ -0,0 +1,24 @@ +CREATE TABLE track ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + code VARCHAR(20) NOT NULL, + name VARCHAR(50) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_track_code UNIQUE (code) +); + +-- member.track(VARCHAR enum)의 10개 값 + 홈페이지에는 있으나 인터널 enum에는 없던 SECURITY. +INSERT INTO track (code, name) VALUES + ('FRONTEND', '프론트엔드'), + ('BACKEND', '백엔드'), + ('ANDROID', '안드로이드'), + ('IOS', 'iOS'), + ('PM', '기획'), + ('DATA', '데이터'), + ('DESIGN', '디자인'), + ('DEVOPS', '데브옵스'), + ('PS', 'PS'), + ('GAME', '게임'), + ('SECURITY', '보안'); diff --git a/src/main/resources/db/migration/V8__add_member_track_id.sql b/src/main/resources/db/migration/V8__add_member_track_id.sql new file mode 100644 index 0000000..1172cd7 --- /dev/null +++ b/src/main/resources/db/migration/V8__add_member_track_id.sql @@ -0,0 +1,14 @@ +-- expand -> migrate -> contract 중 expand+migrate 단계. contract(레거시 컬럼 완전 제거)는 +-- 이번 마이그레이션 범위 밖이다 — 애플리케이션 코드 전환이 배포로 안정화된 뒤 별도 진행한다. + +ALTER TABLE member ADD COLUMN track_id BIGINT; + +UPDATE member m SET track_id = t.id FROM track t WHERE t.code = m.track; + +ALTER TABLE member ALTER COLUMN track_id SET NOT NULL; +ALTER TABLE member ADD CONSTRAINT fk_member_track FOREIGN KEY (track_id) REFERENCES track (id); + +-- 레거시 문자열 컬럼은 남겨 두되(하위 호환 read path), 새 엔티티는 이 컬럼을 매핑하지 않으므로 +-- NOT NULL을 유지하면 새 부원 생성 INSERT가 "track" 컬럼 누락으로 실패한다. CHECK 제약은 +-- NULL을 통과시키므로 ck_member_track과 충돌하지 않는다. +ALTER TABLE member ALTER COLUMN track DROP NOT NULL; diff --git a/src/main/resources/db/migration/V9__create_homepage_content.sql b/src/main/resources/db/migration/V9__create_homepage_content.sql new file mode 100644 index 0000000..a1a1ee9 --- /dev/null +++ b/src/main/resources/db/migration/V9__create_homepage_content.sql @@ -0,0 +1,177 @@ +-- 홈페이지 CMS 스키마 (05-api-spec.md §1.2). track_page는 트랙 마스터(V7)를 참조한다. +-- 최상위 엔티티(track_page/curriculum/activity_category/activity)만 soft delete를 쓰고, +-- 하위 트리는 ON DELETE CASCADE로 물리 삭제한다. + +-- ===== 홈페이지 노출 프로필 (track 1 : track_page 0..1) ===== +CREATE TABLE track_page ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_id BIGINT NOT NULL REFERENCES track (id), + slug VARCHAR(50) NOT NULL, + display_name VARCHAR(50) NOT NULL, + tagline VARCHAR(120) NOT NULL, + hero_image_url VARCHAR(500), + og_image_url VARCHAR(500), + seo_description VARCHAR(200), + display_order INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX uq_track_page_slug ON track_page (slug) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uq_track_page_track ON track_page (track_id) WHERE deleted_at IS NULL; + +CREATE TABLE track_study_point ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + title VARCHAR(60) NOT NULL, + description VARCHAR(200) NOT NULL, + icon_image_url VARCHAR(500), + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_study_point_page ON track_study_point (track_page_id, display_order); + +CREATE TABLE tech_stack ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(50) NOT NULL, + icon_url VARCHAR(500) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_tech_stack_name UNIQUE (name) +); + +CREATE TABLE track_page_tech_stack ( + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + tech_stack_id BIGINT NOT NULL REFERENCES tech_stack (id) ON DELETE CASCADE, + display_order INT NOT NULL, + + PRIMARY KEY (track_page_id, tech_stack_id) +); + +-- 명부(member)가 이미 구현되어 있으므로 FK를 즉시 적용한다 (ADR-012). +CREATE TABLE track_page_member ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + member_id BIGINT NOT NULL REFERENCES member (id), + display_order INT NOT NULL, + is_visible BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_track_page_member UNIQUE (track_page_id, member_id) +); + +-- ===== 커리큘럼 ===== +CREATE TABLE curriculum ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT FALSE, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +-- INV-2: 트랙당 공개 세트는 1개만 (ADR-004) +CREATE UNIQUE INDEX uq_curriculum_published + ON curriculum (track_page_id) WHERE is_published AND deleted_at IS NULL; + +CREATE TABLE curriculum_week ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + curriculum_id BIGINT NOT NULL REFERENCES curriculum (id) ON DELETE CASCADE, + week_from INT NOT NULL, + week_to INT, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT ck_week_range CHECK (week_to IS NULL OR week_to >= week_from), + CONSTRAINT ck_week_from CHECK (week_from BETWEEN 1 AND 99) +); +CREATE INDEX idx_week_curriculum ON curriculum_week (curriculum_id, display_order); + +CREATE TABLE curriculum_topic ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + week_id BIGINT NOT NULL REFERENCES curriculum_week (id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_topic_week ON curriculum_topic (week_id, display_order); + +CREATE TABLE curriculum_topic_detail ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + topic_id BIGINT NOT NULL REFERENCES curriculum_topic (id) ON DELETE CASCADE, + content VARCHAR(300) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_detail_topic ON curriculum_topic_detail (topic_id, display_order); + +-- ===== 활동 ===== +CREATE TABLE activity_category ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slug VARCHAR(30) NOT NULL, + name VARCHAR(30) NOT NULL, + headline VARCHAR(200), + hero_image_url VARCHAR(500), + display_order INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX uq_activity_category_slug ON activity_category (slug) WHERE deleted_at IS NULL; + +CREATE TABLE activity ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + category_id BIGINT NOT NULL REFERENCES activity_category (id), + year INT NOT NULL, + month INT NOT NULL, + title VARCHAR(80) NOT NULL, + summary VARCHAR(200) NOT NULL, + content TEXT, + external_url VARCHAR(500), + display_order INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + + CONSTRAINT ck_activity_month CHECK (month BETWEEN 1 AND 12), + CONSTRAINT ck_activity_year CHECK (year BETWEEN 2000 AND 2100) +); +CREATE INDEX idx_activity_timeline + ON activity (category_id, year DESC, month DESC, display_order) + WHERE deleted_at IS NULL; + +CREATE TABLE activity_image ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + activity_id BIGINT NOT NULL REFERENCES activity (id) ON DELETE CASCADE, + image_url VARCHAR(500) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_activity_image ON activity_image (activity_id, display_order); + +-- ===== 미디어 라이브러리 (콘텐츠 테이블과 FK로 묶지 않는다 · ADR-009) ===== +CREATE TABLE image_asset ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + s3_key VARCHAR(300) NOT NULL, + url VARCHAR(500) NOT NULL, + original_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + byte_size BIGINT NOT NULL, + purpose VARCHAR(30) NOT NULL, + is_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + uploaded_by BIGINT REFERENCES member (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_image_asset_s3_key UNIQUE (s3_key) +); +CREATE INDEX idx_image_asset_library ON image_asset (purpose, created_at DESC) WHERE is_confirmed; diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/IntegrationTestSupport.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/IntegrationTestSupport.java new file mode 100644 index 0000000..bfc2636 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/IntegrationTestSupport.java @@ -0,0 +1,41 @@ +package com.bcsdlab.bcsdinternalapiv2; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; + +/** + * 통합 테스트 공통 베이스. Postgres 컨테이너를 테스트 클래스마다 새로 띄우지 않도록, + * 정적 초기화 블록에서 JVM당 한 번만 기동하는 Testcontainers 공식 "싱글턴 컨테이너" 패턴을 쓴다. + * + *

{@code @Testcontainers}/{@code @Container}를 쓰지 않는 이유: 그 조합은 컨테이너 생명주기를 + * 테스트 클래스 단위로 관리해서(클래스마다 기동), 클래스가 늘어날수록 기동 비용이 누적된다. + * 여기서는 정적 필드를 상속받는 모든 하위 클래스가 같은 컨테이너 인스턴스를 공유한다. + * + *

기존 7개 테스트 클래스(auth 4, member 2, 컨텍스트 로딩 1)는 각자 컨테이너를 띄우는 방식을 + * 그대로 쓴다 — 이 클래스로의 이전은 선택적 후속 작업이다(T-01). + */ +@SpringBootTest +@AutoConfigureMockMvc +public abstract class IntegrationTestSupport { + + @ServiceConnection + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + static { + POSTGRES.start(); + } + + @DynamicPropertySource + static void jwtSecret(DynamicPropertyRegistry registry) { + registry.add("app.jwt.secret", () -> "test-only-secret-key-not-for-production-32bytes-min"); + } + + @Autowired + protected MockMvc mockMvc; +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/ActivityIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/ActivityIntegrationTest.java new file mode 100644 index 0000000..b46a6a6 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/ActivityIntegrationTest.java @@ -0,0 +1,140 @@ +package com.bcsdlab.bcsdinternalapiv2.activity; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.activity.model.Activity; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityImage; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityImageRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityRepository; +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.jdbc.core.JdbcTemplate; + +class ActivityIntegrationTest extends IntegrationTestSupport { + + @Autowired + private ActivityCategoryRepository activityCategoryRepository; + + @Autowired + private ActivityRepository activityRepository; + + @Autowired + private ActivityImageRepository activityImageRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private ActivityCategory category; + + @BeforeEach + void setUp() { + // @SQLRestriction 때문에 deleteAll()은 soft-delete된 행을 찾지 못해 물리적으로 + // 남겨 두고, 그 남은 행이 activity_category FK를 계속 참조해 다음 deleteAll()을 막는다. + jdbcTemplate.update("delete from activity_image"); + jdbcTemplate.update("delete from activity"); + jdbcTemplate.update("delete from activity_category"); + + category = activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + } + + @Test + @DisplayName("AC-3.1 연도 내림차순, AC-3.2 같은 달 안에서는 display_order 순서다") + void 연도_내림차순_같은_달은_순서대로() throws Exception { + activityRepository.save(Activity.builder() + .category(category).year(2019).month(1).title("A").summary("s").displayOrder(0).published(true) + .build()); + Activity may2 = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("5월 두번째").summary("s").displayOrder(1) + .published(true).build()); + Activity may1 = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("5월 첫번째").summary("s").displayOrder(0) + .published(true).build()); + activityRepository.save(Activity.builder() + .category(category).year(2020).month(3).title("B").summary("s").displayOrder(0).published(true) + .build()); + + mockMvc.perform(get("/v1/activities").queryParam("category", category.getSlug())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].year").value(2020)) + .andExpect(jsonPath("$[1].year").value(2019)) + .andExpect(jsonPath("$[1].activities[0].title").value(may1.getTitle())) + .andExpect(jsonPath("$[1].activities[1].title").value(may2.getTitle())) + .andExpect(jsonPath("$[1].activities[2].title").value("A")); + } + + @Test + @DisplayName("AC-3.4 목록의 thumbnailUrl은 사진 첫 장이다") + void 썸네일은_첫_사진() throws Exception { + Activity activity = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("A").summary("s").displayOrder(0).published(true) + .build()); + activityImageRepository.save(ActivityImage.builder() + .activity(activity).imageUrl("https://x/1.png").displayOrder(0).build()); + activityImageRepository.save(ActivityImage.builder() + .activity(activity).imageUrl("https://x/2.png").displayOrder(1).build()); + + mockMvc.perform(get("/v1/activities").queryParam("category", category.getSlug())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].activities[0].thumbnailUrl").value("https://x/1.png")) + .andExpect(jsonPath("$[0].activities[0].images.length()").value(2)); + } + + @Test + @DisplayName("AC-3.9 content가 없으면 hasDetail은 false다") + void 본문_없으면_hasDetail_false() throws Exception { + activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("A").summary("s").displayOrder(0).published(true) + .build()); + + mockMvc.perform(get("/v1/activities").queryParam("category", category.getSlug())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].activities[0].hasDetail").value(false)); + } + + @Test + @DisplayName("AC-3.7 숨긴 활동은 목록에서 사라지고 상세는 404다") + void 숨긴_활동은_목록_제외_상세_404() throws Exception { + Activity hidden = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("숨김").summary("s").displayOrder(0).published(false) + .build()); + activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("공개").summary("s").displayOrder(1).published(true) + .build()); + + mockMvc.perform(get("/v1/activities").queryParam("category", category.getSlug())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].activities.length()").value(1)) + .andExpect(jsonPath("$[0].activities[0].title").value("공개")); + + mockMvc.perform(get("/v1/activities/" + hidden.getId())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("활동 상세는 본문과 카테고리 slug를 포함한다") + void 상세_응답() throws Exception { + Activity activity = activityRepository.save(Activity.builder() + .category(category).year(2019).month(5).title("A").summary("s").content("

본문

") + .displayOrder(0).published(true).build()); + + mockMvc.perform(get("/v1/activities/" + activity.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.categorySlug").value(category.getSlug())) + .andExpect(jsonPath("$.content").value("

본문

")); + } + + @Test + @DisplayName("존재하지 않거나 숨겨진 카테고리로 조회하면 404다") + void 존재하지_않는_카테고리는_404() throws Exception { + mockMvc.perform(get("/v1/activities").queryParam("category", "no-such-category")) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java new file mode 100644 index 0000000..0cc7ac3 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java @@ -0,0 +1,153 @@ +package com.bcsdlab.bcsdinternalapiv2.activity; + +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; +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.repository.TrackMasterRepository; +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.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; + +class AdminActivityCategoryIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private ActivityCategoryRepository activityCategoryRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + // @SQLRestriction 때문에 deleteAll()은 soft-delete된 행을 찾지 못해 물리적으로 + // 남겨 두고, 그 남은 행이 activity_category FK를 계속 참조해 다음 deleteAll()을 막는다. + jdbcTemplate.update("delete from activity_image"); + jdbcTemplate.update("delete from activity"); + jdbcTemplate.update("delete from activity_category"); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + 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("카테고리를 생성하면 공개 목록에 나타난다") + void 카테고리_생성과_공개_목록_노출() throws Exception { + mockMvc.perform(post("/v1/admin/activity-categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"slug\":\"event\",\"name\":\"EVENT\",\"headline\":\"BCSD에서는\\n이런 활동을 하고 있어요.\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.slug").value("event")); + + mockMvc.perform(get("/v1/activity-categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].slug").value("event")) + .andExpect(jsonPath("$[0].headline").value("BCSD에서는\n이런 활동을 하고 있어요.")); + } + + @Test + @DisplayName("slug 중복은 409다") + void slug_중복은_409() throws Exception { + activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + + mockMvc.perform(post("/v1/admin/activity-categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"slug\":\"event\",\"name\":\"EVENT2\"}")) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("숨긴 카테고리는 공개 목록에서 제외된다") + void 숨긴_카테고리는_공개_목록에서_제외된다() throws Exception { + ActivityCategory event = activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + activityCategoryRepository.save(ActivityCategory.builder() + .slug("game").name("GAME").displayOrder(1).published(true).build()); + + mockMvc.perform(patch("/v1/admin/activity-categories/" + event.getId() + "/publish") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"isPublished\":false}")) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/activity-categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].slug").value("game")); + } + + @Test + @DisplayName("탭 순서를 바꾸면 공개 목록에 즉시 반영된다") + void 순서_변경이_공개_목록에_반영된다() throws Exception { + ActivityCategory event = activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + ActivityCategory game = activityCategoryRepository.save(ActivityCategory.builder() + .slug("game").name("GAME").displayOrder(1).published(true).build()); + + mockMvc.perform(patch("/v1/admin/activity-categories/order") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\":[%d,%d]}".formatted(game.getId(), event.getId()))) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/activity-categories")) + .andExpect(jsonPath("$[0].slug").value("game")) + .andExpect(jsonPath("$[1].slug").value("event")); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityIntegrationTest.java new file mode 100644 index 0000000..0b70c3f --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityIntegrationTest.java @@ -0,0 +1,212 @@ +package com.bcsdlab.bcsdinternalapiv2.activity; + +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.activity.model.Activity; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityImageRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityRepository; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; +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.repository.TrackMasterRepository; +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.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; + +class AdminActivityIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private ActivityCategoryRepository activityCategoryRepository; + + @Autowired + private ActivityRepository activityRepository; + + @Autowired + private ActivityImageRepository activityImageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private ActivityCategory category; + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + // @SQLRestriction 때문에 deleteAll()은 soft-delete된 행을 찾지 못해 물리적으로 + // 남겨 두고, 그 남은 행이 activity_category FK를 계속 참조해 다음 deleteAll()을 막는다. + jdbcTemplate.update("delete from activity_image"); + jdbcTemplate.update("delete from activity"); + jdbcTemplate.update("delete from activity_category"); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + category = activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + 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("활동 생성 시 본문의 script 태그가 저장 전에 제거된다 (AC-3.5)") + void 생성_시_본문이_정제된다() throws Exception { + String body = mockMvc.perform(post("/v1/admin/activities") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(("{\"categoryId\":%d,\"year\":2019,\"month\":5,\"title\":\"컨퍼런스\"," + + "\"summary\":\"요약\",\"content\":\"

안내

\"}") + .formatted(category.getId()))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + String content = objectMapper.readTree(body).get("content").asText(); + assertThat(content).doesNotContainIgnoringCase("안내

"); + + assertThat(result).doesNotContainIgnoringCase("안내

"); + + assertThat(result).doesNotContain("onclick"); + } + + @Test + @DisplayName("허용 목록 밖 태그(iframe)는 제거된다") + void 허용_목록_밖_태그는_제거된다() { + String result = ActivityContentSanitizer.sanitize("

안내

"); + + assertThat(result).doesNotContainIgnoringCase(""); + + assertThat(result).doesNotContain("evil.com"); + } + + @Test + @DisplayName("허용된 호스트의 이미지는 보존된다") + void 허용된_호스트의_이미지는_보존된다() { + String result = ActivityContentSanitizer.sanitize("\"a\""); + + assertThat(result).contains("image.bcsdlab.com/a.png"); + } + + @Test + @DisplayName("허용 태그·속성은 그대로 보존된다 (과잉 정제 방지)") + void 허용_태그와_속성은_보존된다() { + String result = ActivityContentSanitizer.sanitize( + "

제 1회 BCSD Lab Conference를 개최했습니다.

" + + "
  • 세션 A
  • 세션 B
" + + "링크"); + + assertThat(result) + .contains("BCSD Lab Conference") + .contains("
  • 세션 A
  • ") + .contains("href=\"https://bcsdlab.com\""); + } + + @Test + @DisplayName("null 입력은 null을 반환한다") + void null_입력은_null을_반환한다() { + assertThat(ActivityContentSanitizer.sanitize(null)).isNull(); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/InitialSetupIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/InitialSetupIntegrationTest.java index fe17ba8..6898f77 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/InitialSetupIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/InitialSetupIntegrationTest.java @@ -10,8 +10,9 @@ import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; @@ -54,9 +55,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; @@ -67,13 +73,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); refreshTokenRepository.deleteAll(); memberRepository.deleteAll(); Member member = Member.builder() .studentNumber("20231234") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("홍길동") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/LoginIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/LoginIntegrationTest.java index 9b6fbfb..15a5704 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/LoginIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/LoginIntegrationTest.java @@ -9,8 +9,9 @@ import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -52,9 +53,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; @@ -63,13 +69,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); refreshTokenRepository.deleteAll(); memberRepository.deleteAll(); Member member = Member.builder() .studentNumber("20231234") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("홍길동") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -165,7 +172,7 @@ void setUp() { .studentNumber("20236543") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("정하늘") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -213,7 +220,7 @@ void setUp() { .studentNumber("20235432") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("서지훈") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -257,7 +264,7 @@ void setUp() { .studentNumber("20230000") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("잠긴계정") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/PasswordResetIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/PasswordResetIntegrationTest.java index 046200d..f822435 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/PasswordResetIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/PasswordResetIntegrationTest.java @@ -16,8 +16,9 @@ import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import jakarta.servlet.http.Cookie; import java.time.Instant; import java.util.List; @@ -62,9 +63,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; @@ -82,6 +88,7 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); refreshTokenRepository.deleteAll(); passwordResetTokenRepository.deleteAll(); memberRepository.deleteAll(); @@ -93,7 +100,7 @@ void setUp() { .studentNumber("20236666") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("최수진") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -169,7 +176,7 @@ void setUp() { .studentNumber("20235555") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("오세훈") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/RefreshTokenIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/RefreshTokenIntegrationTest.java index e28dbde..38e1b2d 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/RefreshTokenIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/RefreshTokenIntegrationTest.java @@ -10,8 +10,9 @@ import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import jakarta.servlet.http.Cookie; import java.util.ArrayList; import java.util.List; @@ -53,9 +54,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; @@ -64,6 +70,7 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); refreshTokenRepository.deleteAll(); memberRepository.deleteAll(); } @@ -74,7 +81,7 @@ void setUp() { .studentNumber("20237777") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("이민수") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -129,7 +136,7 @@ void setUp() { .studentNumber("20238888") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("박영희") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -186,7 +193,7 @@ void setUp() { .studentNumber("20239999") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("김철수") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/controller/HealthCheckIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/controller/HealthCheckIntegrationTest.java new file mode 100644 index 0000000..73eb8ca --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/controller/HealthCheckIntegrationTest.java @@ -0,0 +1,24 @@ +package com.bcsdlab.bcsdinternalapiv2.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * {@link IntegrationTestSupport}(싱글턴 Postgres 컨테이너 베이스)가 실제로 동작하는지 + * 검증하는 최소 스모크 테스트도 겸한다(T-01). + */ +class HealthCheckIntegrationTest extends IntegrationTestSupport { + + @Test + @DisplayName("GET /health는 인증 없이 200과 OK를 반환한다") + void health() throws Exception { + mockMvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(content().string("OK")); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumCloneIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumCloneIntegrationTest.java new file mode 100644 index 0000000..cf5fd3c --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumCloneIntegrationTest.java @@ -0,0 +1,174 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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.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.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 AdminCurriculumCloneIntegrationTest 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 TrackPage trackPage; + private Curriculum source; + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + trackPageRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + trackPage = trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).build()); + + source = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("비기너").published(true).displayOrder(0).build()); + CurriculumWeek week1 = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(source).weekFrom(1).displayOrder(0).build()); + CurriculumWeek week2 = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(source).weekFrom(13).weekTo(16).displayOrder(1).build()); + CurriculumTopic topic1 = curriculumTopicRepository.save( + CurriculumTopic.builder().week(week1).title("웹 동작 방식").displayOrder(0).build()); + curriculumTopicRepository.save( + CurriculumTopic.builder().week(week1).title("HTTP").displayOrder(1).build()); + curriculumTopicRepository.save( + CurriculumTopic.builder().week(week2).title("PROJECT").displayOrder(0).build()); + curriculumTopicDetailRepository.save( + CurriculumTopicDetail.builder().topic(topic1).content("클라이언트-서버").displayOrder(0).build()); + curriculumTopicDetailRepository.save( + CurriculumTopicDetail.builder().topic(topic1).content("DNS").displayOrder(1).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.8 세트를 복제하면 주차·토픽·세부항목 개수와 순서가 원본과 같고 복제본은 비공개다") + void 세트를_복제하면_트리_전체가_복사되고_비공개다() throws Exception { + String body = mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sourceCurriculumId\":%d}".formatted(source.getId()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("비기너")) + .andExpect(jsonPath("$.isPublished").value(false)) + .andReturn().getResponse().getContentAsString(); + long cloneId = objectMapper.readTree(body).get("id").asLong(); + + var clonedWeeks = curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(cloneId); + assertThat(clonedWeeks).hasSize(2); + assertThat(clonedWeeks.get(0).getWeekFrom()).isEqualTo(1); + assertThat(clonedWeeks.get(1).getWeekFrom()).isEqualTo(13); + assertThat(clonedWeeks.get(1).getWeekTo()).isEqualTo(16); + + var clonedTopicsOfWeek1 = curriculumTopicRepository + .findAllByWeek_IdOrderByDisplayOrderAsc(clonedWeeks.get(0).getId()); + assertThat(clonedTopicsOfWeek1).hasSize(2); + assertThat(clonedTopicsOfWeek1.get(0).getTitle()).isEqualTo("웹 동작 방식"); + + var clonedDetails = curriculumTopicDetailRepository + .findAllByTopic_IdOrderByDisplayOrderAsc(clonedTopicsOfWeek1.get(0).getId()); + assertThat(clonedDetails).hasSize(2); + assertThat(clonedDetails.get(0).getContent()).isEqualTo("클라이언트-서버"); + assertThat(clonedDetails.get(1).getContent()).isEqualTo("DNS"); + + // 원본은 그대로다 — 복제가 원본을 건드리지 않는다 + assertThat(curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(source.getId())).hasSize(2); + } + + @Test + @DisplayName("이름 없이 복제하면 원본 이름을 그대로 쓴다") + void 이름_없이_복제하면_원본_이름을_쓴다() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sourceCurriculumId\":%d,\"name\":\"\"}".formatted(source.getId()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("비기너")); + } + + @Test + @DisplayName("복제가 아닌데 이름이 없으면 400이다") + void 복제가_아닌데_이름이_없으면_400() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumIntegrationTest.java new file mode 100644 index 0000000..ea4a257 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/AdminCurriculumIntegrationTest.java @@ -0,0 +1,143 @@ +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.repository.CurriculumRepository; +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 AdminCurriculumIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private CurriculumRepository curriculumRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private TrackPage trackPage; + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + trackPageRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + trackPage = trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).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("세트를 생성하면 비공개 상태로 만들어진다") + void 세트_생성은_비공개_상태다() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"비기너\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("비기너")) + .andExpect(jsonPath("$.isPublished").value(false)); + } + + @Test + @DisplayName("AC-2.1 세트를 공개하면 같은 트랙의 기존 공개 세트가 자동으로 비공개된다") + void 공개하면_기존_공개_세트가_자동으로_내려간다() throws Exception { + Curriculum a = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("A").published(true).displayOrder(0).build()); + Curriculum b = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("B").published(false).displayOrder(1).build()); + + mockMvc.perform(patch("/v1/admin/curriculums/" + b.getId() + "/publish") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"isPublished\":true}")) + .andExpect(status().isNoContent()); + + assertThat(curriculumRepository.findById(a.getId()).orElseThrow().isPublished()).isFalse(); + assertThat(curriculumRepository.findById(b.getId()).orElseThrow().isPublished()).isTrue(); + } + + @Test + @DisplayName("이름 수정과 soft delete가 반영된다") + void 이름_수정과_삭제() throws Exception { + Curriculum curriculum = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("초안").published(false).displayOrder(0).build()); + + mockMvc.perform(put("/v1/admin/curriculums/" + curriculum.getId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"비기너\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("비기너")); + + mockMvc.perform(delete("/v1/admin/curriculums/" + curriculum.getId()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/admin/track-pages/" + trackPage.getId() + "/curriculums") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } +} 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)); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/PublicCurriculumIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/PublicCurriculumIntegrationTest.java new file mode 100644 index 0000000..7b28a46 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/curriculum/PublicCurriculumIntegrationTest.java @@ -0,0 +1,115 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.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.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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class PublicCurriculumIntegrationTest extends IntegrationTestSupport { + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private CurriculumRepository curriculumRepository; + + @Autowired + private CurriculumWeekRepository curriculumWeekRepository; + + @Autowired + private CurriculumTopicRepository curriculumTopicRepository; + + @Autowired + private CurriculumTopicDetailRepository curriculumTopicDetailRepository; + + private TrackMaster backend; + + @BeforeEach + void setUp() { + trackPageRepository.deleteAll(); + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + } + + @Test + @DisplayName("AC-2.2 공개 커리큘럼 세트가 없으면 curriculum은 null이고 500이 아니다") + void 공개_세트가_없으면_null이다() throws Exception { + trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).build()); + + mockMvc.perform(get("/v1/tracks/backend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.curriculum").doesNotExist()); + } + + @Test + @DisplayName("AC-2.4 weekFrom/weekTo가 그대로 반환된다 · AC-2.7 토픽 0개인 주차는 제외된다") + void weekFrom_weekTo_그대로_반환하고_빈_주차는_제외한다() throws Exception { + TrackPage trackPage = trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).build()); + Curriculum curriculum = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("비기너").published(true).displayOrder(0).build()); + + CurriculumWeek weekWithTopic = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(curriculum).weekFrom(4).displayOrder(0).build()); + CurriculumTopic topic = curriculumTopicRepository.save( + CurriculumTopic.builder().week(weekWithTopic).title("JS 비동기").displayOrder(0).build()); + curriculumTopicDetailRepository.save( + CurriculumTopicDetail.builder().topic(topic).content("callback").displayOrder(0).build()); + curriculumTopicDetailRepository.save( + CurriculumTopicDetail.builder().topic(topic).content("Promise").displayOrder(1).build()); + + // 토픽이 없는 범위 주차 — 공개 응답에서 제외되어야 한다(AC-2.7) + curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(curriculum).weekFrom(13).weekTo(16).displayOrder(1).build()); + + mockMvc.perform(get("/v1/tracks/backend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.curriculum.name").value("비기너")) + .andExpect(jsonPath("$.curriculum.weeks.length()").value(1)) + .andExpect(jsonPath("$.curriculum.weeks[0].weekFrom").value(4)) + .andExpect(jsonPath("$.curriculum.weeks[0].weekTo").doesNotExist()) + .andExpect(jsonPath("$.curriculum.weeks[0].topics[0].title").value("JS 비동기")) + .andExpect(jsonPath("$.curriculum.weeks[0].topics[0].details[0]").value("callback")) + .andExpect(jsonPath("$.curriculum.weeks[0].topics[0].details[1]").value("Promise")); + } + + @Test + @DisplayName("비공개 세트는 노출되지 않는다") + void 비공개_세트는_노출되지_않는다() throws Exception { + TrackPage trackPage = trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).build()); + Curriculum draft = curriculumRepository.save(Curriculum.builder() + .trackPage(trackPage).name("초안").published(false).displayOrder(0).build()); + CurriculumWeek week = curriculumWeekRepository.save( + CurriculumWeek.builder().curriculum(draft).weekFrom(1).displayOrder(0).build()); + curriculumTopicRepository.save( + CurriculumTopic.builder().week(week).title("비공개 토픽").displayOrder(0).build()); + + mockMvc.perform(get("/v1/tracks/backend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.curriculum").doesNotExist()); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdExceptionTypeContractTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdExceptionTypeContractTest.java new file mode 100644 index 0000000..43e2616 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdExceptionTypeContractTest.java @@ -0,0 +1,63 @@ +package com.bcsdlab.bcsdinternalapiv2.global.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityExceptionType; +import com.bcsdlab.bcsdinternalapiv2.curriculum.exception.CurriculumExceptionType; +import com.bcsdlab.bcsdinternalapiv2.media.exception.MediaExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +/** + * 도메인 예외 타입(Track/Curriculum/Activity/Media)은 아직 호출하는 컨트롤러가 없다(T-01은 + * 골격만 정의한다). 그래도 {@link BcsdExceptionType} 계약 — 상태 코드·메시지·{@code withDetail} + * 조합이 실제로 동작하는지는 미리 검증해 둔다. + */ +class BcsdExceptionTypeContractTest { + + @Test + @DisplayName("GlobalExceptionType은 지정한 상태·메시지를 그대로 반환한다") + void globalExceptionType_기본_동작() { + BcsdExceptionType type = GlobalExceptionType.ORDER_IDS_MISMATCH; + + assertThat(type.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(type.getMessage()).isEqualTo("순서 변경 대상이 기존 항목과 일치하지 않습니다."); + } + + @Test + @DisplayName("withDetail은 원본 메시지와 상태를 유지한 채 상세 메시지를 덧붙인다") + void withDetail은_상태를_바꾸지_않고_메시지에_상세를_덧붙인다() { + BcsdExceptionType detailed = GlobalExceptionType.ORDER_IDS_MISMATCH.withDetail("weekId=42"); + + assertThat(detailed.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(detailed.getMessage()) + .contains("순서 변경 대상이 기존 항목과 일치하지 않습니다.") + .contains("weekId=42"); + } + + @Test + @DisplayName("TrackExceptionType.TRACK_NOT_FOUND는 404다") + void trackExceptionType() { + assertThat(TrackExceptionType.TRACK_NOT_FOUND.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("CurriculumExceptionType.CURRICULUM_NOT_FOUND는 404다") + void curriculumExceptionType() { + assertThat(CurriculumExceptionType.CURRICULUM_NOT_FOUND.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("ActivityExceptionType.ACTIVITY_NOT_FOUND는 404다") + void activityExceptionType() { + assertThat(ActivityExceptionType.ACTIVITY_NOT_FOUND.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("MediaExceptionType.IMAGE_NOT_FOUND는 404다") + void mediaExceptionType() { + assertThat(MediaExceptionType.IMAGE_NOT_FOUND.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrdersTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrdersTest.java new file mode 100644 index 0000000..d9ae8e2 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrdersTest.java @@ -0,0 +1,54 @@ +package com.bcsdlab.bcsdinternalapiv2.global.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.GlobalException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DisplayOrdersTest { + + @Test + @DisplayName("INV-3 요청 순서대로 0부터 시작하는 연속 정수를 부여한다") + void 요청_순서대로_0부터_연속으로_부여한다() { + Map result = DisplayOrders.reassign(List.of(3L, 1L, 2L), List.of(1L, 2L, 3L)); + + assertThat(result).containsExactly( + Map.entry(3L, 0), + Map.entry(1L, 1), + Map.entry(2L, 2) + ); + } + + @Test + @DisplayName("빈 목록끼리는 예외 없이 빈 결과를 반환한다") + void 빈_목록은_빈_결과를_반환한다() { + assertThat(DisplayOrders.reassign(List.of(), List.of())).isEmpty(); + } + + @Test + @DisplayName("AC-1.5 요청에 존재하지 않는 대상이 누락되면 400 성격의 예외를 던진다") + void 대상이_누락되면_예외를_던진다() { + assertThatThrownBy(() -> DisplayOrders.reassign(List.of(1L, 2L), List.of(1L, 2L, 3L))) + .isInstanceOf(GlobalException.class); + } + + @Test + @DisplayName("AC-1.5 요청에 존재하지 않는 id가 추가되면 예외를 던진다") + void 존재하지_않는_id가_추가되면_예외를_던진다() { + assertThatThrownBy(() -> DisplayOrders.reassign(List.of(1L, 2L, 3L, 4L), List.of(1L, 2L, 3L))) + .isInstanceOf(GlobalException.class); + } + + @Test + @DisplayName("AC-1.5 요청 안에 id가 중복되면 예외를 던진다") + void 요청_안에_id가_중복되면_예외를_던진다() { + // INV-4: reassign은 순수 함수라 예외 시 Map을 전혀 만들지 않는다 — "일부만 적용된 상태"가 + // 존재할 수 없다. 호출자는 반환값을 받은 뒤에만 엔티티를 변경한다. + assertThatThrownBy(() -> DisplayOrders.reassign(List.of(1L, 1L, 2L), List.of(1L, 2L))) + .isInstanceOf(GlobalException.class); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java new file mode 100644 index 0000000..999d6ba --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/media/AdminImageIntegrationTest.java @@ -0,0 +1,158 @@ +package com.bcsdlab.bcsdinternalapiv2.media; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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.media.repository.ImageAssetRepository; +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.repository.TrackMasterRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +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.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +class AdminImageIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private ImageAssetRepository imageAssetRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private S3Presigner s3Presigner; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + jdbcTemplate.update("delete from image_asset"); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + 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); + + PresignedPutObjectRequest presigned = mock(PresignedPutObjectRequest.class); + when(presigned.url()).thenReturn(URI.create("https://s3.example.com/upload?X-Amz-Signature=fake").toURL()); + when(s3Presigner.presignPutObject(any(PutObjectPresignRequest.class))) + .thenReturn(presigned); + } + + @Test + @DisplayName("AC-4.1 5MB를 초과하면 400이다") + void 용량_초과는_400() throws Exception { + mockMvc.perform(post("/v1/admin/images/presigned-url") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fileName\":\"hero.png\",\"contentType\":\"image/png\"," + + "\"byteSize\":6000000,\"purpose\":\"TRACK_HERO\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("AC-4.2 허용 외 확장자는 400이다") + void 허용외_확장자는_400() throws Exception { + mockMvc.perform(post("/v1/admin/images/presigned-url") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fileName\":\"malware.exe\",\"contentType\":\"application/octet-stream\"," + + "\"byteSize\":1000,\"purpose\":\"TRACK_HERO\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("AC-4.3, AC-4.4 complete 전에는 라이브러리에 없고, complete 후에는 조회된다") + void complete_전후_라이브러리_노출() throws Exception { + String body = mockMvc.perform(post("/v1/admin/images/presigned-url") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fileName\":\"hero.png\",\"contentType\":\"image/png\"," + + "\"byteSize\":1000,\"purpose\":\"TRACK_HERO\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.uploadUrl").exists()) + .andExpect(jsonPath("$.publicUrl").exists()) + .andReturn().getResponse().getContentAsString(); + long imageId = objectMapper.readTree(body).get("imageId").asLong(); + + mockMvc.perform(get("/v1/admin/images") + .header("Authorization", "Bearer " + adminToken) + .queryParam("purpose", "TRACK_HERO")) + .andExpect(jsonPath("$.content.length()").value(0)); + + mockMvc.perform(post("/v1/admin/images/" + imageId + "/complete") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.url").exists()); + + mockMvc.perform(get("/v1/admin/images") + .header("Authorization", "Bearer " + adminToken) + .queryParam("purpose", "TRACK_HERO")) + .andExpect(jsonPath("$.content.length()").value(1)); + + assertThat(imageAssetRepository.findById(imageId).orElseThrow().isConfirmed()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 이미지의 complete는 404다") + void 존재하지_않는_이미지_complete는_404() throws Exception { + mockMvc.perform(post("/v1/admin/images/999999/complete") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberIntegrationTest.java index 4e3e095..81c303e 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberIntegrationTest.java @@ -8,8 +8,9 @@ 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.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -45,9 +46,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -55,6 +61,7 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); memberRepository.deleteAll(); } @@ -64,7 +71,7 @@ void setUp() { .studentNumber("20234321") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("김민재") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberWelcomeMailIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberWelcomeMailIntegrationTest.java index 206aa42..fdb9d2e 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberWelcomeMailIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberWelcomeMailIntegrationTest.java @@ -8,8 +8,9 @@ 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.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Instant; @@ -49,9 +50,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -61,12 +67,13 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() throws Exception { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); memberRepository.deleteAll(); Member admin = Member.builder() .studentNumber("20230001") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("관리자") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/AdminTrackIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/AdminTrackIntegrationTest.java new file mode 100644 index 0000000..9e0a75c --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/AdminTrackIntegrationTest.java @@ -0,0 +1,215 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +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.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 AdminTrackIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private TrackMaster backend; + private TrackMaster frontend; + private String adminToken; + private String memberToken; + + @BeforeEach + void setUp() throws Exception { + trackPageRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + frontend = trackMasterRepository.findByCode("FRONTEND").orElseThrow(); + + Member admin = memberRepository.save(newMember("20231111", "admin@bcsd.club")); + adminToken = login("20231111"); + memberRepository.updateRole(admin.getId(), MemberRole.ADMIN); + + memberRepository.save(newMember("20232222", "member@bcsd.club")); + memberToken = login("20232222"); + } + + @Test + @DisplayName("AC-6.1 토큰 없이 호출하면 401이다") + void 토큰_없이_호출하면_401() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages") + .contentType(MediaType.APPLICATION_JSON) + .content(trackPageCreateBody(backend.getId(), "Backend"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("AC-6.2 MEMBER 권한으로 호출하면 403이다") + void member_권한으로_호출하면_403() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages") + .header("Authorization", "Bearer " + memberToken) + .contentType(MediaType.APPLICATION_JSON) + .content(trackPageCreateBody(backend.getId(), "Backend"))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("AC-1.1 표시명에서 slug를 자동 생성한다") + void slug를_자동_생성한다() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(trackPageCreateBody(backend.getId(), "Data Analyst"))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.slug").value("data-analyst")) + .andExpect(jsonPath("$.displayName").value("Data Analyst")); + } + + @Test + @DisplayName("AC-1.2 중복 slug는 409다") + void 중복_slug는_409() throws Exception { + trackPageRepository.save(trackPage(backend, "backend", 0)); + + mockMvc.perform(post("/v1/admin/track-pages") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(trackPageCreateBody(frontend.getId(), "Backend"))) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("AC-1.4 순서 변경이 공개 목록에 즉시 반영된다") + void 순서_변경이_공개_목록에_즉시_반영된다() throws Exception { + TrackPage a = trackPageRepository.save(trackPage(backend, "backend", 0)); + TrackPage b = trackPageRepository.save(trackPage(frontend, "frontend", 1)); + + mockMvc.perform(patch("/v1/admin/track-pages/order") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\":[%d,%d]}".formatted(b.getId(), a.getId()))) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/tracks")) + .andExpect(jsonPath("$[0].slug").value("frontend")) + .andExpect(jsonPath("$[1].slug").value("backend")); + } + + @Test + @DisplayName("AC-1.5 id 집합이 다르면 400이고 아무것도 변경되지 않는다") + void id_집합이_다르면_아무것도_바뀌지_않는다() throws Exception { + TrackPage a = trackPageRepository.save(trackPage(backend, "backend", 0)); + trackPageRepository.save(trackPage(frontend, "frontend", 1)); + + mockMvc.perform(patch("/v1/admin/track-pages/order") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\":[%d]}".formatted(a.getId()))) + .andExpect(status().isBadRequest()); + + mockMvc.perform(get("/v1/tracks")) + .andExpect(jsonPath("$[0].slug").value("backend")) + .andExpect(jsonPath("$[1].slug").value("frontend")); + } + + @Test + @DisplayName("트랙 마스터 코드 중복 생성은 409, 이름·활성 여부 수정은 반영된다") + void 트랙_마스터_생성과_수정() throws Exception { + mockMvc.perform(post("/v1/admin/tracks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"code\":\"BACKEND\",\"name\":\"백엔드\"}")) + .andExpect(status().isConflict()); + + String body = mockMvc.perform(post("/v1/admin/tracks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"code\":\"BLOCKCHAIN\",\"name\":\"블록체인\"}")) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + long newId = objectMapper.readTree(body).get("id").asLong(); + + mockMvc.perform(put("/v1/admin/tracks/" + newId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"블록체인개발\",\"isActive\":false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("블록체인개발")) + .andExpect(jsonPath("$.isActive").value(false)); + + // track은 다른 테스트(TrackPromotionIntegrationTest)가 "시드된 11개뿐"이라고 가정하고 + // IntegrationTestSupport로 컨테이너를 공유한다 — 이 테스트가 만든 행은 직접 치운다. + trackMasterRepository.deleteById(newId); + } + + private Member newMember(String studentNumber, String email) { + return Member.builder() + .studentNumber(studentNumber) + .password(passwordEncoder.encode(RAW_PASSWORD)) + .name("테스트") + .track(backend) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email(email) + .status(MemberStatus.ACTIVE) + .build(); + } + + private String login(String studentNumber) throws Exception { + String body = mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentNumber\":\"%s\",\"password\":\"%s\",\"rememberMe\":false}" + .formatted(studentNumber, RAW_PASSWORD))) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(body).get("accessToken").asText(); + } + + private String trackPageCreateBody(Long trackId, String displayName) { + return "{\"trackId\":%d,\"displayName\":\"%s\",\"tagline\":\"tagline\"}".formatted(trackId, displayName); + } + + private TrackPage trackPage(TrackMaster track, String slug, int order) { + return TrackPage.builder() + .track(track) + .slug(slug) + .displayName(slug) + .tagline("tagline") + .displayOrder(order) + .published(true) + .build(); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/AdminTrackPageMemberIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/AdminTrackPageMemberIntegrationTest.java new file mode 100644 index 0000000..1724365 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/AdminTrackPageMemberIntegrationTest.java @@ -0,0 +1,199 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +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.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.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.TrackPageMemberRepository; +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 AdminTrackPageMemberIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackPageMemberRepository trackPageMemberRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private TrackMaster backend; + private TrackPage trackPage; + private String adminToken; + private Member alice; + private Member bob; + + @BeforeEach + void setUp() throws Exception { + trackPageRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + trackPage = trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).build()); + + Member admin = memberRepository.save(newMember("20231111", "admin@bcsd.club")); + adminToken = login("20231111"); + memberRepository.updateRole(admin.getId(), MemberRole.ADMIN); + + alice = memberRepository.save(newMember("20240001", "alice@bcsd.club")); + bob = memberRepository.save(newMember("20240002", "bob@bcsd.club")); + } + + @Test + @DisplayName("부원을 배정하면 목록에 이름·등급·프로필 사진과 함께 나타난다") + void 부원_배정과_목록_조회() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/members") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberIds\":[%d,%d]}".formatted(alice.getId(), bob.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].name").value(alice.getName())) + .andExpect(jsonPath("$[0].memberType").value("REGULAR")) + .andExpect(jsonPath("$[0].isVisible").value(true)); + + mockMvc.perform(get("/v1/admin/track-pages/" + trackPage.getId() + "/members") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + @DisplayName("INV-8 같은 부원을 두 번 배정하면 409다") + void 같은_부원_중복_배정은_409() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/members") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberIds\":[%d]}".formatted(alice.getId()))) + .andExpect(status().isOk()); + + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/members") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberIds\":[%d]}".formatted(alice.getId()))) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("배정을 해제하면 목록에서 사라진다") + void 배정_해제() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/members") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberIds\":[%d]}".formatted(alice.getId()))) + .andExpect(status().isOk()); + + mockMvc.perform(delete("/v1/admin/track-pages/" + trackPage.getId() + "/members/" + alice.getId()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/admin/track-pages/" + trackPage.getId() + "/members") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + @DisplayName("숨김으로 전환하면 배정은 유지되고 isVisible만 바뀐다") + void 숨김_전환() throws Exception { + mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/members") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberIds\":[%d]}".formatted(alice.getId()))) + .andExpect(status().isOk()); + + mockMvc.perform(patch("/v1/admin/track-pages/" + trackPage.getId() + "/members/" + alice.getId() + + "/visibility") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"isVisible\":false}")) + .andExpect(status().isNoContent()); + + assertThat(trackPageMemberRepository + .findByTrackPage_IdAndMember_Id(trackPage.getId(), alice.getId()).orElseThrow().isVisible()) + .isFalse(); + } + + @Test + @DisplayName("순서를 바꾸면 목록 응답 순서가 바뀐다") + void 순서_변경() throws Exception { + String body = mockMvc.perform(post("/v1/admin/track-pages/" + trackPage.getId() + "/members") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberIds\":[%d,%d]}".formatted(alice.getId(), bob.getId()))) + .andReturn().getResponse().getContentAsString(); + long aliceAssignmentId = objectMapper.readTree(body).get(0).get("id").asLong(); + long bobAssignmentId = objectMapper.readTree(body).get(1).get("id").asLong(); + + mockMvc.perform(patch("/v1/admin/track-pages/" + trackPage.getId() + "/members/order") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\":[%d,%d]}".formatted(bobAssignmentId, aliceAssignmentId))) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/admin/track-pages/" + trackPage.getId() + "/members") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$[0].name").value(bob.getName())) + .andExpect(jsonPath("$[1].name").value(alice.getName())); + } + + private Member newMember(String studentNumber, String email) { + return Member.builder() + .studentNumber(studentNumber) + .password(passwordEncoder.encode(RAW_PASSWORD)) + .name("테스트" + studentNumber) + .track(backend) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email(email) + .status(MemberStatus.ACTIVE) + .build(); + } + + private String login(String studentNumber) throws Exception { + String body = mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentNumber\":\"%s\",\"password\":\"%s\",\"rememberMe\":false}" + .formatted(studentNumber, RAW_PASSWORD))) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(body).get("accessToken").asText(); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java new file mode 100644 index 0000000..2fda6b1 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java @@ -0,0 +1,181 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +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.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * T-05(V9__create_homepage_content.sql)의 제약 조건이 설계대로 동작하는지 검증한다. + * 아직 JPA 엔티티가 없으므로(T-06 이후 도메인 티켓의 몫) raw SQL로 직접 검증한다 — + * partial unique index, CHECK 제약, ON DELETE CASCADE처럼 오타 하나로도 조용히 깨지는 + * DDL을 엔티티가 생기기 전에 먼저 굳혀 둔다. + */ +class HomepageContentSchemaIntegrationTest extends IntegrationTestSupport { + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long frontendTrackId; + private Long backendTrackId; + + @BeforeEach + void setUp() { + jdbcTemplate.update("delete from activity"); + jdbcTemplate.update("delete from activity_category"); + jdbcTemplate.update("delete from curriculum"); + jdbcTemplate.update("delete from track_page"); + jdbcTemplate.update("delete from image_asset"); + + frontendTrackId = jdbcTemplate.queryForObject( + "select id from track where code = 'FRONTEND'", Long.class); + backendTrackId = jdbcTemplate.queryForObject( + "select id from track where code = 'BACKEND'", Long.class); + } + + @Test + @DisplayName("INV-1 slug는 삭제되지 않은 track_page끼리만 유일하다") + void track_page_slug는_삭제되지_않은_행끼리만_유일하다() { + insertTrackPage(frontendTrackId, "frontend"); + + assertThatThrownBy(() -> insertTrackPage(backendTrackId, "frontend")) + .isInstanceOf(DataIntegrityViolationException.class); + + jdbcTemplate.update("update track_page set deleted_at = now() where slug = 'frontend'"); + + // 예외를 던지지 않으면 성공 + insertTrackPage(backendTrackId, "frontend"); + } + + @Test + @DisplayName("INV-2 한 트랙에 공개 커리큘럼 세트는 하나만 허용한다") + void 공개_커리큘럼은_트랙당_하나만_허용한다() { + Long trackPageId = insertTrackPage(frontendTrackId, "frontend"); + insertCurriculum(trackPageId, "비기너", true); + + assertThatThrownBy(() -> insertCurriculum(trackPageId, "심화", true)) + .isInstanceOf(DataIntegrityViolationException.class); + + // 공개가 아니면 여러 개 허용 + insertCurriculum(trackPageId, "초안", false); + } + + @Test + @DisplayName("INV-5 week_to가 week_from보다 작으면 거부된다") + void 주차_범위가_거꾸로면_거부된다() { + Long trackPageId = insertTrackPage(frontendTrackId, "frontend"); + Long curriculumId = insertCurriculum(trackPageId, "비기너", true); + + assertThatThrownBy(() -> jdbcTemplate.update( + "insert into curriculum_week (curriculum_id, week_from, week_to, display_order) " + + "values (?, 5, 3, 0)", curriculumId)) + .isInstanceOf(DataIntegrityViolationException.class); + + jdbcTemplate.update( + "insert into curriculum_week (curriculum_id, week_from, week_to, display_order) " + + "values (?, 13, 16, 0)", curriculumId); + jdbcTemplate.update( + "insert into curriculum_week (curriculum_id, week_from, week_to, display_order) " + + "values (?, 4, null, 1)", curriculumId); + } + + @Test + @DisplayName("INV-6 활동의 month/year 범위를 벗어나면 거부된다") + void 활동_연월_범위를_벗어나면_거부된다() { + Long categoryId = insertActivityCategory("event", "EVENT"); + + assertThatThrownBy(() -> insertActivity(categoryId, 2019, 13)) + .isInstanceOf(DataIntegrityViolationException.class); + assertThatThrownBy(() -> insertActivity(categoryId, 1999, 5)) + .isInstanceOf(DataIntegrityViolationException.class); + + insertActivity(categoryId, 2019, 5); + } + + @Test + @DisplayName("INV-7 커리큘럼을 삭제하면 주차·토픽·세부항목이 cascade로 함께 삭제된다") + void 커리큘럼_삭제는_하위_트리에_cascade된다() { + Long trackPageId = insertTrackPage(frontendTrackId, "frontend"); + Long curriculumId = insertCurriculum(trackPageId, "비기너", true); + Long weekId = jdbcTemplate.queryForObject( + "insert into curriculum_week (curriculum_id, week_from, display_order) " + + "values (?, 1, 0) returning id", + Long.class, curriculumId); + Long topicId = jdbcTemplate.queryForObject( + "insert into curriculum_topic (week_id, title, display_order) values (?, '웹 동작 방식', 0) " + + "returning id", + Long.class, weekId); + jdbcTemplate.update( + "insert into curriculum_topic_detail (topic_id, content, display_order) values (?, 'HTTP', 0)", + topicId); + + jdbcTemplate.update("delete from curriculum where id = ?", curriculumId); + + assertThat(jdbcTemplate.queryForObject( + "select count(*) from curriculum_week where id = ?", Integer.class, weekId)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from curriculum_topic where id = ?", Integer.class, topicId)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from curriculum_topic_detail where topic_id = ?", Integer.class, topicId)) + .isZero(); + } + + @Test + @DisplayName("INV-7 활동을 삭제하면 사진도 cascade로 함께 삭제된다") + void 활동_삭제는_사진에_cascade된다() { + Long categoryId = insertActivityCategory("event", "EVENT"); + Long activityId = insertActivity(categoryId, 2019, 5); + jdbcTemplate.update( + "insert into activity_image (activity_id, image_url, display_order) values (?, 'https://x/1.png', 0)", + activityId); + + jdbcTemplate.update("delete from activity where id = ?", activityId); + + assertThat(jdbcTemplate.queryForObject( + "select count(*) from activity_image where activity_id = ?", Integer.class, activityId)) + .isZero(); + } + + @Test + @DisplayName("카테고리에 활동이 남아 있으면 FK 제약으로 삭제가 거부된다 (AC-3.8의 DB 쪽 근거)") + void 활동이_남은_카테고리는_삭제할_수_없다() { + Long categoryId = insertActivityCategory("event", "EVENT"); + insertActivity(categoryId, 2019, 5); + + assertThatThrownBy(() -> jdbcTemplate.update("delete from activity_category where id = ?", categoryId)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private Long insertTrackPage(Long trackId, String slug) { + return jdbcTemplate.queryForObject( + "insert into track_page (track_id, slug, display_name, tagline, display_order) " + + "values (?, ?, ?, 'tagline', 0) returning id", + Long.class, trackId, slug, slug); + } + + private Long insertCurriculum(Long trackPageId, String name, boolean published) { + return jdbcTemplate.queryForObject( + "insert into curriculum (track_page_id, name, is_published, display_order) " + + "values (?, ?, ?, 0) returning id", + Long.class, trackPageId, name, published); + } + + private Long insertActivityCategory(String slug, String name) { + return jdbcTemplate.queryForObject( + "insert into activity_category (slug, name, display_order) values (?, ?, 0) returning id", + Long.class, slug, name); + } + + private Long insertActivity(Long categoryId, int year, int month) { + return jdbcTemplate.queryForObject( + "insert into activity (category_id, year, month, title, summary, display_order) " + + "values (?, ?, ?, 't', 's', 0) returning id", + Long.class, categoryId, year, month); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TechStackIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TechStackIntegrationTest.java new file mode 100644 index 0000000..0cd2935 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TechStackIntegrationTest.java @@ -0,0 +1,174 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.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.TechStack; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TechStackRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageTechStackRepository; +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 TechStackIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private TechStackRepository techStackRepository; + + @Autowired + private TrackPageTechStackRepository trackPageTechStackRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private TrackPage trackPage; + private TechStack react; + private TechStack java; + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + trackPageRepository.deleteAll(); + techStackRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + trackPage = trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).build()); + react = techStackRepository.save(TechStack.builder().name("React").iconUrl("https://x/react.svg").build()); + java = techStackRepository.save(TechStack.builder().name("Java").iconUrl("https://x/java.svg").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("기술스택 마스터 생성 및 이름 중복 409") + void 기술스택_마스터_생성() throws Exception { + mockMvc.perform(post("/v1/admin/tech-stacks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Vite\",\"iconUrl\":\"https://x/vite.svg\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Vite")); + + mockMvc.perform(post("/v1/admin/tech-stacks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"React\",\"iconUrl\":\"https://x/react-dup.svg\"}")) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("트랙 페이지에 기술스택을 순서대로 부착하고 순서를 바꾸면 반영된다") + void 기술스택을_전체_교체한다() throws Exception { + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/tech-stacks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"techStackIds\":[%d,%d]}".formatted(react.getId(), java.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("React")) + .andExpect(jsonPath("$[1].name").value("Java")); + + // 순서를 뒤집어 다시 교체하면 이전 부착이 완전히 대체된다 + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/tech-stacks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"techStackIds\":[%d]}".formatted(java.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].name").value("Java")); + + var attached = trackPageTechStackRepository.findAllByTrackPageIdOrderByDisplayOrderAsc(trackPage.getId()); + assertThat(attached).hasSize(1); + assertThat(attached.get(0).getTechStack().getName()).isEqualTo("Java"); + } + + @Test + @DisplayName("존재하지 않는 기술스택 id를 보내면 404이고 기존 부착이 유지된다") + void 존재하지_않는_id는_404() throws Exception { + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/tech-stacks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"techStackIds\":[%d]}".formatted(react.getId()))) + .andExpect(status().isOk()); + + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/tech-stacks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"techStackIds\":[999999]}")) + .andExpect(status().isNotFound()); + + var attached = trackPageTechStackRepository.findAllByTrackPageIdOrderByDisplayOrderAsc(trackPage.getId()); + assertThat(attached).hasSize(1); + assertThat(attached.get(0).getTechStack().getName()).isEqualTo("React"); + } + + @Test + @DisplayName("공개 트랙 상세 응답에 techStacks가 순서대로 포함된다") + void 공개_응답에_techStacks가_포함된다() throws Exception { + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/tech-stacks") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"techStackIds\":[%d,%d]}".formatted(java.getId(), react.getId()))) + .andExpect(status().isOk()); + + mockMvc.perform(get("/v1/tracks/backend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.techStacks[0].name").value("Java")) + .andExpect(jsonPath("$.techStacks[1].name").value("React")); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackIntegrationTest.java new file mode 100644 index 0000000..2c1f860 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackIntegrationTest.java @@ -0,0 +1,149 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.member.model.Member; +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.model.TrackPageMember; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageMemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +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; + +class TrackIntegrationTest extends IntegrationTestSupport { + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private TrackPageMemberRepository trackPageMemberRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + private TrackMaster frontend; + private TrackMaster backend; + private TrackMaster pm; + + @BeforeEach + void setUp() { + trackPageRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + frontend = trackMasterRepository.findByCode("FRONTEND").orElseThrow(); + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + pm = trackMasterRepository.findByCode("PM").orElseThrow(); + } + + @Test + @DisplayName("AC-1.3 공개 목록은 공개된 트랙만 display_order 순으로 반환한다") + void 공개_목록은_display_order_순이고_숨김_트랙은_제외한다() throws Exception { + // uq_track_page_track은 track_id 단위 partial unique index라, 활성 상태(미삭제)인 + // track_page는 트랙마다 하나뿐이어야 한다 — 숨김 케이스는 별도 트랙 마스터(pm)를 쓴다. + trackPageRepository.save(trackPage(backend, "backend", 1, true)); + trackPageRepository.save(trackPage(frontend, "frontend", 0, true)); + trackPageRepository.save(trackPage(pm, "hidden", 2, false)); + + mockMvc.perform(get("/v1/tracks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].slug").value("frontend")) + .andExpect(jsonPath("$[1].slug").value("backend")); + } + + @Test + @DisplayName("공개 트랙 상세는 인증 없이 200과 헤더 필드를 반환한다") + void 공개_트랙_상세는_인증_없이_조회된다() throws Exception { + trackPageRepository.save(TrackPage.builder() + .track(frontend) + .slug("frontend") + .displayName("Frontend") + .tagline("사용자 인터페이스를 구축합니다.") + .heroImageUrl("https://image.bcsdlab.com/hero.png") + .displayOrder(0) + .published(true) + .build()); + + mockMvc.perform(get("/v1/tracks/frontend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.slug").value("frontend")) + .andExpect(jsonPath("$.name").value("Frontend")) + .andExpect(jsonPath("$.tagline").value("사용자 인터페이스를 구축합니다.")) + .andExpect(jsonPath("$.heroImageUrl").value("https://image.bcsdlab.com/hero.png")); + } + + @Test + @DisplayName("INV-11 숨김 트랙은 slug를 알아도 404다 (존재하지 않는 slug와 구분되지 않는다)") + void 숨김_트랙은_404다() throws Exception { + trackPageRepository.save(trackPage(frontend, "hidden-track", 0, false)); + + mockMvc.perform(get("/v1/tracks/hidden-track")).andExpect(status().isNotFound()); + mockMvc.perform(get("/v1/tracks/no-such-slug")).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("공개 트랙 상세는 숨긴 부원과 비활성 부원을 제외하고 순서대로 반환한다") + void 공개_상세는_숨김과_비활성_부원을_제외한다() throws Exception { + TrackPage trackPage = trackPageRepository.save(TrackPage.builder() + .track(frontend).slug("frontend").displayName("Frontend").tagline("tagline") + .displayOrder(0).published(true).build()); + + Member visible = memberRepository.save(newMember("20240001", "m1@bcsd.club", MemberStatus.ACTIVE)); + Member hidden = memberRepository.save(newMember("20240002", "m2@bcsd.club", MemberStatus.ACTIVE)); + Member withdrawn = memberRepository.save(newMember("20240003", "m3@bcsd.club", MemberStatus.WITHDRAWN)); + + trackPageMemberRepository.save(TrackPageMember.builder() + .trackPage(trackPage).member(hidden).displayOrder(0).visible(false).build()); + trackPageMemberRepository.save(TrackPageMember.builder() + .trackPage(trackPage).member(visible).displayOrder(1).visible(true).build()); + trackPageMemberRepository.save(TrackPageMember.builder() + .trackPage(trackPage).member(withdrawn).displayOrder(2).visible(true).build()); + + mockMvc.perform(get("/v1/tracks/frontend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members.length()").value(1)) + .andExpect(jsonPath("$.members[0].name").value(visible.getName())); + } + + private Member newMember(String studentNumber, String email, MemberStatus status) { + return Member.builder() + .studentNumber(studentNumber) + .password("{noop}password") + .name("테스트" + studentNumber) + .track(backend) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email(email) + .status(status) + .build(); + } + + private TrackPage trackPage(TrackMaster track, String slug, int order, boolean published) { + return TrackPage.builder() + .track(track) + .slug(slug) + .displayName(slug) + .tagline("tagline") + .displayOrder(order) + .published(published) + .build(); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackPromotionIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackPromotionIntegrationTest.java new file mode 100644 index 0000000..e046bc7 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackPromotionIntegrationTest.java @@ -0,0 +1,121 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +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.repository.TrackMasterRepository; +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.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * T-35(member.track enum → 참조 테이블 승격)의 V7/V8 마이그레이션 결과를 검증한다. + * {@link IntegrationTestSupport}의 두 번째 소비자다. + */ +class TrackPromotionIntegrationTest extends IntegrationTestSupport { + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + // IntegrationTestSupport로 컨테이너/컨텍스트를 공유하는 다른 테스트(로그인을 호출하는 클래스)가 + // 남긴 refresh_token이 있으면 member 삭제가 FK 위반으로 실패한다 — 항상 먼저 지운다. + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + } + + @Test + @DisplayName("AC-8.1 track 테이블에 기존 10개 + SECURITY 총 11행이 시드된다") + void 트랙_11개가_시드된다() { + assertThat(trackMasterRepository.count()).isEqualTo(11); + assertThat(trackMasterRepository.findByCode("SECURITY")).isPresent(); + } + + @Test + @DisplayName("AC-8.2 새로 생성한 부원의 track_id는 null이 아니다") + void 신규_부원의_track_id는_null이_아니다() { + var backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + Member member = memberRepository.save(newMember(backend)); + + Integer nullCount = jdbcTemplate.queryForObject( + "select count(*) from member where id = ? and track_id is null", + Integer.class, member.getId()); + + assertThat(nullCount).isZero(); + } + + @Test + @DisplayName("AC-8.3 V8 백필 결과가 원본 문자열 값과 정확히 일치한다") + void 백필된_track_id가_레거시_문자열과_일치한다() { + // 기존 통합 테스트들이 이미 여러 부원을 만들어 지나갔을 수 있으므로, 여기서 직접 하나 만들어 + // "이 마이그레이션이 실제로 무엇을 했는지"를 이 테스트 스스로 검증한다. + var backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + memberRepository.save(newMember(backend)); + + Integer mismatchCount = jdbcTemplate.queryForObject( + "select count(*) from member m join track t on t.id = m.track_id where t.code <> m.track", + Integer.class); + + assertThat(mismatchCount).isZero(); + } + + @Test + @DisplayName("AC-8.4 레거시 track 컬럼은 NOT NULL이 풀려 있다 (신버전 INSERT가 그 컬럼을 채우지 않아도 된다)") + void 레거시_track_컬럼은_not_null이_아니다() { + Integer nullableCount = jdbcTemplate.queryForObject( + "select count(*) from information_schema.columns " + + "where table_name = 'member' and column_name = 'track' and is_nullable = 'YES'", + Integer.class); + + assertThat(nullableCount).isEqualTo(1); + } + + @Test + @DisplayName("AC-8.4 caveat: track_id 없이 넣는 구버전 스타일 INSERT는 이제 실패한다 " + + "(쓰기 경로는 신버전 배포 후에만 안전하다 — 읽기 전용 하위호환)") + void track_id_없는_구버전_스타일_insert는_실패한다() { + assertThatThrownBy(() -> jdbcTemplate.update( + "insert into member (student_number, password, name, track, generation, member_type, " + + "university, email, status, role) " + + "values ('99999999', 'x', '레거시부원', 'BACKEND', '24-하', 'REGULAR', " + + "'OO대학교', 'legacy-style@bcsd.club', 'PENDING_SETUP', 'MEMBER')")) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private Member newMember(com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster track) { + return Member.builder() + .studentNumber("2023" + (int) (Math.random() * 9000 + 1000)) + .password(passwordEncoder.encode("Temp1234")) + .name("트랙승격테스트") + .track(track) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email("track-promotion-" + System.nanoTime() + "@bcsd.club") + .status(MemberStatus.ACTIVE) + .build(); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackStudyPointIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackStudyPointIntegrationTest.java new file mode 100644 index 0000000..1639ed0 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackStudyPointIntegrationTest.java @@ -0,0 +1,181 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.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.bcsdlab.bcsdinternalapiv2.track.repository.TrackStudyPointRepository; +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 TrackStudyPointIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private TrackStudyPointRepository trackStudyPointRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private TrackPage trackPage; + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + trackPageRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + trackPage = trackPageRepository.save(TrackPage.builder() + .track(backend).slug("backend").displayName("Backend").tagline("tagline") + .displayOrder(0).published(true).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-1.7 전체 교체 후 display_order가 0부터 배열 순서대로 재부여된다") + void 전체_교체_후_순서가_배열_순서대로_재부여된다() throws Exception { + String requestBody = """ + {"studyPoints":[ + {"title":"확장성 있는 엔지니어링","description":"React 컴포넌트 설계"}, + {"title":"사용자 경험","description":"접근성과 성능"} + ]}"""; + + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/study-points") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].title").value("확장성 있는 엔지니어링")) + .andExpect(jsonPath("$[1].title").value("사용자 경험")); + + var saved = trackStudyPointRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPage.getId()); + assertThat(saved).hasSize(2); + assertThat(saved.get(0).getDisplayOrder()).isZero(); + assertThat(saved.get(1).getDisplayOrder()).isEqualTo(1); + } + + @Test + @DisplayName("전체 교체는 기존 카드를 대체한다 (덧붙이지 않는다)") + void 전체_교체는_기존_카드를_대체한다() throws Exception { + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/study-points") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studyPoints\":[{\"title\":\"A\",\"description\":\"a\"}]}")) + .andExpect(status().isOk()); + + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/study-points") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studyPoints\":[{\"title\":\"B\",\"description\":\"b\"}]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].title").value("B")); + } + + @Test + @DisplayName("AC-1.6 5개를 보내면 400이고 아무것도 저장되지 않는다") + void 다섯개를_보내면_400() throws Exception { + String fiveItems = """ + {"studyPoints":[ + {"title":"1","description":"d"},{"title":"2","description":"d"}, + {"title":"3","description":"d"},{"title":"4","description":"d"}, + {"title":"5","description":"d"} + ]}"""; + + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/study-points") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(fiveItems)) + .andExpect(status().isBadRequest()); + + assertThat( + trackStudyPointRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPage.getId())).isEmpty(); + } + + @Test + @DisplayName("빈 배열이면 카드를 전부 지운다") + void 빈_배열이면_전부_지운다() throws Exception { + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/study-points") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studyPoints\":[{\"title\":\"A\",\"description\":\"a\"}]}")) + .andExpect(status().isOk()); + + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/study-points") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studyPoints\":[]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + @DisplayName("공개 트랙 상세 응답에 studyPoints가 순서대로 포함된다") + void 공개_응답에_studyPoints가_포함된다() throws Exception { + mockMvc.perform(put("/v1/admin/track-pages/" + trackPage.getId() + "/study-points") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studyPoints\":[{\"title\":\"A\",\"description\":\"a\"}]}")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/v1/tracks/backend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.studyPoints[0].title").value("A")) + .andExpect(jsonPath("$.studyPoints[0].description").value("a")); + } +}