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/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..d4abe5d --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java @@ -0,0 +1,52 @@ +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, "존재하지 않는 활동입니다."), + ; + + 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/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..5bf398e --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java @@ -0,0 +1,52 @@ +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, "존재하지 않는 커리큘럼입니다."), + ; + + 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/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..8557ef5 --- /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) { +} 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 {@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/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/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