diff --git a/README.md b/README.md new file mode 100644 index 00000000..1b67d24c --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# SPOT + +## Summary + +- 스터디 탐색, 운영, 커뮤니티 기능을 하나의 서비스로 묶은 멀티 모듈 백엔드 프로젝트 +- `Java 21`, `Spring Boot`, `JPA`, `QueryDSL`, `MySQL`, `Redis`, `AWS S3` +- 모듈러 모놀리식 구조 기반으로 도메인 경계를 분리하고, 이벤트 기반 후처리와 배치 구조를 함께 운영 +- 백엔드 개발 및 인프라 운영 전반 담당 + +--- + +## What I Did + +- 대규모 데이터 환경에서 스터디 검색 API의 병목을 분석하고, 데이터 구조와 인덱스 설계를 조정해 응답 시간을 큰 폭으로 단축 +- 조회수와 좋아요를 같은 통계성 데이터로 보지 않고, 쓰기 패턴 차이에 맞춰 Redis Write-Behind와 RDB 즉시 반영 전략으로 분리 +- 외부 알림 호출이 비즈니스 API 안정성에 영향을 주지 않도록 트랜잭셔널 아웃박스 기반 비동기 발송 구조 설계 +- OAuth 로그인 로직에 전략 패턴을 적용해 소셜 로그인 타입별 구현을 분리하고, 공통 처리 흐름을 재구성 + +--- + +## Highlights + +- 검색 성능 개선: `10,986ms → 2.86ms` +- 대규모 조건 검색 환경에서 `300 RPS` 수준 처리 +- 조회수 반영 구조 개선으로 DB 부하 대폭 감소 +- 좋아요 처리에서 정합성을 유지하면서 동시성 처리 성능 개선 +- 알림 시스템을 비동기화해 외부 장애 전파 차단 +- 확장 가능한 로그인 구조로 유지보수성과 테스트 용이성 개선 + +--- + +## Architecture + +- `Modular Monolith` +- 도메인별 모듈 분리: `auth`, `member`, `study`, `post`, `notification`, `region` +- 모듈 간 계약은 `*-api` 모듈로 분리 +- Redis 기반 캐시/집계/쓰기 완충 +- 스케줄러와 이벤트 리스너를 활용한 비동기 후처리 + +--- + +## Role & Contribution + +- 백엔드 설계와 구현 전반 담당 +- 성능 병목 분석, 데이터 구조 개선, 동시성 처리 전략 수립 주도 +- Redis, 배치, 비동기 이벤트 기반 운영 구조 설계 및 적용 +- 인증, 검색, 알림, 통계성 데이터 처리까지 핵심 기능 직접 구현 diff --git a/app/boot/build.gradle b/app/boot/build.gradle index ff5eee51..d27cc9c5 100644 --- a/app/boot/build.gradle +++ b/app/boot/build.gradle @@ -18,6 +18,7 @@ dependencies { implementation project(":modules:member") implementation project(":modules:post") implementation project(":modules:region") + implementation project(":modules:notification") implementation project(":modules:member-api") implementation project(":modules:auth-api") diff --git a/app/config/src/main/java/kr/spot/config/AsyncConfig.java b/app/config/src/main/java/kr/spot/config/AsyncConfig.java index 61f8e78f..67139e42 100644 --- a/app/config/src/main/java/kr/spot/config/AsyncConfig.java +++ b/app/config/src/main/java/kr/spot/config/AsyncConfig.java @@ -20,4 +20,15 @@ public Executor imageUploadExecutor() { executor.initialize(); return executor; } + + @Bean(name = "notificationExecutor") + public Executor notificationExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(20); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("notification-"); + executor.initialize(); + return executor; + } } diff --git a/app/config/src/main/java/kr/spot/config/JacksonConfig.java b/app/config/src/main/java/kr/spot/config/JacksonConfig.java new file mode 100644 index 00000000..d7c0feaa --- /dev/null +++ b/app/config/src/main/java/kr/spot/config/JacksonConfig.java @@ -0,0 +1,17 @@ +package kr.spot.config; + +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { + return builder -> { + builder.serializerByType(Long.class, ToStringSerializer.instance); + }; + } +} diff --git a/build.gradle b/build.gradle index 4dbcd097..f6403fe1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ plugins { id "io.spring.dependency-management" version "1.1.6" apply false id "org.springframework.boot" version "3.3.2" apply false + id "jacoco" } allprojects { @@ -12,6 +13,7 @@ allprojects { subprojects { apply plugin: "java" apply plugin: "io.spring.dependency-management" + apply plugin: "jacoco" dependencyManagement { imports { mavenBom "org.springframework.boot:spring-boot-dependencies:3.3.2" } @@ -23,7 +25,34 @@ subprojects { options.compilerArgs += "-parameters" } - tasks.test { useJUnitPlatform() } + tasks.test { + useJUnitPlatform() + finalizedBy jacocoTestReport + } + + jacoco { + toolVersion = "0.8.12" + } + + jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, includes: [ + '**/domain/**', + '**/application/**' + ], excludes: [ + '**/infrastructure/**', + '**/presentation/**', + '**/config/**' + ]) + })) + } + } dependencies { compileOnly "org.projectlombok:lombok" @@ -31,4 +60,31 @@ subprojects { testCompileOnly "org.projectlombok:lombok" testAnnotationProcessor "org.projectlombok:lombok" } +} + +// 전체 프로젝트 통합 커버리지 리포트 +tasks.register('jacocoRootReport', JacocoReport) { + dependsOn subprojects.findAll { it.plugins.hasPlugin('jacoco') }.collect { it.tasks.named('test') } + + additionalSourceDirs.from(subprojects.findAll { it.plugins.hasPlugin('java') }.collect { it.sourceSets.main.allSource.srcDirs }) + sourceDirectories.from(subprojects.findAll { it.plugins.hasPlugin('java') }.collect { it.sourceSets.main.allSource.srcDirs }) + + def allClassDirs = subprojects.findAll { it.plugins.hasPlugin('java') }.collect { it.sourceSets.main.output } + classDirectories.from(files(allClassDirs).asFileTree.matching { + include '**/domain/**' + include '**/application/**' + exclude '**/infrastructure/**' + exclude '**/presentation/**' + exclude '**/config/**' + }) + + executionData.from(subprojects.findAll { it.plugins.hasPlugin('jacoco') }.collect { + it.tasks.named('test').map { task -> task.extensions.getByType(JacocoTaskExtension).destinationFile } + }) + + reports { + xml.required = true + html.required = true + html.outputLocation = layout.buildDirectory.dir('reports/jacoco/aggregated') + } } \ No newline at end of file diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index 9584a90d..3a9c4477 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -32,6 +32,7 @@ public enum ErrorStatus implements BaseErrorCode { _MEMBER_UNSUPPORTED_LOGIN_TYPE(400, "MEMBER4003", "지원하지 않는 로그인 타입입니다."), _MEMBER_EMAIL_EXIST(400, "MEMBER4004", "이미 해당 방식으로 가입 내역이 존재하는 이메일입니다."), _FAIL_TO_UPDATE_NAME(400, "MEMBER4005", "이름 변경에 실패했습니다."), + _CANNOT_WITHDRAW_WITH_ACTIVE_STUDY(400, "MEMBER4006", "운영 중인 스터디가 있어 탈퇴할 수 없습니다."), // 스터디 관련 _MAX_MEMBERS_MUST_BE_POSITIVE(400, "STUDY4000", "최대 인원 수는 양수여야 합니다."), @@ -48,11 +49,20 @@ public enum ErrorStatus implements BaseErrorCode { "셀프 승인을 위한 유효하지 않은 스터디 멤버 상태입니다."), _STUDY_ALREADY_APPLIED(400, "STUDY4006", "이미 해당 스터디에 지원한 상태입니다."), _STUDY_ACCESS_DENIED(403, "STUDY4032", "스터디에 접근할 권한이 없습니다."), + _INVALID_STUDY_ACCESS(403, "STUDY4033", "유효하지 않은 스터디 접근입니다."), + _STUDY_IS_FULL(400, "STUDY4007", "스터디 정원이 가득 찼습니다."), + _NEXT_OWNER_ID_REQUIRED_FOR_OWNER_WITHDRAWAL(400, "STUDY4008", + "스터디장 탈퇴 시 다음 스터디장 ID가 필요합니다."), + _NEXT_OWNER_NOT_EXIST_IN_STUDY(400, "STUDY4009", + "다음 스터디장이 스터디 멤버에 존재하지 않습니다."), + _CANNOT_DELETE_STUDY_WITH_MEMBERS(400, "STUDY4010", + "스터디 멤버가 있는 상태에서는 스터디를 삭제할 수 없습니다."), // 게시글 관련 _POST_NOT_FOUND(404, "POST404", "게시글을 찾을 수 없습니다."), _COMMENT_NOT_FOUND(404, "COMMENT404", "댓글을 찾을 수 없습니다."), _ONLY_AUTHOR_CAN_MODIFY(403, "POST403", "게시글 및 댓글 수정은 작성자만 가능합니다."), + _PRIVATE_POST_ACCESS_DENIED(403, "POST4030", "스터디원 전용 게시글입니다."), _ALREADY_LIKED(400, "POST4000", "이미 좋아요를 누른 게시글입니다."), _ALREADY_UNLIKED(400, "POST4001", "좋아요를 누르지 않은 게시글입니다."), _SORT_TYPE_INVALID(400, "POST4002", "유효하지 않은 정렬 타입입니다."), @@ -60,7 +70,38 @@ public enum ErrorStatus implements BaseErrorCode { // 지역 관련 _REGION_NOT_FOUND(404, "REGION404", "지역을 찾을 수 없습니다."), _NO_SUCH_REGION(400, "REGION4000", "존재하지 않는 지역 코드입니다."), - ; + + // 일정 관련 + _SCHEDULE_NOT_FOUND(404, "SCHEDULE404", "일정을 찾을 수 없습니다."), + _SCHEDULE_ACCESS_DENIED(403, "SCHEDULE403", "해당 스터디에 속하는 일정이 아닙니다."), + _SCHEDULE_TIME_CONFLICT(400, "SCHEDULE4001", "동일한 시간대에 이미 일정이 존재합니다."), + _SCHEDULE_QR_CODE_ALREADY_ASSIGNED(400, "SCHEDULE4000", "이미 출석 QR 코드가 할당된 일정입니다."), + + // 출석 관련 + _ATTENDANCE_NOT_IN_SCHEDULE_TIME(400, "ATTENDANCE4000", "일정 시간 외에는 출석체크를 진행할 수 없습니다."), + _ATTENDANCE_NOT_STARTED(400, "ATTENDANCE4001", "출석체크가 시작되지 않았습니다."), + _ATTENDANCE_ALREADY_CHECKED(400, "ATTENDANCE4002", "이미 출석체크를 완료했습니다."), + _INVALID_ATTENDANCE_TOKEN(400, "ATTENDANCE4003", "유효하지 않은 출석 토큰입니다."), + + // 투두 관련 + _TODO_NOT_FOUND(404, "TODO404", "투두를 찾을 수 없습니다."), + _TODO_ACCESS_DENIED(403, "TODO403", "해당 스터디에 속하는 투두가 아닙니다."), + _ONLY_TODO_OWNER_CAN_MODIFY(403, "TODO4030", "투두 수정은 작성자만 가능합니다."), + + // 회고 관련 + _REVIEW_NOT_FOUND(404, "REVIEW404", "회고를 찾을 수 없습니다."), + _REVIEW_ACCESS_DENIED(403, "REVIEW403", "해당 스터디에 속하는 회고가 아닙니다."), + _ALREADY_REACTED(400, "REVIEW4000", "이미 반응을 누른 회고입니다."), + _REACTION_NOT_FOUND(404, "REVIEW4040", "반응을 찾을 수 없습니다."), + + // 알림 관련 + _NOTIFICATION_NOT_FOUND(404, "NOTIFICATION404", "알림을 찾을 수 없습니다."), + _INVALID_NOTIFICATION_TYPE(400, "NOTIFICATION4000", "유효하지 않은 알림 유형입니다."), + _NOTIFICATION_PAYLOAD_MISSING_MEMBER_ID(400, "NOTIFICATION4001", "알림 페이로드에 memberId가 필요합니다."), + _NOTIFICATION_PAYLOAD_MISSING_STUDY_ID(400, "NOTIFICATION4002", "알림 페이로드에 studyId가 필요합니다."), + _NOTIFICATION_TEMPLATE_NOT_FOUND(500, "NOTIFICATION5000", "알림 템플릿을 찾을 수 없습니다."), + _PUSH_NOTIFICATION_FAILED(500, "NOTIFICATION5001", "푸시 알림 발송에 실패했습니다."), + _NOTIFICATION_ACCESS_DENIED(403, "NOTIFICATION403", "해당 알림에 접근할 권한이 없습니다."); private final int httpStatus; private final String code; diff --git a/common/view/build.gradle b/common/view/build.gradle new file mode 100644 index 00000000..7d82dc72 --- /dev/null +++ b/common/view/build.gradle @@ -0,0 +1,2 @@ +dependencies { +} diff --git a/common/view/src/main/java/kr/spot/view/ViewAbuseGuard.java b/common/view/src/main/java/kr/spot/view/ViewAbuseGuard.java new file mode 100644 index 00000000..5f872bc6 --- /dev/null +++ b/common/view/src/main/java/kr/spot/view/ViewAbuseGuard.java @@ -0,0 +1,6 @@ +package kr.spot.view; + +public interface ViewAbuseGuard { + + boolean shouldCount(ViewableType type, long targetId, long viewerId); +} diff --git a/common/view/src/main/java/kr/spot/view/ViewCounter.java b/common/view/src/main/java/kr/spot/view/ViewCounter.java new file mode 100644 index 00000000..5ffe9aeb --- /dev/null +++ b/common/view/src/main/java/kr/spot/view/ViewCounter.java @@ -0,0 +1,8 @@ +package kr.spot.view; + +public interface ViewCounter { + + long incrementAndGet(ViewableType type, long targetId); + + long currentDelta(ViewableType type, long targetId); +} diff --git a/common/view/src/main/java/kr/spot/view/ViewableType.java b/common/view/src/main/java/kr/spot/view/ViewableType.java new file mode 100644 index 00000000..11277ae8 --- /dev/null +++ b/common/view/src/main/java/kr/spot/view/ViewableType.java @@ -0,0 +1,15 @@ +package kr.spot.view; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ViewableType { + + POST("post"), + STUDY("study"), + STUDY_BOARD("study-board"); + + private final String keyPrefix; +} diff --git a/infra/view-redis/build.gradle b/infra/view-redis/build.gradle new file mode 100644 index 00000000..d429b256 --- /dev/null +++ b/infra/view-redis/build.gradle @@ -0,0 +1,15 @@ +plugins { + id "io.spring.dependency-management" + id "java" +} + +dependencies { + implementation project(":common:view") + + // Spring Boot Starters + implementation "org.springframework.boot:spring-boot-starter-data-redis" + + // Testing + testImplementation "org.springframework.boot:spring-boot-starter-test" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" +} diff --git a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java similarity index 50% rename from modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java rename to infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java index 8a115d65..3d075cdb 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java +++ b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java @@ -1,7 +1,8 @@ -package kr.spot.infrastructure.redis; +package kr.spot.view.redis; import java.time.Duration; -import kr.spot.application.ports.ViewAbuseGuard; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewableType; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; @@ -10,19 +11,19 @@ @RequiredArgsConstructor public class RedisViewAbuseGuard implements ViewAbuseGuard { - public static final String GUARD_KEY = "view:guard:%d:%d"; - public static final String VALUE = "1"; + private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d"; + private static final String VALUE = "1"; private static final Duration WINDOW = Duration.ofMinutes(10); - + private final StringRedisTemplate redis; - private static String guardKey(long postId, long viewerId) { - return GUARD_KEY.formatted(postId, viewerId); + private static String guardKey(ViewableType type, long targetId, long viewerId) { + return GUARD_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId, viewerId); } @Override - public boolean shouldCount(long postId, long viewerId) { - String key = guardKey(postId, viewerId); + public boolean shouldCount(ViewableType type, long targetId, long viewerId) { + String key = guardKey(type, targetId, viewerId); Boolean ok = redis.opsForValue().setIfAbsent(key, VALUE, WINDOW); return Boolean.TRUE.equals(ok); } diff --git a/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewCounter.java b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewCounter.java new file mode 100644 index 00000000..6662c4e4 --- /dev/null +++ b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewCounter.java @@ -0,0 +1,32 @@ +package kr.spot.view.redis; + +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisViewCounter implements ViewCounter { + + private static final String DELTA_KEY_FORMAT = "view:%s:%d"; + + private final StringRedisTemplate redis; + + private static String deltaKey(ViewableType type, long targetId) { + return DELTA_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId); + } + + @Override + public long incrementAndGet(ViewableType type, long targetId) { + Long v = redis.opsForValue().increment(deltaKey(type, targetId)); + return v == null ? 0L : v; + } + + @Override + public long currentDelta(ViewableType type, long targetId) { + String v = redis.opsForValue().get(deltaKey(type, targetId)); + return (v == null) ? 0L : Long.parseLong(v); + } +} diff --git a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java similarity index 54% rename from modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java rename to infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java index 98665708..35a90a67 100644 --- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java +++ b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java @@ -1,10 +1,11 @@ -package kr.spot.infrastructure.redis; +package kr.spot.view.redis; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; import java.time.Duration; -import kr.spot.application.ports.ViewAbuseGuard; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewableType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,8 +16,9 @@ class RedisViewAbuseGuardTest { - public static final Duration TIMEOUT = Duration.ofMinutes(10); - private static final String GUARD_KEY = "view:guard:%d:%d"; + private static final Duration TIMEOUT = Duration.ofMinutes(10); + private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d"; + @Mock StringRedisTemplate redis; @@ -36,14 +38,14 @@ void setUp() { @DisplayName("이전에 조회한 적 없는 사용자는 true를 반환한다.") void should_return_true_for_new_viewer() { // given - long postId = 1L; + long targetId = 1L; long viewerId = 2L; - String key = GUARD_KEY.formatted(postId, viewerId); + String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId); when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true); // when - boolean result = guard.shouldCount(postId, viewerId); + boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId); // then assertThat(result).isTrue(); @@ -53,16 +55,33 @@ void should_return_true_for_new_viewer() { @DisplayName("이전에 조회한 적 있는 사용자는 false를 반환한다.") void should_return_false_for_existing_viewer() { // given - long postId = 1L; + long targetId = 1L; long viewerId = 2L; - String key = GUARD_KEY.formatted(postId, viewerId); + String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId); when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(false); // when - boolean result = guard.shouldCount(postId, viewerId); + boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId); // then assertThat(result).isFalse(); } + + @Test + @DisplayName("STUDY 타입에 대해서도 정상적으로 동작한다.") + void should_work_for_study_type() { + // given + long studyId = 100L; + long viewerId = 2L; + String key = GUARD_KEY_FORMAT.formatted(ViewableType.STUDY.getKeyPrefix(), studyId, viewerId); + + when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true); + + // when + boolean result = guard.shouldCount(ViewableType.STUDY, studyId, viewerId); + + // then + assertThat(result).isTrue(); + } } diff --git a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisPostViewHistoryCounterTest.java b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java similarity index 50% rename from modules/post/src/test/java/kr/spot/infrastructure/redis/RedisPostViewHistoryCounterTest.java rename to infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java index bb79ba2e..57335230 100644 --- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisPostViewHistoryCounterTest.java +++ b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java @@ -1,9 +1,10 @@ -package kr.spot.infrastructure.redis; +package kr.spot.view.redis; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; -import kr.spot.application.ports.PostViewCounter; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,7 +13,9 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; -class RedisPostViewHistoryCounterTest { +class RedisViewCounterTest { + + private static final String DELTA_KEY_FORMAT = "view:%s:%d"; @Mock StringRedisTemplate redis; @@ -20,25 +23,25 @@ class RedisPostViewHistoryCounterTest { @Mock ValueOperations valueOps; - PostViewCounter counter; + ViewCounter counter; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); when(redis.opsForValue()).thenReturn(valueOps); - counter = new RedisPostViewCounter(redis); + counter = new RedisViewCounter(redis); } @Test - @DisplayName("게시글의 조회수 델타를 증가시키고, 증가된 값을 반환한다.") + @DisplayName("조회수 델타를 증가시키고, 증가된 값을 반환한다.") void should_increment_and_get_delta() { // given - long postId = 1L; - String key = "view:post:%d".formatted(postId); + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); when(valueOps.increment(key)).thenReturn(5L); // when - long delta = counter.incrementAndGetDelta(postId); + long delta = counter.incrementAndGet(ViewableType.POST, targetId); // then assertThat(delta).isEqualTo(5L); @@ -48,12 +51,12 @@ void should_increment_and_get_delta() { @DisplayName("델타 값 증가 시, 키에 해당하는 값이 없으면 0을 반환한다.") void should_return_0_if_no_value() { // given - long postId = 1L; - String key = "view:post:%d".formatted(postId); - when(valueOps.get(key)).thenReturn(null); + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); + when(valueOps.increment(key)).thenReturn(null); // when - long delta = counter.incrementAndGetDelta(postId); + long delta = counter.incrementAndGet(ViewableType.POST, targetId); // then assertThat(delta).isEqualTo(0L); @@ -63,12 +66,12 @@ void should_return_0_if_no_value() { @DisplayName("키에 해당하는 값을 long 타입으로 변환하여 반환한다.") void should_return_current_delta() { // given - long postId = 1L; - String key = "view:post:%d".formatted(postId); + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); when(valueOps.get(key)).thenReturn("10"); // when - long delta = counter.currentDelta(postId); + long delta = counter.currentDelta(ViewableType.POST, targetId); // then assertThat(delta).isEqualTo(10L); @@ -78,14 +81,29 @@ void should_return_current_delta() { @DisplayName("현재 델타 값 조회 시, 키에 해당하는 값이 없으면 0을 반환한다.") void should_return_0_if_no_current_value() { // given - long postId = 1L; - String key = "view:post:%d".formatted(postId); + long targetId = 1L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId); when(valueOps.get(key)).thenReturn(null); // when - long delta = counter.currentDelta(postId); + long delta = counter.currentDelta(ViewableType.POST, targetId); // then assertThat(delta).isEqualTo(0L); } + + @Test + @DisplayName("STUDY 타입에 대해서도 정상적으로 동작한다.") + void should_work_for_study_type() { + // given + long studyId = 100L; + String key = DELTA_KEY_FORMAT.formatted(ViewableType.STUDY.getKeyPrefix(), studyId); + when(valueOps.increment(key)).thenReturn(3L); + + // when + long delta = counter.incrementAndGet(ViewableType.STUDY, studyId); + + // then + assertThat(delta).isEqualTo(3L); + } } diff --git a/modules/auth/src/main/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawn.java b/modules/auth/src/main/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawn.java new file mode 100644 index 00000000..75ecf4a7 --- /dev/null +++ b/modules/auth/src/main/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawn.java @@ -0,0 +1,20 @@ +package kr.spot.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class RevokeTokenOnMemberWithdrawn { + + private final RefreshTokenRepository refreshTokenRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + refreshTokenRepository.deleteByMemberId(event.memberId()); + } +} diff --git a/modules/auth/src/main/java/kr/spot/application/test/CreateTestMemberService.java b/modules/auth/src/main/java/kr/spot/application/test/CreateTestMemberService.java new file mode 100644 index 00000000..51e583e9 --- /dev/null +++ b/modules/auth/src/main/java/kr/spot/application/test/CreateTestMemberService.java @@ -0,0 +1,46 @@ +package kr.spot.application.test; + +import java.util.UUID; +import kr.spot.domain.RefreshToken; +import kr.spot.impl.Snowflake; +import kr.spot.infrastructure.jpa.RefreshTokenRepository; +import kr.spot.infrastructure.jwt.JwtTokenProvider; +import kr.spot.ports.EnsureMemberFromOAuthPort; +import kr.spot.presentation.command.dto.TokenDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class CreateTestMemberService { + + private final Snowflake snowflake = new Snowflake(); + private final EnsureMemberFromOAuthPort ensureMemberFromOAuthPort; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + public TokenDTO createTestMember() { + String randomEmail = "test_" + UUID.randomUUID().toString().substring(0, 8) + "@test.com"; + String nickname = "테스트유저"; + + long memberId = ensureMemberFromOAuthPort.ensure( + "KAKAO", + randomEmail, + nickname, + null + ); + + TokenDTO tokenDTO = jwtTokenProvider.createTestToken(memberId); + saveRefreshToken(memberId, tokenDTO); + return tokenDTO; + } + + private void saveRefreshToken(long memberId, TokenDTO tokenDTO) { + RefreshToken refreshToken = RefreshToken.of(snowflake.nextId(), memberId, + tokenDTO.refreshToken()); + refreshTokenRepository.deleteByMemberId(memberId); + refreshTokenRepository.save(refreshToken); + } +} diff --git a/modules/auth/src/main/java/kr/spot/application/token/LogoutService.java b/modules/auth/src/main/java/kr/spot/application/token/LogoutService.java new file mode 100644 index 00000000..43f39dd4 --- /dev/null +++ b/modules/auth/src/main/java/kr/spot/application/token/LogoutService.java @@ -0,0 +1,18 @@ +package kr.spot.application.token; + +import kr.spot.infrastructure.jpa.RefreshTokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LogoutService { + + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public void logout(long memberId) { + refreshTokenRepository.deleteByMemberId(memberId); + } +} diff --git a/modules/auth/src/main/java/kr/spot/infrastructure/jwt/JwtTokenProvider.java b/modules/auth/src/main/java/kr/spot/infrastructure/jwt/JwtTokenProvider.java index fd4fdf89..85768450 100644 --- a/modules/auth/src/main/java/kr/spot/infrastructure/jwt/JwtTokenProvider.java +++ b/modules/auth/src/main/java/kr/spot/infrastructure/jwt/JwtTokenProvider.java @@ -49,7 +49,7 @@ public TokenDTO createToken(final Long memberId) { REFRESH); // 리프레시 토큰 생성 // 토큰 DTO 반환 - return TokenDTO.of(accessToken, refreshToken); + return TokenDTO.of(memberId, accessToken, refreshToken); } private String generateToken(final Long memberId, Date now, final long expirationTime, @@ -87,6 +87,14 @@ public Long getMemberIdByToken(final String token) { return claims.get(MEMBER_ID, Long.class); } + public TokenDTO createTestToken(final Long memberId) { + Date now = new Date(); + long oneYearInMillis = 365L * 24 * 60 * 60 * 1000; + String accessToken = generateToken(memberId, now, oneYearInMillis, ACCESS); + String refreshToken = generateToken(memberId, now, oneYearInMillis, REFRESH); + return TokenDTO.of(memberId, accessToken, refreshToken); + } + private Claims getClaims(final String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(JWT_SECRET_KEY.getBytes())) diff --git a/modules/auth/src/main/java/kr/spot/presentation/AuthController.java b/modules/auth/src/main/java/kr/spot/presentation/AuthController.java index cd4e0f16..8bd8a2f4 100644 --- a/modules/auth/src/main/java/kr/spot/presentation/AuthController.java +++ b/modules/auth/src/main/java/kr/spot/presentation/AuthController.java @@ -6,6 +6,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.application.token.LogoutService; import kr.spot.application.token.TokenReissueService; import kr.spot.code.status.SuccessStatus; import kr.spot.presentation.command.dto.TokenDTO; @@ -23,6 +25,7 @@ public class AuthController { private final TokenReissueService tokenReissueService; + private final LogoutService logoutService; @Operation(summary = "토큰 재발급", description = """ ## 리프레시 토큰을 통해 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다. @@ -45,4 +48,20 @@ public ResponseEntity> reissueToken( return ResponseEntity.ok( ApiResponse.onSuccess(SuccessStatus._OK, tokenReissueService.reissueToken(refreshToken))); } + + @Operation(summary = "로그아웃", description = """ + ## 로그아웃을 수행합니다. + - 서버에 저장된 리프레시 토큰을 삭제합니다. + - 액세스 토큰은 만료될 때까지 유효하므로, 클라이언트에서도 토큰을 삭제해주세요. + """) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + }) + @PostMapping("/logout") + public ResponseEntity> logout( + @CurrentMember long memberId + ) { + logoutService.logout(memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + } } diff --git a/modules/auth/src/main/java/kr/spot/presentation/OAuthController.java b/modules/auth/src/main/java/kr/spot/presentation/OAuthController.java index da718df0..90ad95ed 100644 --- a/modules/auth/src/main/java/kr/spot/presentation/OAuthController.java +++ b/modules/auth/src/main/java/kr/spot/presentation/OAuthController.java @@ -25,7 +25,7 @@ @RequiredArgsConstructor @RequestMapping("/api/oauth") public class OAuthController { - + private final OAuthService oAuthService; @Operation(summary = "소셜 로그인 리다이렉트 URL 반환", @@ -39,8 +39,8 @@ public class OAuthController { }) @GetMapping("/redirect-url/{type}") public ResponseEntity> getRedirectUrl( - @Parameter(description = "소셜 로그인 타입(naver, kakao)", required = true) - @PathVariable("type") String type) { + @PathVariable @Parameter(description = + "소셜 로그인 타입(naver, kakao)", required = true) String type) { return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, oAuthService.redirectURL(LoginType.valueOf(type.toUpperCase())))); } @@ -58,8 +58,7 @@ public ResponseEntity> getRedirectUrl( }) @GetMapping("/callback/{type}") public ResponseEntity> socialLoginCallback( - @Parameter(description = "소셜 로그인 타입(naver, kakao)", required = true) - @PathVariable("type") String type, + @PathVariable @Parameter(description = "소셜 로그인 타입(naver, kakao)", required = true) String type, @Parameter(description = "소셜 로그인 후 발급받은 code", required = true) @RequestParam("code") String code) { @@ -72,7 +71,7 @@ public ResponseEntity> socialLoginCallback( "예: /api/oauth/client/kakao?accessToken=YOUR_ACCESS_TOKEN") @GetMapping("/client/{type}") public ResponseEntity> socialLoginForClient( - @PathVariable("type") String type, + @PathVariable String type, @RequestParam("accessToken") String accessToken ) { return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, diff --git a/modules/auth/src/main/java/kr/spot/presentation/TestAuthController.java b/modules/auth/src/main/java/kr/spot/presentation/TestAuthController.java new file mode 100644 index 00000000..970bd757 --- /dev/null +++ b/modules/auth/src/main/java/kr/spot/presentation/TestAuthController.java @@ -0,0 +1,33 @@ +package kr.spot.presentation; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.application.test.CreateTestMemberService; +import kr.spot.code.status.SuccessStatus; +import kr.spot.presentation.command.dto.TokenDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "테스트") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/test") +public class TestAuthController { + + private final CreateTestMemberService createTestMemberService; + + @Operation(summary = "테스트용 회원 생성", description = "테스트용 회원을 생성하고 1년 유효기간의 토큰을 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK") + }) + @PostMapping("/members") + public ResponseEntity> createTestMember() { + TokenDTO tokenDTO = createTestMemberService.createTestMember(); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, tokenDTO)); + } +} diff --git a/modules/auth/src/main/java/kr/spot/presentation/command/dto/TokenDTO.java b/modules/auth/src/main/java/kr/spot/presentation/command/dto/TokenDTO.java index 43c40f89..13450e1c 100644 --- a/modules/auth/src/main/java/kr/spot/presentation/command/dto/TokenDTO.java +++ b/modules/auth/src/main/java/kr/spot/presentation/command/dto/TokenDTO.java @@ -1,8 +1,8 @@ package kr.spot.presentation.command.dto; -public record TokenDTO(String accessToken, String refreshToken) { +public record TokenDTO(Long id, String accessToken, String refreshToken) { - public static TokenDTO of(String accessToken, String refreshToken) { - return new TokenDTO(accessToken, refreshToken); + public static TokenDTO of(Long id, String accessToken, String refreshToken) { + return new TokenDTO(id, accessToken, refreshToken); } } diff --git a/modules/auth/src/test/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawnTest.java b/modules/auth/src/test/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawnTest.java new file mode 100644 index 00000000..672ef9e8 --- /dev/null +++ b/modules/auth/src/test/java/kr/spot/application/event/RevokeTokenOnMemberWithdrawnTest.java @@ -0,0 +1,40 @@ +package kr.spot.application.event; + +import static kr.spot.common.AuthFixture.MEMBER_ID; +import static org.mockito.Mockito.verify; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.RefreshTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RevokeTokenOnMemberWithdrawnTest { + + @Mock + RefreshTokenRepository refreshTokenRepository; + + RevokeTokenOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new RevokeTokenOnMemberWithdrawn(refreshTokenRepository); + } + + @Test + @DisplayName("회원 탈퇴 시 해당 회원의 리프레시 토큰을 삭제한다") + void should_deleteRefreshToken_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + verify(refreshTokenRepository).deleteByMemberId(MEMBER_ID); + } +} diff --git a/modules/auth/src/test/java/kr/spot/application/token/LogoutServiceTest.java b/modules/auth/src/test/java/kr/spot/application/token/LogoutServiceTest.java new file mode 100644 index 00000000..1b320c6a --- /dev/null +++ b/modules/auth/src/test/java/kr/spot/application/token/LogoutServiceTest.java @@ -0,0 +1,36 @@ +package kr.spot.application.token; + +import static kr.spot.common.AuthFixture.MEMBER_ID; +import static org.mockito.Mockito.verify; + +import kr.spot.infrastructure.jpa.RefreshTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LogoutServiceTest { + + @Mock + RefreshTokenRepository refreshTokenRepository; + + LogoutService service; + + @BeforeEach + void setUp() { + service = new LogoutService(refreshTokenRepository); + } + + @Test + @DisplayName("로그아웃 시 해당 회원의 리프레시 토큰을 삭제한다") + void should_deleteRefreshToken_when_logout() { + // when + service.logout(MEMBER_ID); + + // then + verify(refreshTokenRepository).deleteByMemberId(MEMBER_ID); + } +} diff --git a/modules/auth/src/test/java/kr/spot/common/AuthFixture.java b/modules/auth/src/test/java/kr/spot/common/AuthFixture.java index 5ba3303a..4cdaa6c6 100644 --- a/modules/auth/src/test/java/kr/spot/common/AuthFixture.java +++ b/modules/auth/src/test/java/kr/spot/common/AuthFixture.java @@ -19,6 +19,6 @@ public static RefreshToken mismatchedRefreshToken(Long id) { } public static TokenDTO newTokenDTO() { - return new TokenDTO(NEW_ACCESS, NEW_REFRESH); + return new TokenDTO(MEMBER_ID, NEW_ACCESS, NEW_REFRESH); } } diff --git a/modules/member/src/main/java/kr/spot/application/command/MemberWithdrawService.java b/modules/member/src/main/java/kr/spot/application/command/MemberWithdrawService.java new file mode 100644 index 00000000..97b1071a --- /dev/null +++ b/modules/member/src/main/java/kr/spot/application/command/MemberWithdrawService.java @@ -0,0 +1,33 @@ +package kr.spot.application.command; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.exception.GeneralException; +import kr.spot.infrastructure.jpa.MemberRepository; +import kr.spot.ports.HasActiveStudyAsLeaderPort; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberWithdrawService { + + private final MemberRepository memberRepository; + private final ApplicationEventPublisher eventPublisher; + private final HasActiveStudyAsLeaderPort hasActiveStudyAsLeaderPort; + + @Transactional + public void withdraw(long memberId) { + validateCanWithdraw(memberId); + memberRepository.deleteById(memberId); + eventPublisher.publishEvent(new MemberWithdrawnEvent(memberId)); + } + + private void validateCanWithdraw(long memberId) { + if (hasActiveStudyAsLeaderPort.hasActiveStudyAsLeader(memberId)) { + throw new GeneralException(ErrorStatus._CANNOT_WITHDRAW_WITH_ACTIVE_STUDY); + } + } +} diff --git a/modules/member/src/main/java/kr/spot/application/query/GetMemberInfoService.java b/modules/member/src/main/java/kr/spot/application/query/GetMemberInfoService.java index 80430329..97569b75 100644 --- a/modules/member/src/main/java/kr/spot/application/query/GetMemberInfoService.java +++ b/modules/member/src/main/java/kr/spot/application/query/GetMemberInfoService.java @@ -1,8 +1,19 @@ package kr.spot.application.query; +import java.util.List; import kr.spot.domain.Member; +import kr.spot.domain.association.PreferredCategory; +import kr.spot.domain.association.PreferredRegion; import kr.spot.infrastructure.jpa.MemberRepository; +import kr.spot.infrastructure.jpa.PreferredCategoryRepository; +import kr.spot.infrastructure.jpa.PreferredRegionRepository; +import kr.spot.ports.StudyParticipationInfoPort; +import kr.spot.ports.dto.StudyParticipationInfo; +import kr.spot.presentation.query.dto.response.GetMemberInfoResponse; +import kr.spot.presentation.query.dto.response.GetMemberInfoResponse.StudyParticipationInfoResponse; import kr.spot.presentation.query.dto.response.GetMemberNameResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferCategoryResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferRegionResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,10 +23,55 @@ @RequiredArgsConstructor public class GetMemberInfoService { + private final StudyParticipationInfoPort studyParticipationInfoPort; private final MemberRepository memberRepository; + private final PreferredCategoryRepository preferredCategoryRepository; + private final PreferredRegionRepository preferredRegionRepository; public GetMemberNameResponse getMemberName(Long memberId) { Member member = memberRepository.getMemberById(memberId); return GetMemberNameResponse.from(member.getName()); } + + public GetMemberInfoResponse getMemberInfo(Long memberId) { + Member member = memberRepository.getMemberById(memberId); + + StudyParticipationInfo studyParticipationInfo = studyParticipationInfoPort.getStudyParticipationInfo( + memberId); + + return GetMemberInfoResponse.from( + member.getId(), + member.getName(), + member.getProfileImageUrl(), + member.getLoginType(), + member.getEmail().getValue(), + StudyParticipationInfoResponse.from( + studyParticipationInfo.participatingStudyCount(), + studyParticipationInfo.recruitingStudyCount(), + studyParticipationInfo.appliedStudyCount() + ) + ); + } + + public GetMemberPreferCategoryResponse getMemberPreferCategories(Long memberId) { + List preferredCategories = preferredCategoryRepository.findAllByMemberId( + memberId); + + List categoryCodes = preferredCategories.stream() + .map(PreferredCategory::getCategory) + .toList(); + + return GetMemberPreferCategoryResponse.from(categoryCodes, categoryCodes.size()); + } + + public GetMemberPreferRegionResponse getMemberPreferRegions(Long memberId) { + List preferredRegions = preferredRegionRepository.findAllByMemberId( + memberId); + + List regionCodes = preferredRegions.stream() + .map(PreferredRegion::getRegionCode) + .toList(); + + return GetMemberPreferRegionResponse.from(regionCodes, regionCodes.size()); + } } diff --git a/modules/member/src/main/java/kr/spot/presentation/command/MemberCommandController.java b/modules/member/src/main/java/kr/spot/presentation/command/MemberCommandController.java index 1b2d155c..e5f1013b 100644 --- a/modules/member/src/main/java/kr/spot/presentation/command/MemberCommandController.java +++ b/modules/member/src/main/java/kr/spot/presentation/command/MemberCommandController.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; +import kr.spot.application.command.MemberWithdrawService; import kr.spot.application.command.RegisterMemberInfoService; import kr.spot.application.command.RegisterPreferredCategoryService; import kr.spot.application.command.RegisterPreferredRegionService; @@ -17,6 +18,7 @@ import kr.spot.presentation.command.dto.request.UpdateMemberNameRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -31,6 +33,7 @@ public class MemberCommandController { private final RegisterPreferredCategoryService registerPreferredCategoryService; private final RegisterPreferredRegionService registerPreferredRegionService; private final RegisterMemberInfoService registerMemberInfoService; + private final MemberWithdrawService memberWithdrawService; @Operation(summary = "선호 카테고리 등록", description = "회원의 선호 카테고리를 등록합니다. 한 번 저장 후 다시 호출하면 덮어씁니다. (이전 데이터 삭제)") @ApiResponses({ @@ -83,4 +86,21 @@ public ResponseEntity> updateMemberName( registerMemberInfoService.updateMemberName(memberId, request); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); } + + @Operation(summary = "회원 탈퇴", description = """ + ## 회원 탈퇴를 수행합니다. + - 회원 정보 및 관련 데이터가 모두 삭제됩니다. + - 클라이언트에서도 저장된 토큰을 삭제해주세요. + """) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "탈퇴 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) + }) + @DeleteMapping("/me") + public ResponseEntity> withdraw( + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + memberWithdrawService.withdraw(memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); + } } diff --git a/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java b/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java index 1d70a4d1..055ad190 100644 --- a/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java +++ b/modules/member/src/main/java/kr/spot/presentation/query/MemberQueryController.java @@ -10,7 +10,10 @@ import kr.spot.annotations.CurrentMember; import kr.spot.application.query.GetMemberInfoService; import kr.spot.code.status.SuccessStatus; +import kr.spot.presentation.query.dto.response.GetMemberInfoResponse; import kr.spot.presentation.query.dto.response.GetMemberNameResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferCategoryResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferRegionResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -37,4 +40,29 @@ public ResponseEntity> getMemberName( GetMemberNameResponse response = getMemberInfoService.getMemberName(memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } + + @Operation(summary = "[마이페이지] 회원 정보 조회", description = "현재 로그인한 회원의 정보를 조회합니다.") + @GetMapping("/info") + public ResponseEntity> getMemberInfo( + @CurrentMember @Parameter(hidden = true) Long memberId) { + GetMemberInfoResponse response = getMemberInfoService.getMemberInfo(memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @Operation(summary = "회원 선호 카테고리 조회", description = "현재 로그인한 회원의 선호 카테고리를 조회합니다.") + @GetMapping("/prefer-categories") + public ResponseEntity> getMemberPreferCategories( + @CurrentMember @Parameter(hidden = true) Long memberId) { + GetMemberPreferCategoryResponse response = getMemberInfoService.getMemberPreferCategories( + memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @Operation(summary = "회원 선호 지역 조회", description = "현재 로그인한 회원의 선호 지역을 조회합니다.") + @GetMapping("/prefer-regions") + public ResponseEntity> getMemberPreferRegions( + @CurrentMember @Parameter(hidden = true) Long memberId) { + GetMemberPreferRegionResponse response = getMemberInfoService.getMemberPreferRegions(memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } } diff --git a/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberInfoResponse.java b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberInfoResponse.java new file mode 100644 index 00000000..4b968dc5 --- /dev/null +++ b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberInfoResponse.java @@ -0,0 +1,52 @@ +package kr.spot.presentation.query.dto.response; + +import kr.spot.domain.enums.LoginType; + +public record GetMemberInfoResponse( + Long memberId, + String nickname, + String profileImageUrl, + LoginType loginType, + String email, + StudyParticipationInfoResponse studyParticipationInfo +) { + + public static GetMemberInfoResponse from( + long memberId, + String nickname, + String profileImageUrl, + LoginType loginType, + String email, + StudyParticipationInfoResponse studyParticipationInfo + ) { + return new GetMemberInfoResponse( + memberId, + nickname, + profileImageUrl, + loginType, + email, + studyParticipationInfo + ); + } + + public record StudyParticipationInfoResponse( + long participatingStudyCount, + long recruitingStudyCount, + long appliedStudyCount + ) { + + public static StudyParticipationInfoResponse from( + long participatingStudyCount, + long recruitingStudyCount, + long appliedStudyCount + ) { + return new StudyParticipationInfoResponse( + participatingStudyCount, + recruitingStudyCount, + appliedStudyCount + ); + } + + } + +} diff --git a/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferCategoryResponse.java b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferCategoryResponse.java new file mode 100644 index 00000000..dd3716df --- /dev/null +++ b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferCategoryResponse.java @@ -0,0 +1,14 @@ +package kr.spot.presentation.query.dto.response; + +import java.util.List; + +public record GetMemberPreferCategoryResponse( + List categories, + long totalCount +) { + + public static GetMemberPreferCategoryResponse from(List categories, + long totalCount) { + return new GetMemberPreferCategoryResponse(categories, totalCount); + } +} diff --git a/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferRegionResponse.java b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferRegionResponse.java new file mode 100644 index 00000000..9fd1ecfa --- /dev/null +++ b/modules/member/src/main/java/kr/spot/presentation/query/dto/response/GetMemberPreferRegionResponse.java @@ -0,0 +1,14 @@ +package kr.spot.presentation.query.dto.response; + +import java.util.List; + +public record GetMemberPreferRegionResponse( + List regionCodes, + long totalCount +) { + + public static GetMemberPreferRegionResponse from(List regionCodes, + long totalCount) { + return new GetMemberPreferRegionResponse(regionCodes, totalCount); + } +} diff --git a/modules/member/src/test/java/kr/spot/application/query/GetMemberInfoServiceTest.java b/modules/member/src/test/java/kr/spot/application/query/GetMemberInfoServiceTest.java new file mode 100644 index 00000000..7ccd1ab6 --- /dev/null +++ b/modules/member/src/test/java/kr/spot/application/query/GetMemberInfoServiceTest.java @@ -0,0 +1,185 @@ +package kr.spot.application.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import kr.spot.common.fixture.MemberFixture; +import kr.spot.domain.Member; +import kr.spot.domain.association.PreferredCategory; +import kr.spot.domain.association.PreferredRegion; +import kr.spot.domain.enums.LoginType; +import kr.spot.infrastructure.jpa.MemberRepository; +import kr.spot.infrastructure.jpa.PreferredCategoryRepository; +import kr.spot.infrastructure.jpa.PreferredRegionRepository; +import kr.spot.ports.StudyParticipationInfoPort; +import kr.spot.ports.dto.StudyParticipationInfo; +import kr.spot.presentation.query.dto.response.GetMemberInfoResponse; +import kr.spot.presentation.query.dto.response.GetMemberNameResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferCategoryResponse; +import kr.spot.presentation.query.dto.response.GetMemberPreferRegionResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GetMemberInfoServiceTest { + + @Mock + MemberRepository memberRepository; + + @Mock + PreferredCategoryRepository preferredCategoryRepository; + + @Mock + PreferredRegionRepository preferredRegionRepository; + + @Mock + StudyParticipationInfoPort studyParticipationInfoPort; + + @InjectMocks + GetMemberInfoService sut; + + @Nested + @DisplayName("getMemberName 메서드는") + class GetMemberNameTest { + + @Test + @DisplayName("회원 ID로 회원 이름을 조회한다") + void should_return_member_name_when_member_exists() { + // given + long memberId = MemberFixture.ID; + Member member = MemberFixture.member(); + + when(memberRepository.getMemberById(memberId)).thenReturn(member); + + // when + GetMemberNameResponse response = sut.getMemberName(memberId); + + // then + assertThat(response.name()).isEqualTo(MemberFixture.NAME); + } + } + + @Nested + @DisplayName("getMemberInfo 메서드는") + class GetMemberInfoTest { + + @Test + @DisplayName("회원 ID로 회원 정보와 스터디 참여 정보를 조회한다") + void should_return_member_info_with_study_participation_info() { + // given + long memberId = MemberFixture.ID; + Member member = MemberFixture.member(); + StudyParticipationInfo studyParticipationInfo = StudyParticipationInfo.from(3L, 1L, 2L); + + when(memberRepository.getMemberById(memberId)).thenReturn(member); + when(studyParticipationInfoPort.getStudyParticipationInfo(memberId)) + .thenReturn(studyParticipationInfo); + + // when + GetMemberInfoResponse response = sut.getMemberInfo(memberId); + + // then + assertThat(response.memberId()).isEqualTo(MemberFixture.ID); + assertThat(response.nickname()).isEqualTo(MemberFixture.NAME); + assertThat(response.profileImageUrl()).isEqualTo(MemberFixture.PROFILE_IMAGE); + assertThat(response.loginType()).isEqualTo(LoginType.KAKAO); + assertThat(response.email()).isEqualTo(MemberFixture.EMAIL); + assertThat(response.studyParticipationInfo().participatingStudyCount()).isEqualTo(3L); + assertThat(response.studyParticipationInfo().recruitingStudyCount()).isEqualTo(1L); + assertThat(response.studyParticipationInfo().appliedStudyCount()).isEqualTo(2L); + } + } + + @Nested + @DisplayName("getMemberPreferCategories 메서드는") + class GetMemberPreferCategoriesTest { + + @Test + @DisplayName("회원 ID로 선호 카테고리 목록을 조회한다") + void should_return_preferred_categories_when_member_has_categories() { + // given + long memberId = MemberFixture.ID; + List preferredCategories = List.of( + PreferredCategory.of(1L, memberId, "PROGRAMMING"), + PreferredCategory.of(2L, memberId, "DESIGN"), + PreferredCategory.of(3L, memberId, "LANGUAGE") + ); + + when(preferredCategoryRepository.findAllByMemberId(memberId)) + .thenReturn(preferredCategories); + + // when + GetMemberPreferCategoryResponse response = sut.getMemberPreferCategories(memberId); + + // then + assertThat(response.categories()).containsExactly("PROGRAMMING", "DESIGN", "LANGUAGE"); + assertThat(response.totalCount()).isEqualTo(3); + } + + @Test + @DisplayName("선호 카테고리가 없으면 빈 목록을 반환한다") + void should_return_empty_list_when_member_has_no_categories() { + // given + long memberId = MemberFixture.ID; + + when(preferredCategoryRepository.findAllByMemberId(memberId)) + .thenReturn(List.of()); + + // when + GetMemberPreferCategoryResponse response = sut.getMemberPreferCategories(memberId); + + // then + assertThat(response.categories()).isEmpty(); + assertThat(response.totalCount()).isZero(); + } + } + + @Nested + @DisplayName("getMemberPreferRegions 메서드는") + class GetMemberPreferRegionsTest { + + @Test + @DisplayName("회원 ID로 선호 지역 목록을 조회한다") + void should_return_preferred_regions_when_member_has_regions() { + // given + long memberId = MemberFixture.ID; + List preferredRegions = List.of( + PreferredRegion.of(1L, memberId, "SEOUL"), + PreferredRegion.of(2L, memberId, "BUSAN") + ); + + when(preferredRegionRepository.findAllByMemberId(memberId)) + .thenReturn(preferredRegions); + + // when + GetMemberPreferRegionResponse response = sut.getMemberPreferRegions(memberId); + + // then + assertThat(response.regionCodes()).containsExactly("SEOUL", "BUSAN"); + assertThat(response.totalCount()).isEqualTo(2); + } + + @Test + @DisplayName("선호 지역이 없으면 빈 목록을 반환한다") + void should_return_empty_list_when_member_has_no_regions() { + // given + long memberId = MemberFixture.ID; + + when(preferredRegionRepository.findAllByMemberId(memberId)) + .thenReturn(List.of()); + + // when + GetMemberPreferRegionResponse response = sut.getMemberPreferRegions(memberId); + + // then + assertThat(response.regionCodes()).isEmpty(); + assertThat(response.totalCount()).isZero(); + } + } +} diff --git a/modules/notification-api/build.gradle b/modules/notification-api/build.gradle new file mode 100644 index 00000000..0c4770e1 --- /dev/null +++ b/modules/notification-api/build.gradle @@ -0,0 +1,6 @@ +plugins { + id "java" +} + +dependencies { +} diff --git a/modules/notification-api/src/main/java/kr/spot/event/NotificationRequestedEvent.java b/modules/notification-api/src/main/java/kr/spot/event/NotificationRequestedEvent.java new file mode 100644 index 00000000..4f7b48e8 --- /dev/null +++ b/modules/notification-api/src/main/java/kr/spot/event/NotificationRequestedEvent.java @@ -0,0 +1,71 @@ +package kr.spot.event; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 알림 요청 이벤트. + * 각 도메인 서비스에서 이 이벤트를 발행하면, Notification 도메인에서 처리합니다. + * + * @param type 알림 유형 (문자열로 전달, Notification 도메인에서 NotificationType으로 변환) + * @param payload 알림 생성에 필요한 데이터 (템플릿 렌더링 및 수신자 결정에 사용) + * @param scheduledAt 발송 예정 시각 (null이면 즉시 발송) + * @param referenceId 연관 엔티티 ID (딥링크용) + * @param imageUrl 알림 이미지 URL (optional) + */ +public record NotificationRequestedEvent( + String type, + Map payload, + LocalDateTime scheduledAt, + Long referenceId, + String imageUrl +) { + + /** + * 즉시 발송 알림 생성 + */ + public static NotificationRequestedEvent immediate( + String type, + Map payload, + Long referenceId, + String imageUrl + ) { + return new NotificationRequestedEvent(type, payload, LocalDateTime.now(), referenceId, imageUrl); + } + + /** + * 즉시 발송 알림 생성 (이미지 없음) + */ + public static NotificationRequestedEvent immediate( + String type, + Map payload, + Long referenceId + ) { + return immediate(type, payload, referenceId, null); + } + + /** + * 예약 발송 알림 생성 + */ + public static NotificationRequestedEvent scheduled( + String type, + LocalDateTime scheduledAt, + Map payload, + Long referenceId, + String imageUrl + ) { + return new NotificationRequestedEvent(type, payload, scheduledAt, referenceId, imageUrl); + } + + /** + * 예약 발송 알림 생성 (이미지 없음) + */ + public static NotificationRequestedEvent scheduled( + String type, + LocalDateTime scheduledAt, + Map payload, + Long referenceId + ) { + return scheduled(type, scheduledAt, payload, referenceId, null); + } +} diff --git a/modules/notification/build.gradle b/modules/notification/build.gradle index 3784339b..3721f10c 100644 --- a/modules/notification/build.gradle +++ b/modules/notification/build.gradle @@ -8,8 +8,16 @@ dependencies { implementation project(":common:snowflake") implementation project(":modules:shared") + implementation project(":modules:auth-api") + implementation project(":modules:study-api") + implementation project(":modules:notification-api") implementation "org.springframework.boot:spring-boot-starter-data-jpa" + implementation "org.springframework.boot:spring-boot-starter-web" + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.4' annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" } diff --git a/modules/notification/src/main/java/kr/spot/application/command/NotificationCommandService.java b/modules/notification/src/main/java/kr/spot/application/command/NotificationCommandService.java new file mode 100644 index 00000000..fce3ab44 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/command/NotificationCommandService.java @@ -0,0 +1,21 @@ +package kr.spot.application.command; + +import kr.spot.domain.Notification; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class NotificationCommandService { + + private final NotificationRepository notificationRepository; + + public void markAllAsRead(long memberId, long notificationId) { + Notification notification = notificationRepository.getById(notificationId); + notification.markAsRead(memberId); + } + +} diff --git a/modules/notification/src/main/java/kr/spot/application/dispatch/NoOpPushNotificationClient.java b/modules/notification/src/main/java/kr/spot/application/dispatch/NoOpPushNotificationClient.java new file mode 100644 index 00000000..3331faad --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/dispatch/NoOpPushNotificationClient.java @@ -0,0 +1,22 @@ +package kr.spot.application.dispatch; + +import kr.spot.domain.Notification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 푸시 알림을 실제로 발송하지 않는 더미 구현체. 개발/테스트 환경 또는 FCM 연동 전에 사용합니다. + */ +@Slf4j +@Component +public class NoOpPushNotificationClient implements PushNotificationClient { + + @Override + public void send(Notification notification) { + log.info("[NoOp] Push notification would be sent: memberId={}, type={}, title={}", + notification.getMemberId(), + notification.getType(), + notification.getTitle() + ); + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationDispatchScheduler.java b/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationDispatchScheduler.java new file mode 100644 index 00000000..4e8e8dea --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationDispatchScheduler.java @@ -0,0 +1,64 @@ +package kr.spot.application.dispatch; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.LocalDateTime; +import java.util.List; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationStatus; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationDispatchScheduler { + + private static final int BATCH_SIZE = 100; + + private final NotificationRepository notificationRepository; + private final NotificationSender notificationSender; + private final String serverId = resolveServerId(); + + private static String resolveServerId() { + try { + String hostname = InetAddress.getLocalHost().getHostName(); + return hostname.length() > 50 ? hostname.substring(0, 50) : hostname; + } catch (UnknownHostException e) { + return "server-" + System.currentTimeMillis(); + } + } + + /** + * 발송 대상 알림을 선점하고 비동기로 발송합니다. 10초마다 실행됩니다. + */ + @Scheduled(fixedDelay = 10_000) + @Transactional + public void dispatch() { + LocalDateTime now = LocalDateTime.now(); + + // 1. 발송 대상 선점 (FOR UPDATE SKIP LOCKED) + int pickedCount = notificationRepository.pickPendingNotifications( + serverId, now, BATCH_SIZE + ); + + if (pickedCount == 0) { + return; + } + + log.info("Picked {} notifications for dispatch", pickedCount); + + // 2. 선점한 알림 조회 + List notifications = notificationRepository + .findByPickedByAndDispatchStatus(serverId, NotificationStatus.PROCESSING); + + // 3. 비동기로 발송 처리 + for (Notification notification : notifications) { + notificationSender.sendAsync(notification.getId()); + } + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationSender.java b/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationSender.java new file mode 100644 index 00000000..d7876ccf --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationSender.java @@ -0,0 +1,96 @@ +package kr.spot.application.dispatch; + +import java.time.Duration; +import java.time.LocalDateTime; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationStatus; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationSender { + + private final PushNotificationClient pushClient; + private final NotificationRepository notificationRepository; + + @Async("notificationExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void sendAsync(long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElse(null); + + if (notification == null) { + log.warn("Notification not found: id={}", notificationId); + return; + } + + // 발송 전 상태 재확인 (다른 서버가 이미 처리했을 수 있음) + if (notification.getDispatchStatus() != NotificationStatus.PROCESSING) { + log.info("Notification {} already processed, status={}", + notificationId, notification.getDispatchStatus()); + return; + } + + try { + pushClient.send(notification); + + // 성공 + notification.markAsSent(LocalDateTime.now()); + notificationRepository.save(notification); + + log.debug("Notification sent successfully: id={}, memberId={}", + notificationId, notification.getMemberId()); + + } catch (Exception e) { + handleFailure(notification, e); + } + } + + private void handleFailure(Notification notification, Exception e) { + log.warn("Failed to send notification: id={}, error={}", + notification.getId(), e.getMessage()); + + if (notification.canRetry()) { + // 재시도 예약 (Exponential Backoff) + Duration backoff = calculateBackoff(notification.getRetryCount() + 1); + notification.scheduleRetry(LocalDateTime.now().plus(backoff)); + + log.info("Notification {} scheduled for retry #{} at {}", + notification.getId(), + notification.getRetryCount(), + notification.getNextRetryAt()); + } else { + // 최종 실패 + notification.markAsFailed(truncateError(e.getMessage())); + + log.error("Notification {} permanently failed after {} retries", + notification.getId(), + notification.getRetryCount()); + } + + notificationRepository.save(notification); + } + + private Duration calculateBackoff(int retryCount) { + // 1분, 5분, 30분 + return switch (retryCount) { + case 1 -> Duration.ofMinutes(1); + case 2 -> Duration.ofMinutes(5); + default -> Duration.ofMinutes(30); + }; + } + + private String truncateError(String message) { + if (message == null) { + return "Unknown error"; + } + return message.length() > 500 ? message.substring(0, 500) : message; + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/dispatch/PushNotificationClient.java b/modules/notification/src/main/java/kr/spot/application/dispatch/PushNotificationClient.java new file mode 100644 index 00000000..912d11ff --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/dispatch/PushNotificationClient.java @@ -0,0 +1,18 @@ +package kr.spot.application.dispatch; + +import kr.spot.domain.Notification; + +/** + * 푸시 알림 발송 클라이언트 인터페이스. + * FCM, APNs 등의 구현체가 이 인터페이스를 구현합니다. + */ +public interface PushNotificationClient { + + /** + * 푸시 알림을 발송합니다. + * + * @param notification 발송할 알림 + * @throws Exception 발송 실패 시 + */ + void send(Notification notification) throws Exception; +} diff --git a/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java b/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java new file mode 100644 index 00000000..f3ffcd79 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java @@ -0,0 +1,20 @@ +package kr.spot.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class CleanupNotificationOnMemberWithdrawn { + + private final NotificationRepository notificationRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + notificationRepository.deleteByMemberId(event.memberId()); + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/event/NotificationEventListener.java b/modules/notification/src/main/java/kr/spot/application/event/NotificationEventListener.java new file mode 100644 index 00000000..8edf6719 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/event/NotificationEventListener.java @@ -0,0 +1,120 @@ +package kr.spot.application.event; + +import java.util.List; +import java.util.Map; +import kr.spot.IdGenerator; +import kr.spot.application.recipient.RecipientResolver; +import kr.spot.application.template.NotificationContent; +import kr.spot.application.template.NotificationTemplateRenderer; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationType; +import kr.spot.event.NotificationRequestedEvent; +import kr.spot.exception.GeneralException; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationEventListener { + + private final IdGenerator idGenerator; + private final NotificationRepository notificationRepository; + private final NotificationTemplateRenderer templateRenderer; + private final RecipientResolver recipientResolver; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(NotificationRequestedEvent event) { + try { + NotificationType type = parseNotificationType(event.type()); + Map payload = event.payload(); + + // 1. 수신자 목록 조회 + List recipientIds = recipientResolver.resolve(type, payload); + if (recipientIds.isEmpty()) { + log.warn("No recipients found for notification type: {}", type); + return; + } + + // 2. 템플릿 렌더링 + NotificationContent content = templateRenderer.render(type, payload); + + // 3. 알림 레코드 생성 및 저장 + List notifications = recipientIds.stream() + .map(memberId -> createNotification( + memberId, type, content, event + )) + .toList(); + + saveNotifications(notifications); + + log.info("Created {} notifications for type: {}", notifications.size(), type); + } catch (GeneralException e) { + log.error("Failed to create notifications: errorCode={}, message={}", + e.getStatus().getCode(), e.getStatus().getMessage()); + throw e; + } catch (Exception e) { + log.error("Failed to create notifications for event: {}", event, e); + } + } + + private NotificationType parseNotificationType(String type) { + try { + return NotificationType.valueOf(type); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus._INVALID_NOTIFICATION_TYPE); + } + } + + private Notification createNotification( + long memberId, + NotificationType type, + NotificationContent content, + NotificationRequestedEvent event + ) { + String dedupeKey = generateDedupeKey(type, event.referenceId(), memberId); + + return Notification.create( + idGenerator.nextId(), + memberId, + type, + content.title(), + content.body(), + event.imageUrl(), + type.getReferenceType(), + event.referenceId(), + event.scheduledAt(), + dedupeKey + ); + } + + private String generateDedupeKey(NotificationType type, Long referenceId, long memberId) { + if (referenceId == null) { + return null; // 중복 방지 키가 없는 경우 (일반 알림) + } + return String.format("%s:%s:%d:%d", + type.name(), + type.getReferenceType(), + referenceId, + memberId + ); + } + + private void saveNotifications(List notifications) { + for (Notification notification : notifications) { + try { + notificationRepository.save(notification); + } catch (DataIntegrityViolationException e) { + // dedupe_key 중복 - 이미 동일한 알림이 존재함 (멱등성 보장) + log.debug("Duplicate notification ignored: dedupeKey={}", + notification.getDedupeKey()); + } + } + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java b/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java new file mode 100644 index 00000000..c80aa881 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java @@ -0,0 +1,67 @@ +package kr.spot.application.event; + +import java.time.LocalDateTime; +import kr.spot.IdGenerator; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationType; +import kr.spot.event.StudyApplicationProcessedEvent; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 스터디 신청 처리 결과 알림을 생성합니다. + * study 모듈에서 발행하는 StudyApplicationProcessedEvent를 처리합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyApplicationNotificationListener { + + private final IdGenerator idGenerator; + private final NotificationRepository notificationRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(StudyApplicationProcessedEvent event) { + if (!event.isApproved()) { + log.debug("Skipping notification for rejected application: studyId={}, applicantId={}", + event.studyId(), event.applicantId()); + return; + } + + try { + Notification notification = createNotification(event); + notificationRepository.save(notification); + log.info("Notification saved: studyId={}, applicantId={}, decision={}", + event.studyId(), event.applicantId(), event.decision()); + } catch (DataIntegrityViolationException e) { + // dedupe_key 중복 - 이미 동일한 알림이 존재함 + log.debug("Duplicate notification ignored for studyId={}, applicantId={}", + event.studyId(), event.applicantId()); + } catch (Exception e) { + log.error("Failed to save notification: {}", event, e); + } + } + + private Notification createNotification(StudyApplicationProcessedEvent event) { + String dedupeKey = String.format("%s:STUDY:%d:%d", + NotificationType.STUDY_APPLICATION_APPROVED.name(), event.studyId(), event.applicantId()); + + return Notification.create( + idGenerator.nextId(), + event.applicantId(), + NotificationType.STUDY_APPLICATION_APPROVED, + event.studyName(), + "스터디 가입이 승인되었습니다! 지금 바로 참여해보세요.", + event.studyThumbnailUrl(), + "STUDY", + event.studyId(), + LocalDateTime.now(), + dedupeKey + ); + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/query/GetNotificationService.java b/modules/notification/src/main/java/kr/spot/application/query/GetNotificationService.java new file mode 100644 index 00000000..6fa6503f --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/query/GetNotificationService.java @@ -0,0 +1,29 @@ +package kr.spot.application.query; + +import java.util.List; +import kr.spot.domain.Notification; +import kr.spot.infrastructure.jpa.NotificationRepository; +import kr.spot.presentation.query.dto.GetNotificationListResponse; +import kr.spot.presentation.query.dto.GetUnreadNotificationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetNotificationService { + + private final NotificationRepository notificationRepository; + + public GetNotificationListResponse getMyNotifications(long memberId) { + List notifications = notificationRepository + .findByMemberIdOrderByCreatedAtDesc(memberId); + return GetNotificationListResponse.from(notifications); + } + + public GetUnreadNotificationResponse hasUnreadNotifications(long memberId) { + boolean hasUnread = notificationRepository.existsByMemberIdAndIsReadFalse(memberId); + return new GetUnreadNotificationResponse(hasUnread); + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/recipient/RecipientResolver.java b/modules/notification/src/main/java/kr/spot/application/recipient/RecipientResolver.java new file mode 100644 index 00000000..194e9207 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/recipient/RecipientResolver.java @@ -0,0 +1,63 @@ +package kr.spot.application.recipient; + +import java.util.List; +import java.util.Map; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.enums.NotificationType; +import kr.spot.exception.GeneralException; +import kr.spot.ports.GetStudyMemberIdsPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecipientResolver { + + private final GetStudyMemberIdsPort getStudyMemberIdsPort; + + public List resolve(NotificationType type, Map payload) { + return switch (type) { + // 특정 회원 1명에게 발송 + case STUDY_APPLICATION_APPROVED, + STUDY_APPLICATION_REJECTED, + TODO_COMPLETED -> resolveSingleMember(payload); + + // 스터디 전체 멤버에게 발송 + case ATTENDANCE_STARTED, + ATTENDANCE_ENDED, + NOTICE_CREATED, + SCHEDULE_CREATED, + SCHEDULE_UPDATED, + SCHEDULE_REMINDER -> resolveStudyMembers(payload); + + // 특정 조건의 회원들에게 발송 (추후 확장) + case HOT_POST_DAILY -> List.of(); + }; + } + + private List resolveSingleMember(Map payload) { + Object memberId = payload.get("memberId"); + if (memberId == null) { + throw new GeneralException(ErrorStatus._NOTIFICATION_PAYLOAD_MISSING_MEMBER_ID); + } + return List.of(toLong(memberId)); + } + + private List resolveStudyMembers(Map payload) { + Object studyId = payload.get("studyId"); + if (studyId == null) { + throw new GeneralException(ErrorStatus._NOTIFICATION_PAYLOAD_MISSING_STUDY_ID); + } + return getStudyMemberIdsPort.getMemberIdsByStudyId(toLong(studyId)); + } + + private long toLong(Object value) { + if (value instanceof Long l) { + return l; + } + if (value instanceof Number n) { + return n.longValue(); + } + return Long.parseLong(String.valueOf(value)); + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/template/NotificationContent.java b/modules/notification/src/main/java/kr/spot/application/template/NotificationContent.java new file mode 100644 index 00000000..3f317487 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/template/NotificationContent.java @@ -0,0 +1,8 @@ +package kr.spot.application.template; + +public record NotificationContent( + String title, + String body +) { + +} diff --git a/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplate.java b/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplate.java new file mode 100644 index 00000000..ca0c7bb7 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplate.java @@ -0,0 +1,8 @@ +package kr.spot.application.template; + +record NotificationTemplate( + String titleTemplate, + String bodyTemplate +) { + +} diff --git a/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplateRenderer.java b/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplateRenderer.java new file mode 100644 index 00000000..3f52b6ab --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplateRenderer.java @@ -0,0 +1,98 @@ +package kr.spot.application.template; + +import java.util.EnumMap; +import java.util.Map; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.enums.NotificationType; +import kr.spot.exception.GeneralException; +import org.springframework.stereotype.Component; + +@Component +public class NotificationTemplateRenderer { + + private static final Map TEMPLATES; + + static { + TEMPLATES = new EnumMap<>(NotificationType.class); + + // 스터디 신청 결과 + TEMPLATES.put(NotificationType.STUDY_APPLICATION_APPROVED, + new NotificationTemplate( + "${studyName}", + "스터디 가입이 승인되었습니다! 지금 바로 참여해보세요." + )); + TEMPLATES.put(NotificationType.STUDY_APPLICATION_REJECTED, + new NotificationTemplate( + "${studyName}", + "스터디 가입이 거절되었습니다." + )); + + // 출석 체크 + TEMPLATES.put(NotificationType.ATTENDANCE_STARTED, + new NotificationTemplate( + "[${studyName}] 출석 체크", + "${scheduleName} 출석 체크가 시작되었습니다. 지금 출석해주세요!" + )); + TEMPLATES.put(NotificationType.ATTENDANCE_ENDED, + new NotificationTemplate( + "[${studyName}] 출석 체크 종료", + "${scheduleName} 출석 체크가 종료되었습니다." + )); + + // 스터디 업데이트 + TEMPLATES.put(NotificationType.NOTICE_CREATED, + new NotificationTemplate( + "[${studyName}] 새 공지사항", + "${noticeTitle}" + )); + TEMPLATES.put(NotificationType.SCHEDULE_CREATED, + new NotificationTemplate( + "[${studyName}] 새 일정", + "${scheduleName} 일정이 등록되었습니다." + )); + TEMPLATES.put(NotificationType.SCHEDULE_UPDATED, + new NotificationTemplate( + "[${studyName}] 일정 변경", + "${scheduleName} 일정이 변경되었습니다." + )); + TEMPLATES.put(NotificationType.TODO_COMPLETED, + new NotificationTemplate( + "[${studyName}] 할 일 완료", + "할 일이 완료되었습니다: ${todoTitle}" + )); + + // 예약 알림 + TEMPLATES.put(NotificationType.SCHEDULE_REMINDER, + new NotificationTemplate( + "[${studyName}] 일정 알림", + "${scheduleName}이(가) 곧 시작됩니다." + )); + TEMPLATES.put(NotificationType.HOT_POST_DAILY, + new NotificationTemplate( + "오늘의 인기글", + "${postTitle}" + )); + } + + public NotificationContent render(NotificationType type, Map payload) { + NotificationTemplate template = TEMPLATES.get(type); + if (template == null) { + throw new GeneralException(ErrorStatus._NOTIFICATION_TEMPLATE_NOT_FOUND); + } + + String title = interpolate(template.titleTemplate(), payload); + String body = interpolate(template.bodyTemplate(), payload); + + return new NotificationContent(title, body); + } + + private String interpolate(String template, Map payload) { + String result = template; + for (Map.Entry entry : payload.entrySet()) { + String placeholder = "${" + entry.getKey() + "}"; + String value = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + result = result.replace(placeholder, value); + } + return result; + } +} diff --git a/modules/notification/src/main/java/kr/spot/domain/Notification.java b/modules/notification/src/main/java/kr/spot/domain/Notification.java index 431f38bd..0756de56 100644 --- a/modules/notification/src/main/java/kr/spot/domain/Notification.java +++ b/modules/notification/src/main/java/kr/spot/domain/Notification.java @@ -1,37 +1,172 @@ package kr.spot.domain; -import jakarta.persistence.Embedded; +import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; -import kr.spot.domain.vo.Content; -import kr.spot.domain.vo.NotificationTarget; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.enums.NotificationStatus; +import kr.spot.domain.enums.NotificationType; +import kr.spot.exception.GeneralException; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; @Getter @Entity -@SQLDelete(sql = "UPDATE notification SET status = 'INACTIVE' WHERE id = ?") -@SQLRestriction("status = 'ACTIVE'") +@Table(name = "notification", indexes = { + @Index(name = "idx_dispatch", columnList = "dispatchStatus, scheduledAt, pickedBy"), + @Index(name = "idx_member_created", columnList = "memberId, createdAt"), + @Index(name = "idx_retry", columnList = "dispatchStatus, nextRetryAt") +}) @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Notification extends BaseEntity { + private static final int DEFAULT_MAX_RETRY = 3; + @Id private Long id; - @Embedded - private Content content; + // 수신자 + @Column(nullable = false) + private Long memberId; + + // 알림 타입 + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private NotificationType type; + + // 알림 내용 + @Column(nullable = false, length = 100) + private String title; + + @Column(nullable = false, length = 500) + private String body; + + @Column(length = 500) + private String imageUrl; + + // 연관 데이터 (딥링크용) + @Column(length = 50) + private String referenceType; + + private Long referenceId; + + // 발송 상태 관리 + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NotificationStatus dispatchStatus; + + @Column(nullable = false) + private LocalDateTime scheduledAt; + + private LocalDateTime sentAt; + + // 동시성 제어 & 재시도 + @Column(length = 50) + private String pickedBy; + + private LocalDateTime pickedAt; + + @Column(nullable = false) + private int retryCount; + + @Column(nullable = false) + private int maxRetry; - @Embedded - private NotificationTarget target; + private LocalDateTime nextRetryAt; - private Boolean isChecked; + // 중복 방지 + @Column(unique = true, length = 200) + private String dedupeKey; + + // 실패 정보 + @Column(length = 500) + private String lastError; + + // 읽음 여부 + @Column(nullable = false) + private boolean isRead; + + private Notification( + Long id, + Long memberId, + NotificationType type, + String title, + String body, + String imageUrl, + String referenceType, + Long referenceId, + LocalDateTime scheduledAt, + String dedupeKey + ) { + this.id = id; + this.memberId = memberId; + this.type = type; + this.title = title; + this.body = body; + this.imageUrl = imageUrl; + this.referenceType = referenceType; + this.referenceId = referenceId; + this.dispatchStatus = NotificationStatus.PENDING; + this.scheduledAt = scheduledAt; + this.retryCount = 0; + this.maxRetry = DEFAULT_MAX_RETRY; + this.isRead = false; + } + + public static Notification create( + long id, + long memberId, + NotificationType type, + String title, + String body, + String imageUrl, + String referenceType, + Long referenceId, + LocalDateTime scheduledAt, + String dedupeKey + ) { + return new Notification( + id, memberId, type, title, body, imageUrl, + referenceType, referenceId, scheduledAt, dedupeKey + ); + } + + public void markAsSent(LocalDateTime now) { + this.dispatchStatus = NotificationStatus.SENT; + this.sentAt = now; + this.pickedBy = null; + this.pickedAt = null; + } + + public void markAsFailed(String errorMessage) { + this.dispatchStatus = NotificationStatus.FAILED; + this.lastError = errorMessage; + this.pickedBy = null; + this.pickedAt = null; + } + + public void scheduleRetry(LocalDateTime nextRetryAt) { + this.dispatchStatus = NotificationStatus.PENDING; + this.retryCount++; + this.nextRetryAt = nextRetryAt; + this.pickedBy = null; + this.pickedAt = null; + } + + public boolean canRetry() { + return retryCount < maxRetry; + } - public static Notification of(Long id, Content content, NotificationTarget target) { - return new Notification(id, content, target, false); + public void markAsRead(long memberId) { + if (memberId != this.memberId) { + throw new GeneralException(ErrorStatus._NOTIFICATION_ACCESS_DENIED); + } + this.isRead = true; } } diff --git a/modules/notification/src/main/java/kr/spot/domain/enums/NotificationStatus.java b/modules/notification/src/main/java/kr/spot/domain/enums/NotificationStatus.java new file mode 100644 index 00000000..e48e5f3d --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/domain/enums/NotificationStatus.java @@ -0,0 +1,9 @@ +package kr.spot.domain.enums; + +public enum NotificationStatus { + PENDING, // 발송 대기 + PROCESSING, // 발송 처리 중 (서버가 선점) + SENT, // 발송 완료 + FAILED // 발송 실패 (최대 재시도 초과) +} + diff --git a/modules/notification/src/main/java/kr/spot/domain/enums/NotificationType.java b/modules/notification/src/main/java/kr/spot/domain/enums/NotificationType.java index e6fa68f8..8c1f3efc 100644 --- a/modules/notification/src/main/java/kr/spot/domain/enums/NotificationType.java +++ b/modules/notification/src/main/java/kr/spot/domain/enums/NotificationType.java @@ -1,9 +1,29 @@ package kr.spot.domain.enums; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor public enum NotificationType { - HOT_POST, - STUDY_ANNOUNCEMENT_UPDATE, - STUDY_TODO_UPDATE, - STUDY_SCHEDULE_UPDATE, - STUDY_APPLICATION_RESULT, + // 즉시 알림 - 스터디 신청 + STUDY_APPLICATION_APPROVED("스터디 승인", "STUDY"), + STUDY_APPLICATION_REJECTED("스터디 거절", "STUDY"), + + // 즉시 알림 - 출석 + ATTENDANCE_STARTED("출석 체크 시작", "SCHEDULE"), + ATTENDANCE_ENDED("출석 체크 종료", "SCHEDULE"), + + // 즉시 알림 - 스터디 업데이트 + NOTICE_CREATED("공지사항 등록", "NOTICE"), + SCHEDULE_CREATED("일정 등록", "SCHEDULE"), + SCHEDULE_UPDATED("일정 변경", "SCHEDULE"), + TODO_COMPLETED("할 일 완료", "TODO"), + + // 예약 알림 + SCHEDULE_REMINDER("일정 리마인드", "SCHEDULE"), + HOT_POST_DAILY("오늘의 인기글", "POST"); + + private final String description; + private final String referenceType; } diff --git a/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java b/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java deleted file mode 100644 index 73078bc6..00000000 --- a/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java +++ /dev/null @@ -1,31 +0,0 @@ -package kr.spot.domain.vo; - -import jakarta.persistence.Embeddable; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import kr.spot.domain.enums.NotificationType; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Embeddable -@EqualsAndHashCode -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class NotificationTarget { - - private Long targetMemberId; - - private Long linkStudyId; - - @Enumerated(EnumType.STRING) - private NotificationType notificationType; - - public static NotificationTarget of(Long targetMemberId, Long linkStudyId, - NotificationType notificationType) { - return new NotificationTarget(targetMemberId, linkStudyId, notificationType); - } -} diff --git a/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java new file mode 100644 index 00000000..39b54f30 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java @@ -0,0 +1,58 @@ +package kr.spot.infrastructure.jpa; + +import java.time.LocalDateTime; +import java.util.List; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationStatus; +import kr.spot.exception.GeneralException; +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; + +public interface NotificationRepository extends JpaRepository { + + void deleteByMemberId(long memberId); + + List findByMemberIdOrderByCreatedAtDesc(long memberId); + + boolean existsByMemberIdAndIsReadFalse(long memberId); + + /** + * 발송 대상 알림 선점 (FOR UPDATE SKIP LOCKED 사용) MySQL 8.0+에서 지원 + */ + @Modifying + @Query(value = """ + UPDATE notification n + SET n.dispatch_status = 'PROCESSING', + n.picked_by = :serverId, + n.picked_at = :now + WHERE n.id IN ( + SELECT id FROM ( + SELECT id FROM notification + WHERE dispatch_status = 'PENDING' + AND scheduled_at <= :now + AND picked_by IS NULL + ORDER BY scheduled_at ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + ) AS subquery + ) + """, nativeQuery = true) + int pickPendingNotifications( + @Param("serverId") String serverId, + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + /** + * 특정 서버가 선점한 알림 목록 조회 + */ + List findByPickedByAndDispatchStatus(String serverId, NotificationStatus status); + + default Notification getById(long id) { + return findById(id).orElseThrow( + () -> new GeneralException(ErrorStatus._NOTIFICATION_NOT_FOUND)); + } +} diff --git a/modules/notification/src/main/java/kr/spot/presentation/command/NotificationCommandController.java b/modules/notification/src/main/java/kr/spot/presentation/command/NotificationCommandController.java new file mode 100644 index 00000000..3da70e28 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/presentation/command/NotificationCommandController.java @@ -0,0 +1,34 @@ +package kr.spot.presentation.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.application.command.NotificationCommandService; +import kr.spot.code.status.SuccessStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "알림") +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationCommandController { + + private final NotificationCommandService notificationCommandService; + + @Operation(summary = "알림 읽음 처리", description = "특정 알림을 읽음 처리합니다.") + @PostMapping("/{notificationId}/read") + public ResponseEntity> markAsRead( + @PathVariable Long notificationId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + notificationCommandService.markAllAsRead(memberId, notificationId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/notification/src/main/java/kr/spot/presentation/query/NotificationQueryController.java b/modules/notification/src/main/java/kr/spot/presentation/query/NotificationQueryController.java new file mode 100644 index 00000000..b2dc877d --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/presentation/query/NotificationQueryController.java @@ -0,0 +1,50 @@ +package kr.spot.presentation.query; + +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.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.application.query.GetNotificationService; +import kr.spot.code.status.SuccessStatus; +import kr.spot.presentation.query.dto.GetNotificationListResponse; +import kr.spot.presentation.query.dto.GetUnreadNotificationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "알림") +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationQueryController { + + private final GetNotificationService getNotificationService; + + @Operation(summary = "내 알림 목록 조회", description = "로그인한 사용자의 알림 목록을 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @GetMapping("/me") + public ResponseEntity> getMyNotifications( + @CurrentMember @Parameter(hidden = true) Long memberId) { + GetNotificationListResponse response = getNotificationService.getMyNotifications(memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @Operation(summary = "읽지 않은 알림 존재 여부 조회", description = "로그인한 사용자의 읽지 않은 알림이 있는지 여부를 조회합니다.") + @GetMapping("/me/unread") + public ResponseEntity> hasUnreadNotifications( + @CurrentMember @Parameter(hidden = true) Long memberId) { + GetUnreadNotificationResponse response = getNotificationService.hasUnreadNotifications( + memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/notification/src/main/java/kr/spot/presentation/query/dto/GetNotificationListResponse.java b/modules/notification/src/main/java/kr/spot/presentation/query/dto/GetNotificationListResponse.java new file mode 100644 index 00000000..b0e90b8e --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/presentation/query/dto/GetNotificationListResponse.java @@ -0,0 +1,45 @@ +package kr.spot.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; +import kr.spot.domain.Notification; + +public record GetNotificationListResponse( + List notifications, + long totalCount +) { + + public static GetNotificationListResponse from(List notifications) { + List responses = notifications.stream() + .map(NotificationResponse::from) + .toList(); + return new GetNotificationListResponse(responses, responses.size()); + } + + public record NotificationResponse( + Long notificationId, + String type, + String title, + String body, + String imageUrl, + String referenceType, + Long referenceId, + boolean isRead, + LocalDateTime createdAt + ) { + + public static NotificationResponse from(Notification notification) { + return new NotificationResponse( + notification.getId(), + notification.getType().name(), + notification.getTitle(), + notification.getBody(), + notification.getImageUrl(), + notification.getReferenceType(), + notification.getReferenceId(), + notification.isRead(), + notification.getCreatedAt() + ); + } + } +} diff --git a/modules/notification/src/main/java/kr/spot/presentation/query/dto/GetUnreadNotificationResponse.java b/modules/notification/src/main/java/kr/spot/presentation/query/dto/GetUnreadNotificationResponse.java new file mode 100644 index 00000000..c15fbcf1 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/presentation/query/dto/GetUnreadNotificationResponse.java @@ -0,0 +1,10 @@ +package kr.spot.presentation.query.dto; + +public record GetUnreadNotificationResponse( + boolean hasUnreadNotifications +) { + + public static GetUnreadNotificationResponse of(boolean hasUnreadNotifications) { + return new GetUnreadNotificationResponse(hasUnreadNotifications); + } +} diff --git a/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java b/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java new file mode 100644 index 00000000..8ae5e70d --- /dev/null +++ b/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java @@ -0,0 +1,41 @@ +package kr.spot.application.event; + +import static org.mockito.Mockito.verify; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.NotificationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CleanupNotificationOnMemberWithdrawnTest { + + private static final long MEMBER_ID = 1L; + + @Mock + NotificationRepository notificationRepository; + + CleanupNotificationOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new CleanupNotificationOnMemberWithdrawn(notificationRepository); + } + + @Test + @DisplayName("회원 탈퇴 시 알림을 삭제한다") + void should_deleteNotifications_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + verify(notificationRepository).deleteByMemberId(MEMBER_ID); + } +} diff --git a/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java b/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java new file mode 100644 index 00000000..99cc5002 --- /dev/null +++ b/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java @@ -0,0 +1,96 @@ +package kr.spot.application.event; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import kr.spot.IdGenerator; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationStatus; +import kr.spot.domain.enums.NotificationType; +import kr.spot.event.StudyApplicationProcessedEvent; +import kr.spot.infrastructure.jpa.NotificationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class StudyApplicationNotificationListenerTest { + + @Mock + IdGenerator idGenerator; + + @Mock + NotificationRepository notificationRepository; + + @Captor + ArgumentCaptor notificationCaptor; + + StudyApplicationNotificationListener listener; + + @BeforeEach + void setUp() { + listener = new StudyApplicationNotificationListener(idGenerator, notificationRepository); + } + + @Test + @DisplayName("승인 이벤트 수신 시 알림이 저장된다") + void should_save_notification_when_approved() { + // given + Long notificationId = 100L; + StudyApplicationProcessedEvent event = StudyApplicationProcessedEvent.of( + 1L, 2L, 3L, "APPROVE", "자바 스터디", "http://image.url/java.png"); + + when(idGenerator.nextId()).thenReturn(notificationId); + when(notificationRepository.save(any(Notification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + listener.handle(event); + + // then + verify(notificationRepository).save(notificationCaptor.capture()); + + Notification notification = notificationCaptor.getValue(); + assertThat(notification.getId()).isEqualTo(notificationId); + assertThat(notification.getTitle()).isEqualTo("자바 스터디"); + assertThat(notification.getBody()).contains("승인"); + assertThat(notification.getMemberId()).isEqualTo(2L); + assertThat(notification.getReferenceId()).isEqualTo(1L); + assertThat(notification.getReferenceType()).isEqualTo("STUDY"); + assertThat(notification.getType()).isEqualTo(NotificationType.STUDY_APPLICATION_APPROVED); + assertThat(notification.getDispatchStatus()).isEqualTo(NotificationStatus.PENDING); + } + + @Test + @Disabled + @DisplayName("거절 이벤트 수신 시 알림이 저장된다") + void should_save_notification_when_rejected() { + // given + Long notificationId = 100L; + StudyApplicationProcessedEvent event = StudyApplicationProcessedEvent.of( + 1L, 2L, 3L, "REJECT", "자바 스터디", "http://image.url"); + + when(idGenerator.nextId()).thenReturn(notificationId); + when(notificationRepository.save(any(Notification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + listener.handle(event); + + // then + verify(notificationRepository).save(notificationCaptor.capture()); + + Notification notification = notificationCaptor.getValue(); + assertThat(notification.getTitle()).isEqualTo("자바 스터디"); + assertThat(notification.getBody()).contains("거절"); + assertThat(notification.getType()).isEqualTo(NotificationType.STUDY_APPLICATION_REJECTED); + } +} diff --git a/modules/post/build.gradle b/modules/post/build.gradle index 94311469..f2d9ac25 100644 --- a/modules/post/build.gradle +++ b/modules/post/build.gradle @@ -8,6 +8,8 @@ dependencies { implementation project(":common:api") implementation project(":common:snowflake") implementation project(":common:bucket") + implementation project(":common:view") + implementation project(":infra:view-redis") implementation project(":modules:shared") implementation project(":modules:member-api") diff --git a/modules/post/src/main/java/kr/spot/application/command/ManagePostService.java b/modules/post/src/main/java/kr/spot/application/command/ManagePostService.java index c23bb689..0f7d3a7f 100644 --- a/modules/post/src/main/java/kr/spot/application/command/ManagePostService.java +++ b/modules/post/src/main/java/kr/spot/application/command/ManagePostService.java @@ -4,17 +4,20 @@ import kr.spot.code.status.ErrorStatus; import kr.spot.domain.Post; import kr.spot.domain.PostStats; +import kr.spot.domain.Report; import kr.spot.domain.association.PostImage; import kr.spot.domain.vo.WriterInfo; import kr.spot.exception.GeneralException; import kr.spot.infrastructure.jpa.PostImageRepository; import kr.spot.infrastructure.jpa.PostRepository; import kr.spot.infrastructure.jpa.PostStatsRepository; +import kr.spot.infrastructure.jpa.ReportRepository; import kr.spot.ports.FileStoragePort; import kr.spot.ports.GetWriterInfoPort; import kr.spot.ports.dto.UploadResult; import kr.spot.ports.dto.WriterInfoResponse; import kr.spot.presentation.command.dto.request.ManagePostRequest; +import kr.spot.presentation.command.dto.request.ReportPostRequest; import kr.spot.presentation.command.dto.response.CreatePostResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -34,6 +37,7 @@ public class ManagePostService { private final PostRepository postRepository; private final PostStatsRepository postStatsRepository; private final PostImageRepository postImageRepository; + private final ReportRepository reportRepository; public CreatePostResponse createPost(ManagePostRequest request, Long writerId, MultipartFile imageFile) { @@ -62,6 +66,12 @@ public void deletePost(Long postId, Long writerId) { } } + public void reportPost(long postId, long reporterId, ReportPostRequest request) { + postRepository.validateExists(postId); + Report report = Report.of(idGenerator.nextId(), postId, reporterId, request.reason()); + reportRepository.save(report); + } + private WriterInfo getWriterInfo(Long writerId) { WriterInfoResponse writerInfoResponse = getWriterInfoPort.get(writerId); return WriterInfo.of(writerInfoResponse.writerId(), writerInfoResponse.nickname(), @@ -79,7 +89,7 @@ private void updatePostImage(Long postId, MultipartFile imageFiles) { if (imageFiles == null || imageFiles.isEmpty()) { return; } - + UploadResult upload = fileStoragePort.upload(imageFiles, FILE_DIR); postImageRepository.deleteByPostId(postId); PostImage postImage = PostImage.of(idGenerator.nextId(), postId, upload.url()); diff --git a/modules/post/src/main/java/kr/spot/application/event/CleanupPostOnMemberWithdrawn.java b/modules/post/src/main/java/kr/spot/application/event/CleanupPostOnMemberWithdrawn.java new file mode 100644 index 00000000..3c751960 --- /dev/null +++ b/modules/post/src/main/java/kr/spot/application/event/CleanupPostOnMemberWithdrawn.java @@ -0,0 +1,27 @@ +package kr.spot.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.CommentRepository; +import kr.spot.infrastructure.jpa.PostLikeRepository; +import kr.spot.infrastructure.jpa.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class CleanupPostOnMemberWithdrawn { + + private final PostLikeRepository postLikeRepository; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + long memberId = event.memberId(); + postLikeRepository.deleteAllByMemberId(memberId); + commentRepository.deleteByWriterInfoWriterId(memberId); + postRepository.deleteByWriterInfoWriterId(memberId); + } +} diff --git a/modules/post/src/main/java/kr/spot/application/ports/PostViewCounter.java b/modules/post/src/main/java/kr/spot/application/ports/PostViewCounter.java deleted file mode 100644 index 1599d504..00000000 --- a/modules/post/src/main/java/kr/spot/application/ports/PostViewCounter.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.spot.application.ports; - -public interface PostViewCounter { - - long incrementAndGetDelta(long postId); - - long currentDelta(long postId); -} diff --git a/modules/post/src/main/java/kr/spot/application/ports/ViewAbuseGuard.java b/modules/post/src/main/java/kr/spot/application/ports/ViewAbuseGuard.java deleted file mode 100644 index ec29dd63..00000000 --- a/modules/post/src/main/java/kr/spot/application/ports/ViewAbuseGuard.java +++ /dev/null @@ -1,6 +0,0 @@ -package kr.spot.application.ports; - -public interface ViewAbuseGuard { - - boolean shouldCount(long postId, long viewerId); -} \ No newline at end of file diff --git a/modules/post/src/main/java/kr/spot/application/query/GetPostService.java b/modules/post/src/main/java/kr/spot/application/query/GetPostService.java index 2c8121a2..bfaf4725 100644 --- a/modules/post/src/main/java/kr/spot/application/query/GetPostService.java +++ b/modules/post/src/main/java/kr/spot/application/query/GetPostService.java @@ -10,8 +10,9 @@ import java.util.Map; import java.util.Set; import kr.spot.application.ports.HotPostStore; -import kr.spot.application.ports.PostViewCounter; -import kr.spot.application.ports.ViewAbuseGuard; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; import kr.spot.domain.Comment; import kr.spot.domain.Post; import kr.spot.domain.PostStats; @@ -44,7 +45,7 @@ public class GetPostService { public static final int MAX_PAGE_SIZE = 50; public static final int MAX_CONTENT_LENGTH = 100; - private final PostViewCounter postViewCounter; + private final ViewCounter viewCounter; private final ViewAbuseGuard viewAbuseGuard; private final HotPostStore hotPostStore; private final PostRepository postRepository; @@ -126,10 +127,10 @@ public RecentPostResponse getRecentPosts() { private long getViewDeltaFromCounter(Long postId, Long viewerId) { long viewDelta = 0L; try { - if (viewAbuseGuard.shouldCount(postId, viewerId)) { - viewDelta = postViewCounter.incrementAndGetDelta(postId); // 델타 증가 및 현재값 반환 + if (viewAbuseGuard.shouldCount(ViewableType.POST, postId, viewerId)) { + viewDelta = viewCounter.incrementAndGet(ViewableType.POST, postId); } else { - viewDelta = postViewCounter.currentDelta(postId); // 델타만 조회 + viewDelta = viewCounter.currentDelta(ViewableType.POST, postId); } } catch (Exception ignore) { log.warn("Redis view counter access failed for postId: {}", postId, ignore); diff --git a/modules/study/src/main/java/kr/spot/domain/Region.java b/modules/post/src/main/java/kr/spot/domain/Report.java similarity index 56% rename from modules/study/src/main/java/kr/spot/domain/Region.java rename to modules/post/src/main/java/kr/spot/domain/Report.java index efe867aa..2aed4d92 100644 --- a/modules/study/src/main/java/kr/spot/domain/Region.java +++ b/modules/post/src/main/java/kr/spot/domain/Report.java @@ -1,5 +1,6 @@ package kr.spot.domain; + import jakarta.persistence.Entity; import jakarta.persistence.Id; import lombok.AccessLevel; @@ -9,25 +10,24 @@ import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; - @Getter @Entity -@SQLDelete(sql = "UPDATE region SET status = 'INACTIVE' WHERE id = ?") +@SQLDelete(sql = "UPDATE report SET status = 'INACTIVE' WHERE id = ?") @SQLRestriction("status = 'ACTIVE'") @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Region extends BaseEntity { +public class Report extends BaseEntity { @Id - private String code; + private Long id; - private String province; + private Long reportedPostId; - private String district; + private Long reporterId; - private String neighborhood; + private String reason; - public static Region of(String code, String province, String district, String neighborhood) { - return new Region(code, province, district, neighborhood); + public static Report of(Long id, Long reportedPostId, Long reporterId, String reason) { + return new Report(id, reportedPostId, reporterId, reason); } } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/batch/PostViewFlusher.java b/modules/post/src/main/java/kr/spot/infrastructure/batch/PostViewFlusher.java index 579236f2..4e7fbc54 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/batch/PostViewFlusher.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/batch/PostViewFlusher.java @@ -17,6 +17,6 @@ public class PostViewFlusher { @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateViewCount(Long postId, long delta) { postStatsRepository.increaseViewBy(postId, delta); - log.debug("DB 업데이트: postId={}, delta={}", postId, delta); + log.debug("게시글 조회수 DB 업데이트: postId={}, delta={}", postId, delta); } } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/jpa/CommentRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/CommentRepository.java index 3913596c..0b832fdc 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/jpa/CommentRepository.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/CommentRepository.java @@ -17,4 +17,6 @@ default Comment getById(Long id) { return findById(id) .orElseThrow(() -> new GeneralException(ErrorStatus._COMMENT_NOT_FOUND)); } + + void deleteByWriterInfoWriterId(long writerId); } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostLikeRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostLikeRepository.java index 94236e03..c53364f1 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostLikeRepository.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostLikeRepository.java @@ -26,4 +26,8 @@ int savePostLike(@Param("id") long id, """, nativeQuery = true) int hardDelete(@Param("postId") Long postId, @Param("memberId") Long memberId); + + @Modifying + @Query(value = "DELETE FROM post_like WHERE member_id = :memberId", nativeQuery = true) + void deleteAllByMemberId(@Param("memberId") long memberId); } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java index e78bff12..a29917a4 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java @@ -19,9 +19,6 @@ public interface PostRepository extends JpaRepository { @Query("select p from Post p where p.id = :id") Optional findByIdWithLock(long id); - @Query("select p from Post p where p.postType = :postType order by p.id desc") - Optional findTopByPostTypeOrderByIdDesc(PostType postType); - @Modifying @Query(""" update Post p @@ -62,4 +59,12 @@ default Post getPostByIdWithLock(long id) { default Post getPostById(long id) { return findById(id).orElseThrow(() -> new GeneralException(ErrorStatus._POST_NOT_FOUND)); } + + default void validateExists(long postId) { + if (!existsById(postId)) { + throw new GeneralException(ErrorStatus._POST_NOT_FOUND); + } + } + + void deleteByWriterInfoWriterId(long writerId); } diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/RegionRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java similarity index 53% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/RegionRepository.java rename to modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java index 84cbf4c2..73a9281a 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/RegionRepository.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java @@ -1,8 +1,8 @@ package kr.spot.infrastructure.jpa; -import kr.spot.domain.Region; +import kr.spot.domain.Report; import org.springframework.data.jpa.repository.JpaRepository; -public interface RegionRepository extends JpaRepository { - +public interface ReportRepository extends JpaRepository { + } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/rdb/DbViewAbuseGuard.java b/modules/post/src/main/java/kr/spot/infrastructure/rdb/DbViewAbuseGuard.java index 8688a2af..a96b7ccc 100644 --- a/modules/post/src/main/java/kr/spot/infrastructure/rdb/DbViewAbuseGuard.java +++ b/modules/post/src/main/java/kr/spot/infrastructure/rdb/DbViewAbuseGuard.java @@ -1,35 +1,43 @@ package kr.spot.infrastructure.rdb; import kr.spot.IdGenerator; -import kr.spot.application.ports.ViewAbuseGuard; import kr.spot.infrastructure.jpa.PostViewHistoryRepository; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewableType; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +/** + * DB 기반 조회 어뷰징 방지 (현재 미사용 - Redis 사용 중) + * POST 타입만 지원 + */ //@Component @Transactional(propagation = Propagation.REQUIRES_NEW) @RequiredArgsConstructor public class DbViewAbuseGuard implements ViewAbuseGuard { - public static final int TTL_MINUTES = 10; - public static final int ZERO = 0; + private static final int TTL_MINUTES = 10; + private static final int ZERO = 0; private final IdGenerator idGenerator; private final PostViewHistoryRepository postViewHistoryRepository; @Override - public boolean shouldCount(long postId, long viewerId) { + public boolean shouldCount(ViewableType type, long targetId, long viewerId) { + if (type != ViewableType.POST) { + throw new UnsupportedOperationException("DbViewAbuseGuard only supports POST type"); + } int updated = postViewHistoryRepository.touchIfExpired( - viewerId, postId, TTL_MINUTES + viewerId, targetId, TTL_MINUTES ); if (updated > ZERO) { return true; } int inserted = postViewHistoryRepository.insertIfAbsent( - idGenerator.nextId(), viewerId, postId + idGenerator.nextId(), viewerId, targetId ); return inserted > ZERO; } diff --git a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisPostViewCounter.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisPostViewCounter.java deleted file mode 100644 index 124c965d..00000000 --- a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisPostViewCounter.java +++ /dev/null @@ -1,31 +0,0 @@ -package kr.spot.infrastructure.redis; - -import kr.spot.application.ports.PostViewCounter; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class RedisPostViewCounter implements PostViewCounter { - - public static final String DELTA_KEY = "view:post:%d"; - - private final StringRedisTemplate redis; - - private static String deltaKey(long postId) { - return DELTA_KEY.formatted(postId); - } - - @Override - public long incrementAndGetDelta(long postId) { - Long v = redis.opsForValue().increment(deltaKey(postId)); - return v == null ? 0L : v; - } - - @Override - public long currentDelta(long postId) { - String v = redis.opsForValue().get(deltaKey(postId)); - return (v == null) ? 0L : Long.parseLong(v); - } -} diff --git a/modules/post/src/main/java/kr/spot/presentation/command/PostCommandController.java b/modules/post/src/main/java/kr/spot/presentation/command/PostCommandController.java index a551e088..384f64bc 100644 --- a/modules/post/src/main/java/kr/spot/presentation/command/PostCommandController.java +++ b/modules/post/src/main/java/kr/spot/presentation/command/PostCommandController.java @@ -13,6 +13,7 @@ import kr.spot.application.command.ManagePostService; import kr.spot.code.status.SuccessStatus; import kr.spot.presentation.command.dto.request.ManagePostRequest; +import kr.spot.presentation.command.dto.request.ReportPostRequest; import kr.spot.presentation.command.dto.response.CreatePostResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -20,6 +21,7 @@ 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.RequestPart; import org.springframework.web.bind.annotation.RestController; @@ -113,4 +115,19 @@ public ResponseEntity> unlikePost( likePostService.unlikePost(postId, writerId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); } + + @Operation(summary = "게시글 신고", description = "특정 게시글을 신고합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "신고 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: 게시글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) + }) + @PostMapping("/{postId}/report") + public ResponseEntity> reportPost( + @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long reporterId, + @RequestBody ReportPostRequest request) { + managePostService.reportPost(postId, reporterId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } } diff --git a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/ApplyStudyRequest.java b/modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java similarity index 52% rename from modules/study/src/main/java/kr/spot/presentation/command/dto/request/ApplyStudyRequest.java rename to modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java index 170e3659..c3e12f5f 100644 --- a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/ApplyStudyRequest.java +++ b/modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java @@ -1,7 +1,7 @@ package kr.spot.presentation.command.dto.request; -public record ApplyStudyRequest( - String message +public record ReportPostRequest( + String reason ) { } diff --git a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostDetailResponse.java b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostDetailResponse.java index e73540ef..faf53717 100644 --- a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostDetailResponse.java +++ b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostDetailResponse.java @@ -18,7 +18,7 @@ public record PostDetailResponse( PostStatsResponse stats, LocalDateTime createdAt, List comments, - Integer commentCount + int commentCount ) { public record CommentResponse( @@ -44,5 +44,4 @@ public static WriterInfoResponse of(Long writerId, String nickname, String profi return new WriterInfoResponse(writerId, nickname, profileImageUrl); } } - } diff --git a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostOverviewResponse.java b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostOverviewResponse.java index efbcda2b..80247db3 100644 --- a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostOverviewResponse.java +++ b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostOverviewResponse.java @@ -16,12 +16,12 @@ public record PostOverview( Long postId, String title, String content, - Long commentCount, + long commentCount, PostType postType ) { public static PostOverview of(Long postId, String title, String content, - Long commentCount, PostType postType) { + long commentCount, PostType postType) { return new PostOverview(postId, title, content, commentCount, postType); } } diff --git a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostStatsResponse.java b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostStatsResponse.java index d23ff5c9..c439306b 100644 --- a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostStatsResponse.java +++ b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/PostStatsResponse.java @@ -1,12 +1,12 @@ package kr.spot.presentation.query.dto.response; public record PostStatsResponse( - Long likeCount, - Long viewCount, - Long commentCount + long likeCount, + long viewCount, + long commentCount ) { - public static PostStatsResponse from(Long likeCount, Long viewCount, Long commentCount) { + public static PostStatsResponse from(long likeCount, long viewCount, long commentCount) { return new PostStatsResponse(likeCount, viewCount, commentCount); } } diff --git a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/RecentPostResponse.java b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/RecentPostResponse.java index 1d5ac8ff..f280cb33 100644 --- a/modules/post/src/main/java/kr/spot/presentation/query/dto/response/RecentPostResponse.java +++ b/modules/post/src/main/java/kr/spot/presentation/query/dto/response/RecentPostResponse.java @@ -14,11 +14,11 @@ public static RecentPostResponse of(List recentPosts) { public record RecentPost( Long postId, String title, - Long commentCount, + long commentCount, PostType postType ) { - public static RecentPost of(Long postId, String title, Long commentCount, PostType postType) { + public static RecentPost of(Long postId, String title, long commentCount, PostType postType) { return new RecentPost(postId, title, commentCount, postType); } } diff --git a/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceImageTest.java b/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceImageTest.java index b22a6fc9..49530330 100644 --- a/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceImageTest.java +++ b/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceImageTest.java @@ -21,6 +21,7 @@ import kr.spot.infrastructure.jpa.PostImageRepository; import kr.spot.infrastructure.jpa.PostRepository; import kr.spot.infrastructure.jpa.PostStatsRepository; +import kr.spot.infrastructure.jpa.ReportRepository; import kr.spot.ports.FileStoragePort; import kr.spot.ports.GetWriterInfoPort; import kr.spot.ports.dto.UploadResult; @@ -59,6 +60,9 @@ class ManagePostServiceImageTest { @Mock PostImageRepository postImageRepository; + @Mock + ReportRepository reportRepository; + @Captor ArgumentCaptor postCaptor; @@ -73,8 +77,7 @@ class ManagePostServiceImageTest { @BeforeEach void setUp() { managePostService = new ManagePostService(idGenerator, getWriterInfoPort, fileStoragePort, - postRepository, - postStatsRepository, postImageRepository); + postRepository, postStatsRepository, postImageRepository, reportRepository); } @Test diff --git a/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceTest.java b/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceTest.java index da0da492..e6a3f37e 100644 --- a/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceTest.java +++ b/modules/post/src/test/java/kr/spot/application/command/ManagePostServiceTest.java @@ -3,17 +3,27 @@ import static kr.spot.common.PostFixture.CONTENT; import static kr.spot.common.PostFixture.OTHER_WRITER_ID; import static kr.spot.common.PostFixture.POST_ID; +import static kr.spot.common.PostFixture.REPORTER_ID; +import static kr.spot.common.PostFixture.REPORT_REASON; import static kr.spot.common.PostFixture.TITLE; import static kr.spot.common.PostFixture.WRITER_ID; +import static kr.spot.common.PostFixture.reportPostRequest; import static kr.spot.common.PostFixture.writerInfo; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; import kr.spot.common.PostFixture; import kr.spot.domain.Post; +import kr.spot.domain.Report; import kr.spot.domain.enums.PostType; import kr.spot.domain.vo.WriterInfo; import kr.spot.exception.GeneralException; @@ -21,13 +31,18 @@ import kr.spot.infrastructure.jpa.PostLikeRepository; import kr.spot.infrastructure.jpa.PostRepository; import kr.spot.infrastructure.jpa.PostStatsRepository; +import kr.spot.infrastructure.jpa.ReportRepository; import kr.spot.ports.FileStoragePort; import kr.spot.ports.GetWriterInfoPort; +import kr.spot.presentation.command.dto.request.ReportPostRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -56,14 +71,19 @@ class ManagePostServiceTest { @Mock PostImageRepository postImageRepository; + @Mock + ReportRepository reportRepository; + + @Captor + ArgumentCaptor reportCaptor; + ManagePostService managePostService; LikePostService likePostService; @BeforeEach void setUp() { managePostService = new ManagePostService(idGenerator, getWriterInfoPort, fileStoragePort, - postRepository, - postStatsRepository, postImageRepository); + postRepository, postStatsRepository, postImageRepository, reportRepository); likePostService = new LikePostService(idGenerator, postLikeRepository, postStatsRepository); } @@ -138,4 +158,75 @@ void should_process_successfully_when_unliking_not_liked_post() { // when & then likePostService.unlikePost(POST_ID, WRITER_ID); } + + @Nested + @DisplayName("게시글 신고 (reportPost)") + class ReportPost { + + @Test + @DisplayName("게시글을 정상적으로 신고할 수 있다") + void should_report_post_successfully() { + // given + long generatedId = 100L; + ReportPostRequest request = reportPostRequest(); + + doNothing().when(postRepository).validateExists(anyLong()); + when(idGenerator.nextId()).thenReturn(generatedId); + when(reportRepository.save(any(Report.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + managePostService.reportPost(POST_ID, REPORTER_ID, request); + + // then + verify(postRepository).validateExists(POST_ID); + verify(reportRepository).save(reportCaptor.capture()); + + Report capturedReport = reportCaptor.getValue(); + assertThat(capturedReport.getId()).isEqualTo(generatedId); + assertThat(capturedReport.getReportedPostId()).isEqualTo(POST_ID); + assertThat(capturedReport.getReporterId()).isEqualTo(REPORTER_ID); + assertThat(capturedReport.getReason()).isEqualTo(REPORT_REASON); + } + + @Test + @DisplayName("존재하지 않는 게시글을 신고하면 예외가 발생한다") + void should_throw_exception_when_post_not_found() { + // given + long nonExistentPostId = 999L; + ReportPostRequest request = reportPostRequest(); + + doThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND)) + .when(postRepository).validateExists(anyLong()); + + // when & then + assertThatThrownBy( + () -> managePostService.reportPost(nonExistentPostId, REPORTER_ID, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND); + + verify(reportRepository, never()).save(any()); + } + + @Test + @DisplayName("동일한 게시글을 여러 번 신고할 수 있다") + void should_allow_multiple_reports_on_same_post() { + // given + long generatedId1 = 100L; + long generatedId2 = 101L; + ReportPostRequest request = reportPostRequest(); + + doNothing().when(postRepository).validateExists(anyLong()); + when(idGenerator.nextId()).thenReturn(generatedId1, generatedId2); + when(reportRepository.save(any(Report.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + managePostService.reportPost(POST_ID, REPORTER_ID, request); + managePostService.reportPost(POST_ID, OTHER_WRITER_ID, request); + + // then + verify(reportRepository, org.mockito.Mockito.times(2)).save(any(Report.class)); + } + } } diff --git a/modules/post/src/test/java/kr/spot/application/event/CleanupPostOnMemberWithdrawnTest.java b/modules/post/src/test/java/kr/spot/application/event/CleanupPostOnMemberWithdrawnTest.java new file mode 100644 index 00000000..650bf0d5 --- /dev/null +++ b/modules/post/src/test/java/kr/spot/application/event/CleanupPostOnMemberWithdrawnTest.java @@ -0,0 +1,53 @@ +package kr.spot.application.event; + +import static org.mockito.Mockito.inOrder; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.infrastructure.jpa.CommentRepository; +import kr.spot.infrastructure.jpa.PostLikeRepository; +import kr.spot.infrastructure.jpa.PostRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CleanupPostOnMemberWithdrawnTest { + + private static final long MEMBER_ID = 1L; + + @Mock + PostLikeRepository postLikeRepository; + + @Mock + CommentRepository commentRepository; + + @Mock + PostRepository postRepository; + + CleanupPostOnMemberWithdrawn handler; + + @BeforeEach + void setUp() { + handler = new CleanupPostOnMemberWithdrawn(postLikeRepository, commentRepository, postRepository); + } + + @Test + @DisplayName("회원 탈퇴 시 좋아요, 댓글, 게시글 순서로 삭제한다") + void should_deleteLikesThenCommentsThenPosts_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + + // when + handler.handle(event); + + // then + InOrder inOrder = inOrder(postLikeRepository, commentRepository, postRepository); + inOrder.verify(postLikeRepository).deleteAllByMemberId(MEMBER_ID); + inOrder.verify(commentRepository).deleteByWriterInfoWriterId(MEMBER_ID); + inOrder.verify(postRepository).deleteByWriterInfoWriterId(MEMBER_ID); + } +} diff --git a/modules/post/src/test/java/kr/spot/application/query/GetPostServiceTest.java b/modules/post/src/test/java/kr/spot/application/query/GetPostServiceTest.java index f70e3b26..f4df4a6d 100644 --- a/modules/post/src/test/java/kr/spot/application/query/GetPostServiceTest.java +++ b/modules/post/src/test/java/kr/spot/application/query/GetPostServiceTest.java @@ -19,8 +19,9 @@ import java.util.Set; import java.util.stream.IntStream; import kr.spot.application.ports.HotPostStore; -import kr.spot.application.ports.PostViewCounter; -import kr.spot.application.ports.ViewAbuseGuard; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; import kr.spot.code.status.ErrorStatus; import kr.spot.common.PostFixture; import kr.spot.domain.Post; @@ -49,7 +50,7 @@ class GetPostServiceTest { private static final String KEY = "popular:top3:total"; @Mock - PostViewCounter postViewCounter; + ViewCounter viewCounter; @Mock ViewAbuseGuard viewAbuseGuard; @@ -76,7 +77,7 @@ class GetPostServiceTest { @BeforeEach void setUp() { - getPostService = new GetPostService(postViewCounter, viewAbuseGuard, hotPostStore, + getPostService = new GetPostService(viewCounter, viewAbuseGuard, hotPostStore, postRepository, postQueryRepository, commentRepository, @@ -196,22 +197,22 @@ void should_get_post_without_comments() { // ---------------- 추가: 뷰 델타/가드/장애 케이스 ---------------- @Test - @DisplayName("어뷰징 가드 통과 시: incrementAndGetDelta를 호출해 DB viewCount + 델타로 노출") + @DisplayName("어뷰징 가드 통과 시: incrementAndGet을 호출해 DB viewCount + 델타로 노출") void should_add_delta_when_guard_allows() { long postId = 1L, viewerId = 10L; Post post = post(); - PostStats stats = postStats(); // 예: DB viewCount = 0L 가정 + PostStats stats = postStats(); when(postRepository.getPostById(postId)).thenReturn(post); when(postStatsRepository.getPostStatsById(postId)).thenReturn(stats); when(postImageRepository.getPostImageByPostId(postId)).thenReturn(postImage()); - when(viewAbuseGuard.shouldCount(postId, viewerId)).thenReturn(true); - when(postViewCounter.incrementAndGetDelta(postId)).thenReturn(5L); + when(viewAbuseGuard.shouldCount(ViewableType.POST, postId, viewerId)).thenReturn(true); + when(viewCounter.incrementAndGet(ViewableType.POST, postId)).thenReturn(5L); var res = getPostService.getPostDetail(postId, viewerId); - verify(viewAbuseGuard).shouldCount(postId, viewerId); - verify(postViewCounter).incrementAndGetDelta(postId); + verify(viewAbuseGuard).shouldCount(ViewableType.POST, postId, viewerId); + verify(viewCounter).incrementAndGet(ViewableType.POST, postId); assertThat(res.stats().viewCount()).isEqualTo(stats.getViewCount() + 5L); } @@ -220,18 +221,18 @@ void should_add_delta_when_guard_allows() { void should_use_current_delta_when_guard_blocks() { long postId = 1L, viewerId = 10L; Post post = post(); - PostStats stats = postStats(); // DB viewCount = 0L 가정 + PostStats stats = postStats(); when(postRepository.getPostById(postId)).thenReturn(post); when(postStatsRepository.getPostStatsById(postId)).thenReturn(stats); when(postImageRepository.getPostImageByPostId(postId)).thenReturn(postImage()); - when(viewAbuseGuard.shouldCount(postId, viewerId)).thenReturn(false); - when(postViewCounter.currentDelta(postId)).thenReturn(7L); + when(viewAbuseGuard.shouldCount(ViewableType.POST, postId, viewerId)).thenReturn(false); + when(viewCounter.currentDelta(ViewableType.POST, postId)).thenReturn(7L); var res = getPostService.getPostDetail(postId, viewerId); - verify(viewAbuseGuard).shouldCount(postId, viewerId); - verify(postViewCounter).currentDelta(postId); + verify(viewAbuseGuard).shouldCount(ViewableType.POST, postId, viewerId); + verify(viewCounter).currentDelta(ViewableType.POST, postId); assertThat(res.stats().viewCount()).isEqualTo(stats.getViewCount() + 7L); } @@ -240,20 +241,20 @@ void should_use_current_delta_when_guard_blocks() { void should_fallback_to_db_count_when_redis_fails() { long postId = 1L, viewerId = 10L; Post post = post(); - PostStats stats = postStats(); // DB viewCount 예: 123L + PostStats stats = postStats(); when(postRepository.getPostById(postId)).thenReturn(post); when(postStatsRepository.getPostStatsById(postId)).thenReturn(stats); when(postImageRepository.getPostImageByPostId(postId)).thenReturn(postImage()); - when(viewAbuseGuard.shouldCount(postId, viewerId)).thenReturn(true); - when(postViewCounter.incrementAndGetDelta(postId)).thenThrow( + when(viewAbuseGuard.shouldCount(ViewableType.POST, postId, viewerId)).thenReturn(true); + when(viewCounter.incrementAndGet(ViewableType.POST, postId)).thenThrow( new RuntimeException("Redis down")); var res = getPostService.getPostDetail(postId, viewerId); - verify(viewAbuseGuard).shouldCount(postId, viewerId); - verify(postViewCounter).incrementAndGetDelta(postId); - assertThat(res.stats().viewCount()).isEqualTo(stats.getViewCount()); // 델타 미반영 + verify(viewAbuseGuard).shouldCount(ViewableType.POST, postId, viewerId); + verify(viewCounter).incrementAndGet(ViewableType.POST, postId); + assertThat(res.stats().viewCount()).isEqualTo(stats.getViewCount()); } // ---------------- getPostList ---------------- diff --git a/modules/post/src/test/java/kr/spot/common/PostFixture.java b/modules/post/src/test/java/kr/spot/common/PostFixture.java index a80df7b8..357efaaa 100644 --- a/modules/post/src/test/java/kr/spot/common/PostFixture.java +++ b/modules/post/src/test/java/kr/spot/common/PostFixture.java @@ -2,10 +2,12 @@ import kr.spot.domain.Post; import kr.spot.domain.PostStats; +import kr.spot.domain.Report; import kr.spot.domain.association.PostImage; import kr.spot.domain.enums.PostType; import kr.spot.domain.vo.WriterInfo; import kr.spot.presentation.command.dto.request.ManagePostRequest; +import kr.spot.presentation.command.dto.request.ReportPostRequest; import org.springframework.test.util.ReflectionTestUtils; public class PostFixture { @@ -59,4 +61,21 @@ public static WriterInfo writerInfo() { public static ManagePostRequest updatePostRequest() { return new ManagePostRequest(UPDATED_TITLE, UPDATED_CONTENT, PostType.COUNSELING); } + + // Report 관련 + public static Long REPORT_ID = 100L; + public static Long REPORTER_ID = 3L; + public static String REPORT_REASON = "부적절한 내용입니다."; + + public static ReportPostRequest reportPostRequest() { + return new ReportPostRequest(REPORT_REASON); + } + + public static Report report() { + return Report.of(REPORT_ID, POST_ID, REPORTER_ID, REPORT_REASON); + } + + public static Report report(Long reportId, Long postId, Long reporterId) { + return Report.of(reportId, postId, reporterId, REPORT_REASON); + } } diff --git a/modules/shared/src/main/java/kr/spot/domain/events/MemberWithdrawnEvent.java b/modules/shared/src/main/java/kr/spot/domain/events/MemberWithdrawnEvent.java new file mode 100644 index 00000000..704a8669 --- /dev/null +++ b/modules/shared/src/main/java/kr/spot/domain/events/MemberWithdrawnEvent.java @@ -0,0 +1,5 @@ +package kr.spot.domain.events; + +public record MemberWithdrawnEvent(long memberId) { + +} diff --git a/modules/study-api/src/main/java/kr/spot/event/StudyApplicationProcessedEvent.java b/modules/study-api/src/main/java/kr/spot/event/StudyApplicationProcessedEvent.java new file mode 100644 index 00000000..3ed33b7c --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/event/StudyApplicationProcessedEvent.java @@ -0,0 +1,27 @@ +package kr.spot.event; + +public record StudyApplicationProcessedEvent( + Long studyId, + Long applicantId, + Long processedBy, + String decision, + String studyName, + String studyThumbnailUrl +) { + + public static StudyApplicationProcessedEvent of( + Long studyId, + Long applicantId, + Long processedBy, + String decision, + String studyName, + String studyThumbnailUrl + ) { + return new StudyApplicationProcessedEvent( + studyId, applicantId, processedBy, decision, studyName, studyThumbnailUrl); + } + + public boolean isApproved() { + return "APPROVE".equals(decision); + } +} diff --git a/modules/study-api/src/main/java/kr/spot/ports/GetStudyMemberIdsPort.java b/modules/study-api/src/main/java/kr/spot/ports/GetStudyMemberIdsPort.java new file mode 100644 index 00000000..2e523de5 --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/ports/GetStudyMemberIdsPort.java @@ -0,0 +1,18 @@ +package kr.spot.ports; + +import java.util.List; + +/** + * 스터디 멤버 ID 조회 포트. + * 알림 발송 등에서 스터디 멤버 목록을 조회할 때 사용합니다. + */ +public interface GetStudyMemberIdsPort { + + /** + * 해당 스터디의 모든 멤버 ID를 조회합니다. + * + * @param studyId 스터디 ID + * @return 멤버 ID 목록 + */ + List getMemberIdsByStudyId(long studyId); +} diff --git a/modules/study-api/src/main/java/kr/spot/ports/HasActiveStudyAsLeaderPort.java b/modules/study-api/src/main/java/kr/spot/ports/HasActiveStudyAsLeaderPort.java new file mode 100644 index 00000000..c21e0504 --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/ports/HasActiveStudyAsLeaderPort.java @@ -0,0 +1,6 @@ +package kr.spot.ports; + +public interface HasActiveStudyAsLeaderPort { + + boolean hasActiveStudyAsLeader(long memberId); +} diff --git a/modules/study-api/src/main/java/kr/spot/ports/StudyParticipationInfoPort.java b/modules/study-api/src/main/java/kr/spot/ports/StudyParticipationInfoPort.java new file mode 100644 index 00000000..e0e6673e --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/ports/StudyParticipationInfoPort.java @@ -0,0 +1,8 @@ +package kr.spot.ports; + +import kr.spot.ports.dto.StudyParticipationInfo; + +public interface StudyParticipationInfoPort { + + StudyParticipationInfo getStudyParticipationInfo(long memberId); +} diff --git a/modules/study-api/src/main/java/kr/spot/ports/dto/StudyParticipationInfo.java b/modules/study-api/src/main/java/kr/spot/ports/dto/StudyParticipationInfo.java new file mode 100644 index 00000000..f810fcf1 --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/ports/dto/StudyParticipationInfo.java @@ -0,0 +1,21 @@ +package kr.spot.ports.dto; + +public record StudyParticipationInfo( + long participatingStudyCount, + long recruitingStudyCount, + long appliedStudyCount +) { + + public static StudyParticipationInfo from( + long participatingStudyCount, + long recruitingStudyCount, + long appliedStudyCount + ) { + return new StudyParticipationInfo( + participatingStudyCount, + recruitingStudyCount, + appliedStudyCount + ); + } + +} \ No newline at end of file diff --git a/modules/study/build.gradle b/modules/study/build.gradle index f7b9623f..d613c9fe 100644 --- a/modules/study/build.gradle +++ b/modules/study/build.gradle @@ -8,6 +8,7 @@ dependencies { implementation project(":common:api") implementation project(":common:snowflake") implementation project(":common:bucket") + implementation project(":common:view") implementation project(":modules:shared") implementation project(":modules:auth-api") @@ -17,6 +18,7 @@ dependencies { // Spring Boot Starters implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-data-jpa" + implementation "org.springframework.boot:spring-boot-starter-data-redis" // Testing testImplementation "org.springframework.boot:spring-boot-starter-test" @@ -30,6 +32,10 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // QR Code + implementation "com.google.zxing:core:3.5.2" + implementation "com.google.zxing:javase:3.5.2" } def querydslDir = "$buildDir/generated/querydsl" diff --git a/modules/study/src/main/java/kr/spot/application/mapper/StudyDTOMapper.java b/modules/study/src/main/java/kr/spot/application/mapper/StudyDTOMapper.java deleted file mode 100644 index eb483cf3..00000000 --- a/modules/study/src/main/java/kr/spot/application/mapper/StudyDTOMapper.java +++ /dev/null @@ -1,29 +0,0 @@ -package kr.spot.application.mapper; - -import java.util.List; -import kr.spot.domain.Study; -import kr.spot.presentation.query.dto.response.GetStudyOverviewResponse; -import kr.spot.presentation.query.dto.response.GetStudyOverviewResponse.StudyOverview; - -public class StudyDTOMapper { - - public static GetStudyOverviewResponse toDTO(List studies, boolean hasNext, - Long nextCursor, - Long totalElements) { - List list = studies.stream().map( - study -> StudyOverview.of( - study.getId(), - study.getName(), - study.getDescription(), - study.getMaxMembers(), - study.getCurrentMembers(), - 0, - false, - 0, - study.getImageUrl() - ) - ).toList(); - - return GetStudyOverviewResponse.of(list, hasNext, nextCursor, totalElements); - } -} diff --git a/modules/study/src/main/java/kr/spot/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/application/query/GetMyStudyInfoService.java deleted file mode 100644 index 18084b09..00000000 --- a/modules/study/src/main/java/kr/spot/application/query/GetMyStudyInfoService.java +++ /dev/null @@ -1,142 +0,0 @@ -package kr.spot.application.query; - -import java.util.List; -import kr.spot.application.mapper.StudyDTOMapper; -import kr.spot.domain.Study; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.FeeCategory; -import kr.spot.domain.enums.RecruitingStatus; -import kr.spot.domain.enums.SortBy; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.infrastructure.jpa.querydsl.StudyQueryRepository; -import kr.spot.ports.GetPreferredCategoryPort; -import kr.spot.ports.GetPreferredRegionPort; -import kr.spot.presentation.query.dto.response.GetStudyOverviewResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class GetMyStudyInfoService { - - public static final int MAX_PAGE_SIZE = 50; - - private final GetPreferredRegionPort getPreferredRegionPort; - private final GetPreferredCategoryPort getPreferredCategoryPort; - private final StudyQueryRepository studyQueryRepository; - - public GetStudyOverviewResponse getMyStudyOverview( - Long viewerId, - StudyMemberStatus status, - Long cursor, - int size - ) { - final int pageSize = Math.min(size, MAX_PAGE_SIZE); - List rows = studyQueryRepository.findMyStudies( - viewerId, - status, - cursor, - pageSize + 1 - ); - - long totalElements = studyQueryRepository.countMyStudies( - viewerId, - status - ); - return toCursorPage(rows, pageSize, totalElements); - } - - public GetStudyOverviewResponse getMyPreferredRegionStudies( - Long viewerId, - RecruitingStatus recruitingStatus, - FeeCategory feeCategory, - List categories, - SortBy sortBy, - Long cursor, - Integer size, - List regionCodes - ) { - final int pageSize = Math.min(size, MAX_PAGE_SIZE); - List preferredRegionCodes = getPreferredRegionPort.get(viewerId); - - List rows = studyQueryRepository.findMyPreferredRegionStudies( - recruitingStatus, - feeCategory, - categories, - sortBy, - cursor, - pageSize + 1, - filterPreferredRegionCodes(regionCodes, preferredRegionCodes) - ); - - long totalElements = studyQueryRepository.countMyPreferredRegionStudies( - recruitingStatus, - feeCategory, - categories, - filterPreferredRegionCodes(regionCodes, preferredRegionCodes) - ); - return toCursorPage(rows, pageSize, totalElements); - } - - public GetStudyOverviewResponse getMyPreferredCategoryStudies( - Long viewerId, - Category category, - RecruitingStatus recruitingStatus, - FeeCategory feeCategory, - SortBy sortBy, - Long cursor, - Integer size - ) { - final int pageSize = Math.min(size, MAX_PAGE_SIZE); - List preferredRegionCodes = getCategories(viewerId, category); - - List rows = studyQueryRepository.findMyPreferredCategoryStudies( - recruitingStatus, - feeCategory, - sortBy, - cursor, - pageSize + 1, - preferredRegionCodes - ); - - long totalElements = studyQueryRepository.countMyPreferredCategoryStudies( - recruitingStatus, - feeCategory, - preferredRegionCodes - ); - return toCursorPage(rows, pageSize, totalElements); - } - - private GetStudyOverviewResponse toCursorPage(List rows, int pageSize, - Long totalElements) { - boolean hasNext = rows.size() > pageSize; - List pageContent = hasNext ? rows.subList(0, pageSize) : rows; - Long nextCursor = hasNext ? pageContent.getLast().getId() : null; - return StudyDTOMapper.toDTO(pageContent, hasNext, nextCursor, totalElements); - } - - private List filterPreferredRegionCodes(List regionCodes, - List preferredRegionCodes) { - if (regionCodes == null || regionCodes.isEmpty()) { - return preferredRegionCodes; - } - return preferredRegionCodes.stream() - .filter(regionCodes::contains) - .toList(); - } - - private List getCategories(Long viewerId, Category category) { - List preferredRegionCodes; - if (category == null) { - preferredRegionCodes = getPreferredCategoryPort.get(viewerId) - .stream() - .map(Category::fromString) - .toList(); - } else { - preferredRegionCodes = List.of(category); - } - return preferredRegionCodes; - } -} diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyRepository.java b/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyRepository.java deleted file mode 100644 index 99dda341..00000000 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package kr.spot.infrastructure.jpa; - -import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.Study; -import kr.spot.exception.GeneralException; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface StudyRepository extends JpaRepository { - - default Study getStudyById(Long studyId) { - return findById(studyId) - .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_NOT_FOUND)); - } -} diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyCategoryRepository.java b/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyCategoryRepository.java deleted file mode 100644 index 5e8bb5ba..00000000 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyCategoryRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.spot.infrastructure.jpa.associations; - -import kr.spot.domain.associations.StudyCategory; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface StudyCategoryRepository extends JpaRepository { - -} diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyMemberRepository.java b/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyMemberRepository.java deleted file mode 100644 index 15450ee7..00000000 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyMemberRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package kr.spot.infrastructure.jpa.associations; - -import java.util.List; -import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.exception.GeneralException; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface StudyMemberRepository extends JpaRepository { - - default StudyMember getStudyMemberById(Long studyMemberId) { - return findById(studyMemberId) - .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_MEMBER_NOT_FOUND)); - } - - boolean existsByStudyIdAndMemberIdAndStudyMemberStatusIn(Long studyId, Long memberId, - List studyMemberStatuses); - -} diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStatsRepository.java b/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStatsRepository.java deleted file mode 100644 index a2a4ba68..00000000 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStatsRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package kr.spot.infrastructure.jpa.associations; - -import kr.spot.domain.associations.StudyStats; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface StudyStatsRepository extends JpaRepository { - -} diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyQueryRepository.java b/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyQueryRepository.java deleted file mode 100644 index 5754bf44..00000000 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyQueryRepository.java +++ /dev/null @@ -1,209 +0,0 @@ -package kr.spot.infrastructure.jpa.querydsl; - -import com.querydsl.core.types.OrderSpecifier; -import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.jpa.impl.JPAQueryFactory; -import java.util.List; -import kr.spot.domain.QStudy; -import kr.spot.domain.Study; -import kr.spot.domain.associations.QStudyCategory; -import kr.spot.domain.associations.QStudyMember; -import kr.spot.domain.associations.QStudyRegion; -import kr.spot.domain.associations.QStudyStats; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.FeeCategory; -import kr.spot.domain.enums.RecruitingStatus; -import kr.spot.domain.enums.SortBy; -import kr.spot.domain.enums.StudyMemberStatus; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class StudyQueryRepository { - - private final JPAQueryFactory query; - - public List findMyStudies( - Long viewerId, - StudyMemberStatus status, - Long cursor, - int limit) { - QStudy study = QStudy.study; - QStudyMember studyMember = QStudyMember.studyMember; - - return query - .select(study) - .from(studyMember) - .join(study).on(study.id.eq(studyMember.studyId)) - .where( - studyMember.memberId.eq(viewerId), - studyMember.studyMemberStatus.eq(status), - ltCursor(cursor, study) - ) - .orderBy(study.id.desc()) - .limit(limit) - .fetch(); - } - - public long countMyStudies( - Long viewerId, - StudyMemberStatus status - ) { - QStudy study = QStudy.study; - QStudyMember studyMember = QStudyMember.studyMember; - - return query - .select(study.id.countDistinct()) - .from(studyMember) - .join(study).on(study.id.eq(studyMember.studyId)) - .where( - studyMember.memberId.eq(viewerId), - studyMember.studyMemberStatus.eq(status) - ) - .fetchOne(); - } - - public List findMyPreferredRegionStudies( - RecruitingStatus recruitingStatus, - FeeCategory feeCategory, - List categories, - SortBy sortBy, - Long cursor, - int limit, - List regionCodes - ) { - QStudy study = QStudy.study; - QStudyRegion studyRegion = QStudyRegion.studyRegion; - QStudyCategory studyCategory = QStudyCategory.studyCategory; - QStudyStats studyStats = QStudyStats.studyStats; - - return query - .select(study) - .from(study) - .join(studyRegion).on(studyRegion.studyId.eq(study.id)) - .leftJoin(studyCategory).on(studyCategory.studyId.eq(study.id)) - .leftJoin(studyStats).on(studyStats.studyId.eq(study.id)) - .where( - studyRegion.regionCode.in(regionCodes), - eqRecruitingStatus(recruitingStatus, study), - eqFeeCategory(feeCategory, study), - inCategories(categories, studyCategory), - ltCursor(cursor, study) - ) - .groupBy(study.id) - .orderBy( - orderBy(sortBy, study, studyStats), - study.id.desc() - ) - .limit(limit) - .fetch(); - } - - public long countMyPreferredRegionStudies( - RecruitingStatus recruitingStatus, - FeeCategory feeCategory, - List categories, - List regionCodes - ) { - QStudy study = QStudy.study; - QStudyRegion studyRegion = QStudyRegion.studyRegion; - QStudyCategory studyCategory = QStudyCategory.studyCategory; - - return query - .select(study.id.countDistinct()) - .from(study) - .join(studyRegion).on(studyRegion.studyId.eq(study.id)) - .leftJoin(studyCategory).on(studyCategory.studyId.eq(study.id)) - .where( - studyRegion.regionCode.in(regionCodes), - eqRecruitingStatus(recruitingStatus, study), - eqFeeCategory(feeCategory, study), - inCategories(categories, studyCategory) - ) - .fetchOne(); - } - - public List findMyPreferredCategoryStudies( - RecruitingStatus recruitingStatus, - FeeCategory feeCategory, - SortBy sortBy, - Long cursor, - int limit, - List categories - ) { - QStudy study = QStudy.study; - QStudyCategory studyCategory = QStudyCategory.studyCategory; - QStudyStats studyStats = QStudyStats.studyStats; - - return query - .select(study) - .from(study) - .leftJoin(studyCategory).on(studyCategory.studyId.eq(study.id)) - .leftJoin(studyStats).on(studyStats.studyId.eq(study.id)) - .where( - eqRecruitingStatus(recruitingStatus, study), - eqFeeCategory(feeCategory, study), - inCategories(categories, studyCategory), - ltCursor(cursor, study) - ) - .groupBy(study.id) - .orderBy( - orderBy(sortBy, study, studyStats), - study.id.desc() - ) - .limit(limit) - .fetch(); - } - - public long countMyPreferredCategoryStudies( - RecruitingStatus recruitingStatus, - FeeCategory feeCategory, - List categories - ) { - QStudy study = QStudy.study; - QStudyCategory studyCategory = QStudyCategory.studyCategory; - - return query - .select(study.id.countDistinct()) - .from(study) - .leftJoin(studyCategory).on(studyCategory.studyId.eq(study.id)) - .where( - eqRecruitingStatus(recruitingStatus, study), - eqFeeCategory(feeCategory, study), - inCategories(categories, studyCategory) - ) - .fetchOne(); - } - - private BooleanExpression eqRecruitingStatus(RecruitingStatus recruitingStatus, QStudy study) { - return recruitingStatus == null ? null : study.recruitingStatus.eq(recruitingStatus); - } - - private BooleanExpression eqFeeCategory(FeeCategory feeCategory, QStudy study) { - return feeCategory == null ? null : study.fee.feeCategory.eq(feeCategory); - } - - private BooleanExpression inCategories(List categories, QStudyCategory studyCategory) { - if (categories == null || categories.isEmpty()) { - return null; - } - return studyCategory.category.in(categories); - } - - private BooleanExpression ltCursor(Long cursor, QStudy study) { - return cursor == null ? null : study.id.lt(cursor); - } - - private OrderSpecifier orderBy(SortBy sortBy, QStudy study, QStudyStats studyStats) { - if (sortBy == null) { - return study.id.desc(); - } - - return switch (sortBy) { - case RECENT -> study.id.desc(); - case LIKES -> studyStats.likeCount.desc(); - case HITS -> studyStats.viewCount.desc(); - }; - } -} diff --git a/modules/study/src/main/java/kr/spot/post/application/command/LikePostService.java b/modules/study/src/main/java/kr/spot/post/application/command/LikePostService.java new file mode 100644 index 00000000..fd742c95 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/command/LikePostService.java @@ -0,0 +1,45 @@ +package kr.spot.post.application.command; + +import kr.spot.IdGenerator; +import kr.spot.post.infrastructure.jpa.PostLikeRepository; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service(value = "studyLikePostService") +@Transactional +@RequiredArgsConstructor +public class LikePostService { + + private final IdGenerator idGenerator; + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + private final PostStatsRepository postStatsRepository; + private final StudyAccessValidator accessValidator; + + public void likePost(long studyId, long postId, long memberId) { + validateAccess(studyId, postId, memberId); + + int inserted = postLikeRepository.savePostLike(idGenerator.nextId(), postId, memberId); + if (inserted == 1) { + postStatsRepository.increaseLike(postId); + } + } + + public void unlikePost(long studyId, long postId, long memberId) { + validateAccess(studyId, postId, memberId); + + int deleted = postLikeRepository.hardDelete(postId, memberId); + if (deleted > 0) { + postStatsRepository.decreaseLike(postId); + } + } + + private void validateAccess(long studyId, long postId, long memberId) { + accessValidator.validateStudyMember(studyId, memberId); + postRepository.getById(postId).validateBelongsToStudy(studyId); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/application/command/ManageCommentService.java b/modules/study/src/main/java/kr/spot/post/application/command/ManageCommentService.java new file mode 100644 index 00000000..ab60fbcf --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/command/ManageCommentService.java @@ -0,0 +1,73 @@ +package kr.spot.post.application.command; + +import kr.spot.IdGenerator; +import kr.spot.ports.GetWriterInfoPort; +import kr.spot.ports.dto.WriterInfoResponse; +import kr.spot.post.domain.Comment; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.vo.WriterInfo; +import kr.spot.post.infrastructure.jpa.CommentRepository; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import kr.spot.post.presentation.command.dto.CreateCommentResponse; +import kr.spot.post.presentation.command.dto.ManageCommentRequest; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service(value = "ManageStudyCommentService") +@Transactional +@RequiredArgsConstructor +public class ManageCommentService { + + private final IdGenerator idGenerator; + private final GetWriterInfoPort getWriterInfoPort; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final PostStatsRepository postStatsRepository; + private final StudyAccessValidator studyAccessValidator; + + public CreateCommentResponse createComment(long writerId, long postId, long studyId, + ManageCommentRequest request) { + studyAccessValidator.validateStudyMember(studyId, writerId); + validatePostBelongsToStudy(postId, studyId); + WriterInfo writerInfo = getWriterInfo(writerId); + Comment comment = Comment.of(idGenerator.nextId(), postId, writerInfo, request.content()); + Comment save = saveCommentAndIncreaseCommentCount(postId, comment); + return CreateCommentResponse.of(save.getId()); + } + + public void updateComment(long writerId, long commentId, long studyId, + ManageCommentRequest request) { + studyAccessValidator.validateStudyMember(studyId, writerId); + Comment comment = commentRepository.getById(commentId); + validatePostBelongsToStudy(comment.getPostId(), studyId); + comment.update(writerId, request.content()); + } + + public void deleteComment(long writerId, long commentId, long studyId) { + studyAccessValidator.validateStudyMember(studyId, writerId); + Comment comment = commentRepository.getById(commentId); + validatePostBelongsToStudy(comment.getPostId(), studyId); + comment.delete(writerId); + postStatsRepository.decreaseCommentCount(comment.getPostId()); + } + + private void validatePostBelongsToStudy(long postId, long studyId) { + Post post = postRepository.getById(postId); + post.validateBelongsToStudy(studyId); + } + + private WriterInfo getWriterInfo(long writerId) { + WriterInfoResponse writerInfoResponse = getWriterInfoPort.get(writerId); + return WriterInfo.of(writerInfoResponse.writerId(), writerInfoResponse.nickname(), + writerInfoResponse.profileImageUrl()); + } + + private Comment saveCommentAndIncreaseCommentCount(long postId, Comment comment) { + Comment save = commentRepository.save(comment); + postStatsRepository.increaseCommentCount(postId); + return save; + } +} diff --git a/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java new file mode 100644 index 00000000..9b8797eb --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java @@ -0,0 +1,78 @@ +package kr.spot.post.application.command; + +import kr.spot.IdGenerator; +import kr.spot.ports.GetWriterInfoPort; +import kr.spot.ports.dto.WriterInfoResponse; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.domain.vo.WriterInfo; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import kr.spot.post.presentation.command.dto.ManagePostRequest; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service(value = "ManageStudyPostService") +@Transactional +@RequiredArgsConstructor +public class ManagePostService { + + private final IdGenerator idGenerator; + private final GetWriterInfoPort getWriterInfoPort; + private final PostRepository postRepository; + private final PostStatsRepository postStatsRepository; + private final StudyAccessValidator studyAccessValidator; + + public long createPost(long studyId, long writerId, ManagePostRequest request) { + studyAccessValidator.validateStudyMember(studyId, writerId); + WriterInfo writerInfo = getWriterInfo(writerId); + Post post = createAndSavePost(request, studyId, writerInfo); + initializeAndSavePostStats(post); + return post.getId(); + } + + public void updatePost(long studyId, long postId, ManagePostRequest request, long writerId) { + studyAccessValidator.validateStudyMember(studyId, writerId); + Post post = postRepository.getById(postId); + post.update(request.title(), request.content(), request.isPrivate(), writerId, studyId); + } + + public void deletePost(long studyId, long postId, long writerId) { + studyAccessValidator.validateStudyMember(studyId, writerId); + Post post = postRepository.getById(postId); + post.delete(writerId, studyId); + } + + public void pinPost(long studyId, long postId, long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + Post post = postRepository.getById(postId); + post.pin(studyId); + } + + public void unpinPost(long studyId, long postId, long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + Post post = postRepository.getById(postId); + post.unpin(studyId); + } + + private WriterInfo getWriterInfo(long writerId) { + WriterInfoResponse writerInfoResponse = getWriterInfoPort.get(writerId); + return WriterInfo.of(writerInfoResponse.writerId(), writerInfoResponse.nickname(), + writerInfoResponse.profileImageUrl()); + } + + private Post createAndSavePost(ManagePostRequest request, long studyId, WriterInfo writerInfo) { + Post post = Post.of(idGenerator.nextId(), studyId, writerInfo, request.title(), + request.content(), + request.isPrivate()); + postRepository.save(post); + return post; + } + + private void initializeAndSavePostStats(Post post) { + PostStats postStats = PostStats.of(post.getId()); + postStatsRepository.save(postStats); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawn.java b/modules/study/src/main/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawn.java new file mode 100644 index 00000000..c99bfe66 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/event/CleanupStudyPostOnMemberWithdrawn.java @@ -0,0 +1,29 @@ +package kr.spot.post.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.post.infrastructure.jpa.CommentRepository; +import kr.spot.post.infrastructure.jpa.PostLikeRepository; +import kr.spot.post.infrastructure.jpa.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Order(5) +@Component +@RequiredArgsConstructor +public class CleanupStudyPostOnMemberWithdrawn { + + private final PostLikeRepository postLikeRepository; + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + long memberId = event.memberId(); + postLikeRepository.deleteAllByMemberId(memberId); + commentRepository.deleteByWriterInfoWriterId(memberId); + postRepository.deleteByWriterInfoWriterId(memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java new file mode 100644 index 00000000..9e28c9a9 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java @@ -0,0 +1,155 @@ +package kr.spot.post.application.query; + +import static kr.spot.post.application.query.mapper.PostResponseMapper.DEFAULT_MAX_CONTENT_LENGTH; +import static kr.spot.post.application.query.mapper.PostResponseMapper.toDetailResponse; +import static kr.spot.post.application.query.mapper.PostResponseMapper.toPostItem; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import kr.spot.post.domain.Comment; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.infrastructure.jpa.PostRepository; +import kr.spot.post.infrastructure.jpa.querydsl.PostQueryRepository; +import kr.spot.post.presentation.query.dto.PostDetailResponse; +import kr.spot.post.presentation.query.dto.PostListResponse; +import kr.spot.post.presentation.query.dto.PostListResponse.PostItem; +import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service(value = "studyGetPostService") +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetPostService { + + private static final int MAX_PAGE_SIZE = 50; + + private final ViewCounter viewCounter; + private final ViewAbuseGuard viewAbuseGuard; + private final StudyAccessValidator accessValidator; + private final PostRepository postRepository; + private final PostQueryRepository postQueryRepository; + + public PostDetailResponse getPostDetail(long studyId, long postId, long viewerId) { + Post post = findAndValidatePost(studyId, postId); + post.validatePublicAccess(accessValidator.isStudyMember(studyId, viewerId)); + + PostStats stats = postQueryRepository.findStatsByPostId(postId); + List comments = postQueryRepository.findCommentsByPostId(postId); + long displayViewCount = calculateDisplayViewCount(stats, postId, viewerId); + boolean isLiked = postQueryRepository.isLiked(viewerId, postId); + + return toDetailResponse(post, stats, displayViewCount, comments, viewerId, isLiked); + } + + public PostListResponse getPostList(long studyId, Long cursor, long viewerId, int size) { + int pageSize = Math.min(size, MAX_PAGE_SIZE); + boolean isStudyMember = accessValidator.isStudyMember(studyId, viewerId); + + List pinnedPosts = fetchPinnedPostsIfFirstPage(studyId, cursor); + PaginationResult paginationResult = fetchNormalPostsWithPagination(studyId, cursor, pageSize, + pinnedPosts.size()); + + List allPosts = mergePosts(pinnedPosts, paginationResult.posts()); + Map statsMap = fetchStatsForPosts(allPosts); + Set likedPostIds = fetchLikedPostIds(allPosts, viewerId); + + List postItems = mapToPostItems(allPosts, statsMap, isStudyMember, likedPostIds); + + return buildListResponse(postItems, paginationResult); + } + + private Post findAndValidatePost(long studyId, long postId) { + Post post = postRepository.getById(postId); + post.validateBelongsToStudy(studyId); + return post; + } + + private List fetchPinnedPostsIfFirstPage(long studyId, Long cursor) { + if (cursor != null) { + return List.of(); + } + return postQueryRepository.findPinnedPosts(studyId); + } + + private PaginationResult fetchNormalPostsWithPagination(long studyId, Long cursor, int pageSize, + int pinnedCount) { + int remainingSlots = pageSize - pinnedCount; + int fetchLimit = remainingSlots + 1; + + List posts = postQueryRepository.findPageByIdDesc(studyId, cursor, fetchLimit); + boolean hasNext = posts.size() > remainingSlots; + + if (hasNext) { + posts = posts.subList(0, remainingSlots); + } + + Long nextCursor = hasNext ? posts.getLast().getId() : null; + return new PaginationResult(posts, hasNext, nextCursor); + } + + private List mergePosts(List pinnedPosts, List normalPosts) { + List merged = new ArrayList<>(pinnedPosts.size() + normalPosts.size()); + merged.addAll(pinnedPosts); + merged.addAll(normalPosts); + return merged; + } + + private Map fetchStatsForPosts(List posts) { + List postIds = posts.stream().map(Post::getId).toList(); + return postQueryRepository.findStatsByPostIds(postIds); + } + + private Set fetchLikedPostIds(List posts, long viewerId) { + List postIds = posts.stream().map(Post::getId).toList(); + return postQueryRepository.findLikedPostIds(viewerId, postIds); + } + + private List mapToPostItems(List posts, Map statsMap, + boolean isStudyMember, Set likedPostIds) { + return posts.stream() + .map(post -> toPostItem(post, statsMap.get(post.getId()), DEFAULT_MAX_CONTENT_LENGTH, + isStudyMember, likedPostIds.contains(post.getId()))) + .toList(); + } + + private PostListResponse buildListResponse(List postItems, + PaginationResult paginationResult) { + return PostListResponse.builder() + .posts(postItems) + .hasNext(paginationResult.hasNext()) + .nextCursor(paginationResult.nextCursor()) + .build(); + } + + private long calculateDisplayViewCount(PostStats stats, long postId, long viewerId) { + long baseViewCount = (stats != null) ? stats.getViewCount() : 0L; + long viewDelta = getViewDeltaFromCounter(postId, viewerId); + return baseViewCount + viewDelta; + } + + private long getViewDeltaFromCounter(long postId, long viewerId) { + try { + if (viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)) { + return viewCounter.incrementAndGet(ViewableType.STUDY_BOARD, postId); + } + return viewCounter.currentDelta(ViewableType.STUDY_BOARD, postId); + } catch (Exception e) { + log.warn("Redis view counter access failed for study postId: {}", postId, e); + return 0L; + } + } + + private record PaginationResult(List posts, boolean hasNext, Long nextCursor) { + + } +} diff --git a/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java b/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java new file mode 100644 index 00000000..01d3a390 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java @@ -0,0 +1,133 @@ +package kr.spot.post.application.query.mapper; + +import java.util.List; +import kr.spot.post.domain.Comment; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.domain.vo.WriterInfo; +import kr.spot.post.presentation.query.dto.PostDetailResponse; +import kr.spot.post.presentation.query.dto.PostDetailResponse.CommentResponse; +import kr.spot.post.presentation.query.dto.PostListResponse.PostItem; +import kr.spot.post.presentation.query.dto.PostStatsResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PostResponseMapper { + + public static final int DEFAULT_MAX_CONTENT_LENGTH = 100; + private static final String PRIVATE_POST_MESSAGE = "이 글은 스터디원에게만 노출됩니다."; + + public static PostDetailResponse toDetailResponse( + Post post, + PostStats stats, + long displayViewCount, + List comments, + long viewerId, + boolean isLiked + ) { + return PostDetailResponse.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .isPinned(post.isPinned()) + .isOwner(post.isOwnedBy(viewerId)) + .isLiked(isLiked) + .stats(toStatsResponse(stats, displayViewCount)) + .createdAt(post.getCreatedAt()) + .comments(toCommentResponses(comments, viewerId)) + .build(); + } + + public static PostItem toPostItem(Post post, PostStats stats, int maxContentLength, + boolean isStudyMember, boolean isLiked) { + if (post.isPrivate() && !isStudyMember) { + return toMaskedPostItem(post, stats, isLiked); + } + return toPublicPostItem(post, stats, maxContentLength, isLiked); + } + + private static PostItem toPublicPostItem(Post post, PostStats stats, int maxContentLength, + boolean isLiked) { + return PostItem.of( + post.getId(), + post.getTitle(), + summarize(post.getContent(), maxContentLength), + post.isPinned(), + isLiked, + toStatsResponse(stats), + post.getCreatedAt() + ); + } + + private static PostItem toMaskedPostItem(Post post, PostStats stats, boolean isLiked) { + return PostItem.of( + post.getId(), + PRIVATE_POST_MESSAGE, + PRIVATE_POST_MESSAGE, + post.isPinned(), + isLiked, + toStatsResponse(stats), + post.getCreatedAt() + ); + } + + private static List toCommentResponses(List comments, long viewerId) { + return comments.stream() + .map(comment -> toCommentResponse(comment, viewerId)) + .toList(); + } + + private static CommentResponse toCommentResponse(Comment comment, long viewerId) { + WriterInfo writer = comment.getWriterInfo(); + return CommentResponse.of( + comment.getId(), + comment.getContent(), + writer.isSameWriter(viewerId), + toDetailWriterInfoResponse(writer), + comment.getCreatedAt() + ); + } + + + private static PostDetailResponse.WriterInfoResponse toDetailWriterInfoResponse( + WriterInfo writerInfo) { + return PostDetailResponse.WriterInfoResponse.of( + writerInfo.getWriterId(), + writerInfo.getWriterName(), + writerInfo.getWriterProfileImageUrl() + ); + } + + private static PostStatsResponse toStatsResponse(PostStats stats, long displayViewCount) { + if (stats == null) { + return PostStatsResponse.from(0L, displayViewCount, 0L); + } + return PostStatsResponse.from( + stats.getLikeCount(), + displayViewCount, + stats.getCommentCount() + ); + } + + private static PostStatsResponse toStatsResponse(PostStats stats) { + if (stats == null) { + return PostStatsResponse.from(0L, 0L, 0L); + } + return PostStatsResponse.from( + stats.getLikeCount(), + stats.getViewCount(), + stats.getCommentCount() + ); + } + + private static String summarize(String content, int maxLength) { + if (content == null) { + return ""; + } + String stripped = content.strip(); + return stripped.length() > maxLength + ? stripped.substring(0, maxLength) + "..." + : stripped; + } +} diff --git a/modules/study/src/main/java/kr/spot/post/domain/Comment.java b/modules/study/src/main/java/kr/spot/post/domain/Comment.java new file mode 100644 index 00000000..56e08d18 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/domain/Comment.java @@ -0,0 +1,44 @@ +package kr.spot.post.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import kr.spot.domain.BaseEntity; +import kr.spot.post.domain.vo.WriterInfo; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity(name = "StudyPostComment") +@SQLDelete(sql = "UPDATE study_post_comment SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Comment extends BaseEntity { + + @Id + private Long id; + + private Long postId; + + private WriterInfo writerInfo; + + private String content; + + public static Comment of(Long id, Long postId, WriterInfo writerInfo, String content) { + return new Comment(id, postId, writerInfo, content); + } + + public void update(Long currentMemberId, String content) { + writerInfo.validateIsOwnMember(currentMemberId); + this.content = content; + } + + public void delete(Long currentMemberId) { + writerInfo.validateIsOwnMember(currentMemberId); + super.delete(); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/domain/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java new file mode 100644 index 00000000..6553e3f2 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java @@ -0,0 +1,97 @@ +package kr.spot.post.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.BaseEntity; +import kr.spot.exception.GeneralException; +import kr.spot.post.domain.vo.WriterInfo; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity(name = "StudyPost") +@SQLDelete(sql = "UPDATE study_post SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Post extends BaseEntity { + + @Id + private Long id; + + private Long studyId; + + @Embedded + private WriterInfo writerInfo; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + private LocalDateTime pinnedAt; + + private boolean isPrivate; + + public static Post of(Long id, Long studyId, WriterInfo writerInfo, String title, String content, + boolean isPrivate) { + return new Post(id, studyId, writerInfo, title, content, null, isPrivate); + } + + public void update(String title, String content, boolean isPrivate, long memberId, long studyId) { + validateStudyId(studyId); + writerInfo.validateIsOwnMember(memberId); + this.title = title; + this.content = content; + this.isPrivate = isPrivate; + } + + public void delete(long memberId, long studyId) { + validateStudyId(studyId); + writerInfo.validateIsOwnMember(memberId); + super.delete(); + } + + public void pin(long studyId) { + validateStudyId(studyId); + this.pinnedAt = LocalDateTime.now(); + } + + public void unpin(long studyId) { + validateStudyId(studyId); + this.pinnedAt = null; + } + + public boolean isPinned() { + return pinnedAt != null; + } + + public boolean isOwnedBy(long memberId) { + return writerInfo.isSameWriter(memberId); + } + + public void validateBelongsToStudy(long studyId) { + validateStudyId(studyId); + } + + public void validatePublicAccess(boolean isStudyMember) { + if (this.isPrivate && !isStudyMember) { + throw new GeneralException(ErrorStatus._PRIVATE_POST_ACCESS_DENIED); + } + } + + private void validateStudyId(long studyId) { + if (this.studyId != studyId) { + throw new GeneralException(ErrorStatus._INVALID_STUDY_ACCESS); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/post/domain/PostLike.java b/modules/study/src/main/java/kr/spot/post/domain/PostLike.java new file mode 100644 index 00000000..a8b6ee95 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/domain/PostLike.java @@ -0,0 +1,36 @@ +package kr.spot.post.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import kr.spot.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity(name = "StudyPostLike") +@SQLDelete(sql = "UPDATE study_post_like SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "study_post_like", + uniqueConstraints = @UniqueConstraint(name = "uk_study_post_member", columnNames = {"post_id", + "member_id"})) +public class PostLike extends BaseEntity { + + @Id + private Long id; + + private Long postId; + + private Long memberId; + + public static PostLike of(Long id, Long postId, Long memberId) { + return new PostLike(id, postId, memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyStats.java b/modules/study/src/main/java/kr/spot/post/domain/PostStats.java similarity index 54% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyStats.java rename to modules/study/src/main/java/kr/spot/post/domain/PostStats.java index 29de3c49..b7be7ce7 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyStats.java +++ b/modules/study/src/main/java/kr/spot/post/domain/PostStats.java @@ -1,4 +1,4 @@ -package kr.spot.domain.associations; +package kr.spot.post.domain; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -11,15 +11,15 @@ import org.hibernate.annotations.SQLRestriction; @Getter -@Entity -@SQLDelete(sql = "UPDATE study_stats SET status = 'INACTIVE' WHERE id = ?") +@Entity(name = "StudyPostStats") +@SQLDelete(sql = "UPDATE study_post_stats SET status = 'INACTIVE' WHERE id = ?") @SQLRestriction("status = 'ACTIVE'") @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class StudyStats extends BaseEntity { +public class PostStats extends BaseEntity { @Id - private Long studyId; + private Long postId; private Long viewCount; @@ -27,7 +27,11 @@ public class StudyStats extends BaseEntity { private Long commentCount; - public static StudyStats of(Long studyId) { - return new StudyStats(studyId, 0L, 0L, 0L); + public static PostStats of(Long postId) { + return new PostStats(postId, 0L, 0L, 0L); + } + + public static PostStats of(Long postId, Long viewCount, Long likeCount, Long commentCount) { + return new PostStats(postId, viewCount, likeCount, commentCount); } } diff --git a/modules/study/src/main/java/kr/spot/post/domain/vo/WriterInfo.java b/modules/study/src/main/java/kr/spot/post/domain/vo/WriterInfo.java new file mode 100644 index 00000000..8437abb6 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/domain/vo/WriterInfo.java @@ -0,0 +1,39 @@ +package kr.spot.post.domain.vo; + +import jakarta.persistence.Embeddable; +import java.util.Objects; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class WriterInfo { + + private Long writerId; + + private String writerName; + + private String WriterProfileImageUrl; + + public static WriterInfo of(Long writerId, String writerName, String writerProfileImageUrl) { + return new WriterInfo(writerId, writerName, writerProfileImageUrl); + } + + public void validateIsOwnMember(Long currentUserId) { + if (!Objects.equals(writerId, currentUserId)) { + throw new GeneralException(ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + } + + public boolean isSameWriter(Long memberId) { + return Objects.equals(this.writerId, memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJob.java b/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJob.java new file mode 100644 index 00000000..7bc4dedc --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJob.java @@ -0,0 +1,157 @@ +package kr.spot.post.infrastructure.batch; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyPostViewFlushJob { + + private static final String KEY_PREFIX = "view:study-board:"; + private static final String KEY_PATTERN = KEY_PREFIX + "*"; + private static final String LOCK_KEY = "lock:study-post-view-flush"; + + private final StringRedisTemplate redis; + private final StudyPostViewFlusher studyPostViewFlusher; + + @Scheduled(cron = "0 * * * * *") + public void flush() { + if (!tryAcquireLock()) { + log.debug("다른 인스턴스에서 실행 중"); + return; + } + try { + executeBatch(); + } finally { + releaseLock(); + } + } + + private void executeBatch() { + log.info("StudyPostViewFlushJob 시작"); + long startTime = System.currentTimeMillis(); + + BatchResult result = processAllKeys(); + + long duration = System.currentTimeMillis() - startTime; + log.info("StudyPostViewFlushJob 완료: 성공 {}건, 실패 {}건, {}ms", + result.successCount, result.failureCount, duration); + } + + private BatchResult processAllKeys() { + BatchResult result = new BatchResult(); + ScanOptions options = ScanOptions.scanOptions() + .match(KEY_PATTERN) + .count(500) + .build(); + + redis.execute((RedisCallback) connection -> { + try (Cursor cursor = connection.scan(options)) { + while (cursor.hasNext()) { + byte[] keyBytes = cursor.next(); + processKey(keyBytes, result); + } + } catch (Exception e) { + log.error("SCAN 처리 중 오류", e); + } + return null; + }); + + return result; + } + + private void processKey(byte[] keyBytes, BatchResult result) { + String key = new String(keyBytes, StandardCharsets.UTF_8); + long delta = 0; + + try { + String valueStr = redis.opsForValue().getAndDelete(key); + if (valueStr == null) { + return; + } + + delta = parseLong(valueStr); + if (delta <= 0) { + log.warn("유효하지 않은 값 무시: key={}, value={}", key, valueStr); + return; + } + + Long postId = extractPostIdFromKey(key); + if (postId == null) { + log.error("postId 추출 실패, 값 손실: key={}, delta={}", key, delta); + return; + } + + studyPostViewFlusher.updateViewCount(postId, delta); + result.recordSuccess(); + log.debug("처리 완료: postId={}, delta={}", postId, delta); + + } catch (Exception e) { + result.recordFailure(); + log.error("처리 실패: key={}", key, e); + if (delta > 0) { + restoreValue(key, delta); + } + } + } + + private void restoreValue(String key, long delta) { + try { + redis.opsForValue().increment(key, delta); + log.warn("DB 반영 실패, Redis에 값 복구 완료: key={}, delta={}", key, delta); + } catch (Exception e) { + log.error("복구 실패, 데이터 손실: key={}, delta={}", key, delta, e); + } + } + + private boolean tryAcquireLock() { + Boolean acquired = redis.opsForValue() + .setIfAbsent(LOCK_KEY, "1", 55, TimeUnit.SECONDS); + return Boolean.TRUE.equals(acquired); + } + + private void releaseLock() { + redis.delete(LOCK_KEY); + } + + private long parseLong(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + log.warn("Long 파싱 실패: {}", value); + return 0L; + } + } + + private Long extractPostIdFromKey(String key) { + try { + return Long.parseLong(key.substring(KEY_PREFIX.length())); + } catch (Exception e) { + log.error("postId 추출 실패: {}", key, e); + return null; + } + } + + private static class BatchResult { + + int successCount = 0; + int failureCount = 0; + + void recordSuccess() { + successCount++; + } + + void recordFailure() { + failureCount++; + } + } +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusher.java b/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusher.java new file mode 100644 index 00000000..77556f1e --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusher.java @@ -0,0 +1,22 @@ +package kr.spot.post.infrastructure.batch; + +import kr.spot.post.infrastructure.jpa.PostStatsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyPostViewFlusher { + + private final PostStatsRepository postStatsRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateViewCount(Long postId, long delta) { + postStatsRepository.increaseViewBy(postId, delta); + log.debug("스터디 게시글 조회수 DB 업데이트: postId={}, delta={}", postId, delta); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java new file mode 100644 index 00000000..0221f94e --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java @@ -0,0 +1,18 @@ +package kr.spot.post.infrastructure.jpa; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.post.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository(value = "studyCommentRepository") +public interface CommentRepository extends JpaRepository { + + default Comment getById(long id) { + return findById(id) + .orElseThrow(() -> new GeneralException(ErrorStatus._COMMENT_NOT_FOUND)); + } + + void deleteByWriterInfoWriterId(long writerId); +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java new file mode 100644 index 00000000..36e6609d --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java @@ -0,0 +1,35 @@ +package kr.spot.post.infrastructure.jpa; + +import kr.spot.post.domain.PostLike; +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.stereotype.Repository; + +@Repository(value = "studyPostLikeRepository") +public interface PostLikeRepository extends JpaRepository { + + @Modifying + @Query( + value = """ + INSERT IGNORE INTO study_post_like(id, post_id, member_id, status, created_at, updated_at) + VALUES (:id, :postId, :memberId, 'ACTIVE', NOW(), NOW()) + """, nativeQuery = true) + int savePostLike(@Param("id") long id, + @Param("postId") long postId, + @Param("memberId") long memberId); + + @Modifying + @Query(value = """ + DELETE FROM study_post_like + WHERE post_id = :postId + AND member_id = :memberId + """, nativeQuery = true) + int hardDelete(@Param("postId") Long postId, + @Param("memberId") Long memberId); + + @Modifying + @Query(value = "DELETE FROM study_post_like WHERE member_id = :memberId", nativeQuery = true) + void deleteAllByMemberId(@Param("memberId") long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java new file mode 100644 index 00000000..5ecea141 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java @@ -0,0 +1,18 @@ +package kr.spot.post.infrastructure.jpa; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.post.domain.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository(value = "studyPostRepository") +public interface PostRepository extends JpaRepository { + + default Post getById(long postId) { + return findById(postId).orElseThrow( + () -> new GeneralException(ErrorStatus._POST_NOT_FOUND)); + } + + void deleteByWriterInfoWriterId(long writerId); +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java new file mode 100644 index 00000000..f65e1ac8 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java @@ -0,0 +1,37 @@ +package kr.spot.post.infrastructure.jpa; + +import kr.spot.post.domain.PostStats; +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.stereotype.Repository; + +@Repository(value = "studyPostStatsRepository") +public interface PostStatsRepository extends JpaRepository { + + @Modifying + @Query("update StudyPostStats s set s.commentCount = s.commentCount + 1 where s.postId = :postId") + int increaseCommentCount(@Param("postId") long postId); + + @Modifying + @Query("update StudyPostStats s set s.commentCount = case when s.commentCount > 0 then s.commentCount - 1 else 0 end where s.postId = :postId") + int decreaseCommentCount(@Param("postId") long postId); + + @Modifying + @Query(""" + update StudyPostStats s + set s.viewCount = s.viewCount + :delta, + s.updatedAt = CURRENT_TIMESTAMP + where s.postId = :postId and s.status = 'ACTIVE' + """) + int increaseViewBy(@Param("postId") long postId, @Param("delta") long delta); + + @Modifying + @Query("update StudyPostStats s set s.likeCount = s.likeCount + 1 where s.postId = :postId") + int increaseLike(@Param("postId") long postId); + + @Modifying + @Query("update StudyPostStats s set s.likeCount = case when s.likeCount > 0 then s.likeCount - 1 else 0 end where s.postId = :postId") + int decreaseLike(@Param("postId") long postId); +} diff --git a/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java new file mode 100644 index 00000000..b4e74e75 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java @@ -0,0 +1,110 @@ +package kr.spot.post.infrastructure.jpa.querydsl; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import kr.spot.post.domain.Comment; +import kr.spot.post.domain.Post; +import kr.spot.post.domain.PostStats; +import kr.spot.post.domain.QComment; +import kr.spot.post.domain.QPost; +import kr.spot.post.domain.QPostLike; +import kr.spot.post.domain.QPostStats; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository(value = "studyPostQueryRepository") +@RequiredArgsConstructor +public class PostQueryRepository { + + private final JPAQueryFactory query; + + private final QPost post = QPost.post; + private final QPostStats postStats = QPostStats.postStats; + private final QComment comment = QComment.comment; + private final QPostLike postLike = QPostLike.postLike; + + public List findPinnedPosts(long studyId) { + return query + .selectFrom(post) + .where( + post.studyId.eq(studyId), + post.pinnedAt.isNotNull() + ) + .orderBy(post.pinnedAt.desc()) + .fetch(); + } + + public List findPageByIdDesc(long studyId, Long cursor, int limit) { + return query + .selectFrom(post) + .where( + post.studyId.eq(studyId), + post.pinnedAt.isNull(), + ltCursor(cursor) + ) + .orderBy(post.id.desc()) + .limit(limit) + .fetch(); + } + + private BooleanExpression ltCursor(Long cursor) { + return (cursor == null) ? null : post.id.lt(cursor); + } + + public Map findStatsByPostIds(Collection postIds) { + if (postIds.isEmpty()) { + return Map.of(); + } + + return query + .selectFrom(postStats) + .where(postStats.postId.in(postIds)) + .fetch() + .stream() + .collect(Collectors.toMap(PostStats::getPostId, it -> it)); + } + + public PostStats findStatsByPostId(long postId) { + return query + .selectFrom(postStats) + .where(postStats.postId.eq(postId)) + .fetchOne(); + } + + public List findCommentsByPostId(long postId) { + return query + .selectFrom(comment) + .where(comment.postId.eq(postId)) + .orderBy(comment.createdAt.asc()) + .fetch(); + } + + public Set findLikedPostIds(Long viewerId, Collection postIds) { + if (viewerId == null || postIds.isEmpty()) { + return Set.of(); + } + return new HashSet<>( + query.select(postLike.postId) + .from(postLike) + .where(postLike.memberId.eq(viewerId), postLike.postId.in(postIds)) + .fetch() + ); + } + + public boolean isLiked(Long viewerId, Long postId) { + if (viewerId == null) { + return false; + } + Integer result = query.selectOne() + .from(postLike) + .where(postLike.memberId.eq(viewerId), postLike.postId.eq(postId)) + .fetchFirst(); + return result != null; + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java new file mode 100644 index 00000000..508852e6 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java @@ -0,0 +1,86 @@ +package kr.spot.post.presentation.command; + +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.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.post.application.command.ManageCommentService; +import kr.spot.post.presentation.command.dto.CreateCommentResponse; +import kr.spot.post.presentation.command.dto.ManageCommentRequest; +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.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; + +@Tag(name = "스터디 게시글 - 댓글") +@RestController("studyCommentCommandController") +@RequestMapping("/api/studies/{studyId}/posts") +@RequiredArgsConstructor +public class CommentCommandController { + + private final ManageCommentService commentService; + + @Operation(summary = "댓글 작성", description = "특정 게시글에 새로운 댓글을 작성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "작성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = """ + - `MEMBER404`: 회원을 찾을 수 없습니다. + - `POST404`: 게시글을 찾을 수 없습니다. + """, content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping("/{postId}/comments") + public ResponseEntity> createComment( + @CurrentMember @Parameter(hidden = true) Long writerId, + @PathVariable Long studyId, + @PathVariable Long postId, + @RequestBody ManageCommentRequest request) { + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED, + commentService.createComment(writerId, postId, studyId, request))); + } + + @Operation(summary = "댓글 수정", description = "기존 댓글을 수정합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "수정 권한이 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`COMMENT404`: 댓글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PutMapping("/comments/{commentId}") + public ResponseEntity> updateComment( + @PathVariable Long commentId, + @PathVariable Long studyId, + @RequestBody ManageCommentRequest request, + @CurrentMember @Parameter(hidden = true) Long writerId) { + commentService.updateComment(writerId, commentId, studyId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } + + @Operation(summary = "댓글 삭제", description = "기존 댓글을 삭제합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "삭제 권한이 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`COMMENT404`: 댓글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @DeleteMapping("/comments/{commentId}") + public ResponseEntity> deleteComment( + @PathVariable Long commentId, + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long writerId + ) { + commentService.deleteComment(writerId, commentId, studyId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } + +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java new file mode 100644 index 00000000..9517d874 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java @@ -0,0 +1,147 @@ +package kr.spot.post.presentation.command; + +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.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.post.application.command.LikePostService; +import kr.spot.post.application.command.ManagePostService; +import kr.spot.post.presentation.command.dto.CreatePostResponse; +import kr.spot.post.presentation.command.dto.ManagePostRequest; +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.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; + +@Tag(name = "스터디 게시글") +@RestController("studyPostCommandController") +@RequestMapping("/api/studies/{studyId}/posts") +@RequiredArgsConstructor +public class PostCommandController { + + private final ManagePostService managePostService; + private final LikePostService likePostService; + + @Operation(summary = "스터디 게시글 생성", description = "스터디에 새로운 게시글을 작성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "게시글 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`STUDY404`: 스터디를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping + public ResponseEntity> createPost( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long writerId, + @RequestBody ManagePostRequest request) { + long postId = managePostService.createPost(studyId, writerId, request); + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._CREATED, CreatePostResponse.from(postId))); + } + + @Operation(summary = "스터디 게시글 수정", description = "스터디의 게시글을 수정합니다. 작성자만 수정할 수 있습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "게시글 수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`POST403`: 작성자만 수정할 수 있습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: 게시글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PutMapping("/{postId}") + public ResponseEntity> updatePost( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long writerId, + @RequestBody ManagePostRequest request) { + managePostService.updatePost(studyId, postId, request, writerId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "스터디 게시글 삭제", description = "스터디의 게시글을 삭제합니다. 작성자만 삭제할 수 있습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "게시글 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`POST403`: 작성자만 삭제할 수 있습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: 게시글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @DeleteMapping("/{postId}") + public ResponseEntity> deletePost( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long writerId) { + managePostService.deletePost(studyId, postId, writerId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "스터디 게시글 핀", description = "스터디의 게시글을 상단에 고정합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "게시글 핀 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`STUDY403`: 해당 스터디에 대한 접근 권한이 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: 게시글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping("/{postId}/pin") + public ResponseEntity> pinPost( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + managePostService.pinPost(studyId, postId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "스터디 게시글 핀 해제", description = "스터디의 게시글 상단 고정을 해제합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "게시글 핀 해제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`STUDY403`: 해당 스터디에 대한 접근 권한이 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: 게시글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @DeleteMapping("/{postId}/pin") + public ResponseEntity> unpinPost( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + managePostService.unpinPost(studyId, postId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "스터디 게시글 좋아요", description = "스터디 게시글에 좋아요를 추가합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "좋아요 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`STUDY403`: 해당 스터디에 대한 접근 권한이 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: 게시글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping("/{postId}/like") + public ResponseEntity> likePost( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + likePostService.likePost(studyId, postId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "스터디 게시글 좋아요 취소", description = "스터디 게시글의 좋아요를 취소합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "좋아요 취소 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`STUDY403`: 해당 스터디에 대한 접근 권한이 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: 게시글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @DeleteMapping("/{postId}/like") + public ResponseEntity> unlikePost( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + likePostService.unlikePost(studyId, postId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreateCommentResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreateCommentResponse.java new file mode 100644 index 00000000..5d77fc68 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreateCommentResponse.java @@ -0,0 +1,8 @@ +package kr.spot.post.presentation.command.dto; + +public record CreateCommentResponse(Long commentId) { + + public static CreateCommentResponse of(Long commentId) { + return new CreateCommentResponse(commentId); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreatePostResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreatePostResponse.java new file mode 100644 index 00000000..a0ded69a --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreatePostResponse.java @@ -0,0 +1,8 @@ +package kr.spot.post.presentation.command.dto; + +public record CreatePostResponse(Long postId) { + + public static CreatePostResponse from(long postId) { + return new CreatePostResponse(postId); + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManageCommentRequest.java b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManageCommentRequest.java new file mode 100644 index 00000000..d781d2c6 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManageCommentRequest.java @@ -0,0 +1,5 @@ +package kr.spot.post.presentation.command.dto; + +public record ManageCommentRequest(String content) { + +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManagePostRequest.java b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManagePostRequest.java new file mode 100644 index 00000000..cd0b4d53 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManagePostRequest.java @@ -0,0 +1,6 @@ +package kr.spot.post.presentation.command.dto; + +public record ManagePostRequest( + String title, String content, Boolean isPrivate) { + +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/PostQueryController.java b/modules/study/src/main/java/kr/spot/post/presentation/query/PostQueryController.java new file mode 100644 index 00000000..34004b04 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/PostQueryController.java @@ -0,0 +1,69 @@ +package kr.spot.post.presentation.query; + +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.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.post.application.query.GetPostService; +import kr.spot.post.presentation.query.dto.PostDetailResponse; +import kr.spot.post.presentation.query.dto.PostListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 게시글") +@RestController("studyPostQueryController") +@RequestMapping("/api/studies/{studyId}/posts") +@RequiredArgsConstructor +public class PostQueryController { + + private final GetPostService getPostService; + + @Operation(summary = "스터디 게시글 상세 조회", description = "특정 게시글의 상세 정보를 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`POST404`: 게시글을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) + }) + @GetMapping("{postId}") + public ResponseEntity> getPostDetail( + @PathVariable Long studyId, + @PathVariable Long postId, + @CurrentMember @Parameter(hidden = true) Long viewerId + ) { + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._OK, getPostService.getPostDetail(studyId, postId, viewerId))); + } + + @Operation(summary = "스터디 게시글 리스트 조회", description = + "스터디 게시글 리스트를 조회합니다. 마지막으로 본 게시글 이후의 게시글들을 페이징하여 가져옵니다." + + "또한 글자 수가 많은 게시글의 경우 일부 내용이 생략되어 제공됩니다. (현재 기준은 100자)") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "size 파라미터는 1과 50 사이여야 합니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) + }) + @GetMapping + public ResponseEntity> getPostList( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long viewerId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size + ) { + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._OK, + getPostService.getPostList(studyId, cursor, viewerId, size))); + } + +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java new file mode 100644 index 00000000..4c729b70 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java @@ -0,0 +1,50 @@ +package kr.spot.post.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record PostDetailResponse( + Long postId, + String title, + String content, + boolean isPinned, + boolean isOwner, + boolean isLiked, + WriterInfoResponse writer, + PostStatsResponse stats, + LocalDateTime createdAt, + List comments +) { + + public record CommentResponse( + Long commentId, + String content, + boolean isOwner, + WriterInfoResponse writer, + LocalDateTime createdAt + ) { + + public static CommentResponse of( + Long commentId, + String content, + boolean isOwner, + WriterInfoResponse writer, + LocalDateTime createdAt + ) { + return new CommentResponse(commentId, content, isOwner, writer, createdAt); + } + } + + public record WriterInfoResponse( + Long writerId, + String nickname, + String profileImageUrl + ) { + + public static WriterInfoResponse of(Long writerId, String nickname, String profileImageUrl) { + return new WriterInfoResponse(writerId, nickname, profileImageUrl); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java new file mode 100644 index 00000000..a180d7fd --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java @@ -0,0 +1,37 @@ +package kr.spot.post.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record PostListResponse( + List posts, + boolean hasNext, + Long nextCursor +) { + + public record PostItem( + Long postId, + String title, + String content, + boolean isPinned, + boolean isLiked, + PostStatsResponse stats, + LocalDateTime createdAt + ) { + + public static PostItem of( + Long postId, + String title, + String content, + boolean isPinned, + boolean isLiked, + PostStatsResponse stats, + LocalDateTime createdAt + ) { + return new PostItem(postId, title, content, isPinned, isLiked, stats, createdAt); + } + } + +} diff --git a/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostStatsResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostStatsResponse.java new file mode 100644 index 00000000..a0111277 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostStatsResponse.java @@ -0,0 +1,12 @@ +package kr.spot.post.presentation.query.dto; + +public record PostStatsResponse( + long likeCount, + long viewCount, + long commentCount +) { + + public static PostStatsResponse from(long likeCount, long viewCount, long commentCount) { + return new PostStatsResponse(likeCount, viewCount, commentCount); + } +} diff --git a/modules/study/src/main/java/kr/spot/presentation/command/StudyCommandController.java b/modules/study/src/main/java/kr/spot/presentation/command/StudyCommandController.java deleted file mode 100644 index e2f7ba8c..00000000 --- a/modules/study/src/main/java/kr/spot/presentation/command/StudyCommandController.java +++ /dev/null @@ -1,52 +0,0 @@ -package kr.spot.presentation.command; - -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.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import kr.spot.ApiResponse; -import kr.spot.annotations.CurrentMember; -import kr.spot.application.command.CreateStudyService; -import kr.spot.code.status.SuccessStatus; -import kr.spot.presentation.command.dto.request.CreateStudyRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -@RestController -@RequestMapping("/api/studies") -@RequiredArgsConstructor -public class StudyCommandController { - - private final CreateStudyService createStudyService; - - @Tag(name = "스터디") - @Operation(summary = "스터디 생성", description = - "새로운 스터디를 생성합니다. 요청 정보는 `multipart/form-data` 형식으로 보내야 하며, 이미지 파일은 선택 사항입니다." - + "request는 application/json 형식으로 보내야 합니다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "생성 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = """ - - `COMMON4000`: 잘못된 요청입니다. (예: 유효하지 않은 카테고리/스타일/지역 코드, 스터디 이름 공백, 최대 인원 수 음수, 유효하지 않은 스터디 비용) - - `MEMBER4001`: 이름은 null 또는 공백일 수 없습니다. - - `STUDY4000`: 최대 인원 수는 양수여야 합니다. - - `STUDY4000`: 유효하지 않은 스터디 비용입니다. - - `STUDY4001`: 존재하지 않는 카테고리입니다. - - `REGION4000`: 존재하지 않는 지역 코드입니다. - """, content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) - }) - @PostMapping - public ResponseEntity> createStudy(@RequestPart CreateStudyRequest request, - @CurrentMember @Parameter(hidden = true) Long memberId, - @RequestPart(required = false) MultipartFile imageFile) { - createStudyService.createStudy(request, memberId, imageFile); - return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); - } -} diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java new file mode 100644 index 00000000..f74de2da --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java @@ -0,0 +1,43 @@ +package kr.spot.review.application.command; + +import kr.spot.IdGenerator; +import kr.spot.review.domain.associations.ReviewReaction; +import kr.spot.review.domain.enums.Reaction; +import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; +import kr.spot.review.infrastructure.jpa.ReviewRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ManageReviewReactionService { + + private final IdGenerator idGenerator; + private final ReviewRepository reviewRepository; + private final ReviewReactionRepository reviewReactionRepository; + private final StudyAccessValidator studyAccessValidator; + + public void addReaction(long studyId, long reviewId, long memberId, Reaction reaction) { + studyAccessValidator.validateStudyMember(studyId, memberId); + reviewRepository.validateExists(reviewId); + + if (reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(reviewId, memberId, + reaction)) { + return; + } + + ReviewReaction reviewReaction = ReviewReaction.of( + idGenerator.nextId(), reviewId, memberId, reaction); + reviewReactionRepository.save(reviewReaction); + } + + public void removeReaction(long studyId, long reviewId, long memberId, Reaction reaction) { + studyAccessValidator.validateStudyMember(studyId, memberId); + reviewRepository.validateExists(reviewId); + + reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name()); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java new file mode 100644 index 00000000..389e49c7 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java @@ -0,0 +1,87 @@ +package kr.spot.review.application.command; + +import java.util.List; +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.ports.FileStoragePort; +import kr.spot.ports.GetWriterInfoPort; +import kr.spot.ports.dto.UploadResult; +import kr.spot.ports.dto.WriterInfoResponse; +import kr.spot.review.domain.Review; +import kr.spot.review.domain.vo.Content; +import kr.spot.review.domain.vo.WriterInfo; +import kr.spot.review.infrastructure.jpa.ReviewRepository; +import kr.spot.review.presentation.command.dto.CreateReviewRequest; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Transactional +@RequiredArgsConstructor +public class ManageReviewService { + + private static final String REVIEW_IMAGE_DIR = "reviews"; + private static final int MAX_REVIEW_IMAGE_COUNT = 3; + + private final IdGenerator idGenerator; + private final GetWriterInfoPort getWriterInfoPort; + private final FileStoragePort fileStoragePort; + private final ReviewRepository reviewRepository; + private final StudyAccessValidator studyAccessValidator; + + public long createReview(long studyId, long memberId, CreateReviewRequest request, + List imageFiles) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + WriterInfo writerInfo = getWriterInfo(memberId); + List imageUrls = uploadImages(imageFiles); + Content content = Content.of(request.activity(), request.learned(), request.encouragement(), + imageUrls); + + long reviewId = idGenerator.nextId(); + Review review = Review.of(reviewId, studyId, writerInfo, content, request.isPrivate()); + reviewRepository.save(review); + return reviewId; + } + + public void deleteReview(long studyId, long reviewId, long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + Review review = reviewRepository.getById(reviewId); + review.getWriterInfo().validateIsOwnMember(memberId); + + reviewRepository.delete(review); + } + + private WriterInfo getWriterInfo(long memberId) { + WriterInfoResponse response = getWriterInfoPort.get(memberId); + return WriterInfo.of(response.writerId(), response.nickname(), response.profileImageUrl()); + } + + private List uploadImages(List imageFiles) { + if (imageFiles == null || imageFiles.isEmpty()) { + return List.of(); + } + + List validImageFiles = imageFiles.stream() + .filter(file -> file != null && !file.isEmpty()) + .toList(); + + if (validImageFiles.size() > MAX_REVIEW_IMAGE_COUNT) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + + return validImageFiles.stream() + .map(this::uploadImage) + .toList(); + } + + private String uploadImage(MultipartFile imageFile) { + UploadResult result = fileStoragePort.upload(imageFile, REVIEW_IMAGE_DIR); + return result.url(); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawn.java b/modules/study/src/main/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawn.java new file mode 100644 index 00000000..62914fe5 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/application/event/CleanupReviewOnMemberWithdrawn.java @@ -0,0 +1,26 @@ +package kr.spot.review.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.review.infrastructure.jpa.ReviewReactionRepository; +import kr.spot.review.infrastructure.jpa.ReviewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Order(4) +@Component +@RequiredArgsConstructor +public class CleanupReviewOnMemberWithdrawn { + + private final ReviewReactionRepository reviewReactionRepository; + private final ReviewRepository reviewRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + long memberId = event.memberId(); + reviewReactionRepository.deleteAllByMemberId(memberId); + reviewRepository.deleteByWriterInfoWriterId(memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java new file mode 100644 index 00000000..628ff55f --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java @@ -0,0 +1,109 @@ +package kr.spot.review.application.query; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import kr.spot.review.domain.Review; +import kr.spot.review.domain.enums.Reaction; +import kr.spot.review.domain.vo.Content; +import kr.spot.review.domain.vo.WriterInfo; +import kr.spot.review.infrastructure.jpa.querydsl.ReviewQueryRepository; +import kr.spot.review.infrastructure.jpa.querydsl.ReviewQueryRepository.ReactionCounts; +import kr.spot.review.presentation.query.dto.GetReviewListResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.ContentResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.ReactionCountResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.ReactionResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.ReviewResponse; +import kr.spot.review.presentation.query.dto.GetReviewListResponse.WriterInfoResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetReviewService { + + private static final String PRIVATE_CONTENT_MESSAGE = "이 글은 스터디원에게만 노출됩니다."; + + private final ReviewQueryRepository reviewQueryRepository; + private final StudyAccessValidator studyAccessValidator; + + public GetReviewListResponse getReviewList(long studyId, Long viewerId, Long cursor, int size) { + boolean isStudyMember = + viewerId != null && studyAccessValidator.isStudyMember(studyId, viewerId); + List reviews = reviewQueryRepository.findByStudyIdWithCursor(studyId, cursor, + size + 1); + + boolean hasNext = reviews.size() > size; + if (hasNext) { + reviews = reviews.subList(0, size); + } + + List reviewIds = reviews.stream().map(Review::getId).toList(); + Map reactionCountsMap = reviewQueryRepository.findReactionCountsByReviewIds( + reviewIds); + Map> memberReactionsMap = reviewQueryRepository.findMemberReactionsByReviewIds( + viewerId, reviewIds); + + long totalElements = reviewQueryRepository.countByStudyId(studyId); + Long nextCursor = hasNext && !reviews.isEmpty() ? reviews.getLast().getId() + : null; + + List reviewResponses = reviews.stream() + .map(review -> toReviewResponse(review, reactionCountsMap, memberReactionsMap, + isStudyMember)) + .toList(); + + return new GetReviewListResponse(reviewResponses, hasNext, nextCursor, totalElements); + } + + private ReviewResponse toReviewResponse(Review review, + Map reactionCountsMap, + Map> memberReactionsMap, + boolean isStudyMember) { + + WriterInfo writerInfo = review.getWriterInfo(); + Content content = review.getContent(); + ReactionCounts counts = reactionCountsMap.getOrDefault(review.getId(), + new ReactionCounts(0, 0, 0, 0)); + Set memberReactions = memberReactionsMap.getOrDefault(review.getId(), Set.of()); + + ContentResponse contentResponse = createContentResponse(review, content, isStudyMember); + + return ReviewResponse.from( + review.getId(), + WriterInfoResponse.from(writerInfo.getWriterId(), writerInfo.getWriterName(), + writerInfo.getWriterProfileImageUrl()), + contentResponse, + ReactionCountResponse.from(counts.fire(), counts.heart(), counts.star(), counts.smile()), + ReactionResponse.from( + memberReactions.contains(Reaction.FIRE), + memberReactions.contains(Reaction.HEART), + memberReactions.contains(Reaction.STAR), + memberReactions.contains(Reaction.SMILE) + ), + review.isPrivate(), + review.getCreatedAt() + ); + } + + private ContentResponse createContentResponse(Review review, Content content, + boolean isStudyMember) { + if (review.isPrivate() && !isStudyMember) { + return ContentResponse.from( + PRIVATE_CONTENT_MESSAGE, + PRIVATE_CONTENT_MESSAGE, + PRIVATE_CONTENT_MESSAGE, + List.of() + ); + } + return ContentResponse.from( + content.getActivity(), + content.getLearned(), + content.getEncouragement(), + content.getImageUrls() + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/Review.java b/modules/study/src/main/java/kr/spot/review/domain/Review.java new file mode 100644 index 00000000..36d93735 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/Review.java @@ -0,0 +1,45 @@ +package kr.spot.review.domain; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import kr.spot.domain.BaseEntity; +import kr.spot.review.domain.vo.Content; +import kr.spot.review.domain.vo.WriterInfo; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE review SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Review extends BaseEntity { + + @Id + private Long id; + + private Long studyId; + + @Embedded + private WriterInfo writerInfo; + + @Embedded + private Content content; + + private Boolean isPrivate; + + public static Review of(Long id, Long studyId, WriterInfo writerInfo, Content content, + Boolean isPrivate) { + return new Review(id, studyId, writerInfo, content, isPrivate); + } + + public boolean isPrivate() { + return Boolean.TRUE.equals(isPrivate); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java b/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java new file mode 100644 index 00000000..788c4be4 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java @@ -0,0 +1,46 @@ +package kr.spot.review.domain.associations; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import kr.spot.domain.BaseEntity; +import kr.spot.review.domain.enums.Reaction; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE review_reaction SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table( + name = "review_reaction", + uniqueConstraints = @UniqueConstraint( + name = "uk_review_member_reaction", + columnNames = {"reviewId", "memberId", "reaction"} + ) +) +public class ReviewReaction extends BaseEntity { + + @Id + private Long id; + + private Long reviewId; + + private Long memberId; + + @Enumerated(EnumType.STRING) + private Reaction reaction; + + public static ReviewReaction of(Long id, Long reviewId, Long memberId, Reaction reaction) { + return new ReviewReaction(id, reviewId, memberId, reaction); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/enums/Reaction.java b/modules/study/src/main/java/kr/spot/review/domain/enums/Reaction.java new file mode 100644 index 00000000..a8539692 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/enums/Reaction.java @@ -0,0 +1,8 @@ +package kr.spot.review.domain.enums; + +public enum Reaction { + FIRE, + HEART, + STAR, + SMILE +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java b/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java new file mode 100644 index 00000000..162538c8 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java @@ -0,0 +1,65 @@ +package kr.spot.review.domain.vo; + +import jakarta.persistence.Embeddable; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Content { + + // 오늘은 무엇을 했나요? + private String activity; + + // 오늘 새롭게 배운 점은 무엇인가요? + private String learned; + + // 고생한 나에게 한마디 + private String encouragement; + + private String imageUrl; + private String imageUrl2; + private String imageUrl3; + + public static Content of(String activity, String learned, + String encouragement, List imageUrls) { + List safeImageUrls = imageUrls == null ? List.of() : imageUrls; + return new Content( + activity, + learned, + encouragement, + getImageUrlByIndex(safeImageUrls, 0), + getImageUrlByIndex(safeImageUrls, 1), + getImageUrlByIndex(safeImageUrls, 2) + ); + } + + public List getImageUrls() { + List imageUrls = new ArrayList<>(); + if (imageUrl != null) { + imageUrls.add(imageUrl); + } + if (imageUrl2 != null) { + imageUrls.add(imageUrl2); + } + if (imageUrl3 != null) { + imageUrls.add(imageUrl3); + } + return List.copyOf(imageUrls); + } + + private static String getImageUrlByIndex(List imageUrls, int index) { + if (index >= imageUrls.size()) { + return null; + } + return imageUrls.get(index); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/domain/vo/WriterInfo.java b/modules/study/src/main/java/kr/spot/review/domain/vo/WriterInfo.java new file mode 100644 index 00000000..02b0c712 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/domain/vo/WriterInfo.java @@ -0,0 +1,40 @@ +package kr.spot.review.domain.vo; + +import jakarta.persistence.Embeddable; +import java.util.Objects; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class WriterInfo { + + private Long writerId; + + private String writerName; + + private String WriterProfileImageUrl; + + public static WriterInfo of(Long writerId, String writerName, + String writerProfileImageUrl) { + return new WriterInfo(writerId, writerName, writerProfileImageUrl); + } + + public void validateIsOwnMember(Long currentUserId) { + if (!Objects.equals(writerId, currentUserId)) { + throw new GeneralException(ErrorStatus._ONLY_AUTHOR_CAN_MODIFY); + } + } + + public boolean isSameWriter(Long memberId) { + return Objects.equals(this.writerId, memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java new file mode 100644 index 00000000..9d53b424 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java @@ -0,0 +1,29 @@ +package kr.spot.review.infrastructure.jpa; + +import kr.spot.review.domain.associations.ReviewReaction; +import kr.spot.review.domain.enums.Reaction; +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; + +public interface ReviewReactionRepository extends JpaRepository { + + boolean existsByReviewIdAndMemberIdAndReaction( + Long reviewId, Long memberId, Reaction reaction); + + @Modifying + @Query(value = """ + DELETE FROM review_reaction + WHERE review_id = :reviewId + AND member_id = :memberId + AND reaction = :reaction + """, nativeQuery = true) + int hardDelete(@Param("reviewId") Long reviewId, + @Param("memberId") Long memberId, + @Param("reaction") String reaction); + + @Modifying + @Query(value = "DELETE FROM review_reaction WHERE member_id = :memberId", nativeQuery = true) + void deleteAllByMemberId(@Param("memberId") long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java new file mode 100644 index 00000000..d6daf770 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java @@ -0,0 +1,22 @@ +package kr.spot.review.infrastructure.jpa; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.review.domain.Review; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository { + + default Review getById(long reviewId) { + return findById(reviewId).orElseThrow( + () -> new GeneralException(ErrorStatus._REVIEW_NOT_FOUND)); + } + + default void validateExists(long reviewId) { + if (!existsById(reviewId)) { + throw new GeneralException(ErrorStatus._REVIEW_NOT_FOUND); + } + } + + void deleteByWriterInfoWriterId(long writerId); +} diff --git a/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/querydsl/ReviewQueryRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/querydsl/ReviewQueryRepository.java new file mode 100644 index 00000000..aea9bfcf --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/querydsl/ReviewQueryRepository.java @@ -0,0 +1,121 @@ +package kr.spot.review.infrastructure.jpa.querydsl; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import kr.spot.review.domain.QReview; +import kr.spot.review.domain.Review; +import kr.spot.review.domain.associations.QReviewReaction; +import kr.spot.review.domain.enums.Reaction; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ReviewQueryRepository { + + private final JPAQueryFactory query; + + private final QReview review = QReview.review; + private final QReviewReaction reviewReaction = QReviewReaction.reviewReaction; + + public List findByStudyIdWithCursor(long studyId, Long cursor, int limit) { + return query + .selectFrom(review) + .where( + review.studyId.eq(studyId), + ltCursor(cursor) + ) + .orderBy(review.id.desc()) + .limit(limit) + .fetch(); + } + + private BooleanExpression ltCursor(Long cursor) { + return (cursor == null) ? null : review.id.lt(cursor); + } + + public long countByStudyId(long studyId) { + Long count = query + .select(review.count()) + .from(review) + .where(review.studyId.eq(studyId)) + .fetchOne(); + return count != null ? count : 0L; + } + + public Map findReactionCountsByReviewIds(Collection reviewIds) { + if (reviewIds.isEmpty()) { + return Map.of(); + } + + List rows = query + .select(Projections.constructor(ReactionCountRow.class, + reviewReaction.reviewId, + reviewReaction.reaction, + reviewReaction.count())) + .from(reviewReaction) + .where(reviewReaction.reviewId.in(reviewIds)) + .groupBy(reviewReaction.reviewId, reviewReaction.reaction) + .fetch(); + + return rows.stream() + .collect(Collectors.groupingBy( + ReactionCountRow::reviewId, + Collectors.collectingAndThen( + Collectors.toList(), + this::toReactionCounts + ) + )); + } + + private ReactionCounts toReactionCounts(List rows) { + long fire = 0, heart = 0, star = 0, smile = 0; + for (ReactionCountRow row : rows) { + switch (row.reaction()) { + case FIRE -> fire = row.count(); + case HEART -> heart = row.count(); + case STAR -> star = row.count(); + case SMILE -> smile = row.count(); + } + } + return new ReactionCounts(fire, heart, star, smile); + } + + public Map> findMemberReactionsByReviewIds( + Long memberId, Collection reviewIds) { + if (memberId == null || reviewIds.isEmpty()) { + return Map.of(); + } + + return query + .select(reviewReaction.reviewId, reviewReaction.reaction) + .from(reviewReaction) + .where( + reviewReaction.memberId.eq(memberId), + reviewReaction.reviewId.in(reviewIds) + ) + .fetch() + .stream() + .collect(Collectors.groupingBy( + tuple -> tuple.get(reviewReaction.reviewId), + Collectors.mapping( + tuple -> tuple.get(reviewReaction.reaction), + Collectors.toSet() + ) + )); + } + + public record ReactionCountRow(Long reviewId, Reaction reaction, Long count) { + + } + + public record ReactionCounts(long fire, long heart, long star, long smile) { + + } +} diff --git a/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java b/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java new file mode 100644 index 00000000..4f99ef04 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java @@ -0,0 +1,97 @@ +package kr.spot.review.presentation.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.ArrayList; +import java.util.List; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.review.application.command.ManageReviewReactionService; +import kr.spot.review.application.command.ManageReviewService; +import kr.spot.review.domain.enums.Reaction; +import kr.spot.review.presentation.command.dto.CreateReviewRequest; +import kr.spot.review.presentation.command.dto.CreateReviewResponse; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "스터디 회고록") +@RestController +@RequestMapping("/api/studies/{studyId}/reviews") +@RequiredArgsConstructor +public class ReviewCommandController { + + private final ManageReviewService manageReviewService; + private final ManageReviewReactionService manageReviewReactionService; + + @Operation(summary = "스터디 회고록 작성", description = "특정 스터디에 대한 회고록을 작성합니다.") + @PostMapping + public ResponseEntity> createReview( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestPart CreateReviewRequest request, + @RequestPart(required = false) MultipartFile imageFile, + @RequestPart(required = false) List imageFiles + ) { + long reviewId = manageReviewService.createReview(studyId, memberId, request, + mergeImageFiles(imageFile, imageFiles)); + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._CREATED, CreateReviewResponse.from(reviewId))); + } + + @Operation(summary = "스터디 회고록 삭제", description = "특정 스터디에 대한 회고록을 삭제합니다.") + @DeleteMapping("/{reviewId}") + public ResponseEntity> deleteReview( + @PathVariable Long studyId, + @PathVariable Long reviewId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + manageReviewService.deleteReview(studyId, reviewId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } + + @Operation(summary = "스터디 회고록 반응 추가", description = "특정 스터디 회고록에 대한 반응을 추가합니다.") + @PostMapping("/{reviewId}/reactions") + public ResponseEntity> addReaction( + @PathVariable Long studyId, + @PathVariable Long reviewId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestParam Reaction reaction + ) { + manageReviewReactionService.addReaction(studyId, reviewId, memberId, reaction); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED)); + } + + @Operation(summary = "스터디 회고록 반응 제거", description = "특정 스터디 회고록에 대한 반응을 제거합니다.") + @DeleteMapping("/{reviewId}/reactions") + public ResponseEntity> removeReaction( + @PathVariable Long studyId, + @PathVariable Long reviewId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestParam Reaction reaction + ) { + manageReviewReactionService.removeReaction(studyId, reviewId, memberId, reaction); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._NO_CONTENT)); + } + + private List mergeImageFiles(MultipartFile imageFile, + List imageFiles) { + List mergedFiles = new ArrayList<>(); + if (imageFiles != null) { + mergedFiles.addAll(imageFiles); + } + if (imageFile != null) { + mergedFiles.add(imageFile); + } + return mergedFiles; + } +} diff --git a/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewRequest.java b/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewRequest.java new file mode 100644 index 00000000..1b0ff5fd --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewRequest.java @@ -0,0 +1,10 @@ +package kr.spot.review.presentation.command.dto; + +public record CreateReviewRequest( + String activity, + String learned, + String encouragement, + Boolean isPrivate +) { + +} diff --git a/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewResponse.java b/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewResponse.java new file mode 100644 index 00000000..5e3020dc --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewResponse.java @@ -0,0 +1,8 @@ +package kr.spot.review.presentation.command.dto; + +public record CreateReviewResponse(Long reviewId) { + + public static CreateReviewResponse from(long reviewId) { + return new CreateReviewResponse(reviewId); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java b/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java new file mode 100644 index 00000000..acdf0e74 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java @@ -0,0 +1,42 @@ +package kr.spot.review.presentation.query; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.review.application.query.GetReviewService; +import kr.spot.review.presentation.query.dto.GetReviewListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 회고록") +@RestController +@RequestMapping("/api/studies/{studyId}/reviews") +@RequiredArgsConstructor +public class ReviewQueryController { + + private final GetReviewService getReviewService; + + + @Operation(summary = "스터디 회고록 목록 조회", description = "특정 스터디의 회고록 목록을 조회합니다. 커서 기반 페이지네이션을 지원합니다.") + @GetMapping + public ResponseEntity> getReviewList( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Min(1) @Max(50) Integer size + ) { + GetReviewListResponse response = getReviewService.getReviewList(studyId, memberId, cursor, + size); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java b/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java new file mode 100644 index 00000000..c069d183 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java @@ -0,0 +1,87 @@ +package kr.spot.review.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record GetReviewListResponse( + List reviews, + boolean hasNext, + Long nextCursor, + long totalElements +) { + + public record ReviewResponse( + Long reviewId, + WriterInfoResponse writer, + ContentResponse content, + ReactionCountResponse reactionCounts, + ReactionResponse reactions, + boolean isPrivate, + LocalDateTime createdAt + ) { + + public static ReviewResponse from( + Long reviewId, + WriterInfoResponse writer, + ContentResponse content, + ReactionCountResponse reactionCounts, + ReactionResponse reactions, + boolean isPrivate, + LocalDateTime createdAt + ) { + return new ReviewResponse(reviewId, writer, content, reactionCounts, reactions, isPrivate, + createdAt + ); + } + } + + public record WriterInfoResponse( + Long memberId, + String nickname, + String profileImageUrl + ) { + + public static WriterInfoResponse from(Long memberId, String nickname, String profileImageUrl) { + return new WriterInfoResponse(memberId, nickname, profileImageUrl); + } + } + + public record ContentResponse( + String activity, + String learned, + String encouragement, + List imageUrls + ) { + + public static ContentResponse from(String activity, String learned, String encouragement, + List imageUrls) { + return new ContentResponse(activity, learned, encouragement, imageUrls); + } + } + + public record ReactionCountResponse( + long fireCount, + long heartCount, + long starCount, + long smileCount + ) { + + public static ReactionCountResponse from(long fireCount, long heartCount, long starCount, + long smileCount) { + return new ReactionCountResponse(fireCount, heartCount, starCount, smileCount); + } + } + + public record ReactionResponse( + boolean isFired, + boolean isHearted, + boolean isStarred, + boolean isSmiled + ) { + + public static ReactionResponse from(boolean isFired, boolean isHearted, boolean isStarred, + boolean isSmiled) { + return new ReactionResponse(isFired, isHearted, isStarred, isSmiled); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java new file mode 100644 index 00000000..ff698e1d --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/AttendanceCommandService.java @@ -0,0 +1,133 @@ +package kr.spot.schedule.application.command; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.ports.GetMemberInfoPort; +import kr.spot.ports.dto.MemberInfoResponse; +import kr.spot.schedule.application.event.AttendanceStartedEvent; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.domain.vo.MemberInfo; +import kr.spot.schedule.infrastructure.crypto.AttendanceTokenEncryptor; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class AttendanceCommandService { + + private static final List ACTIVE_MEMBER_STATUSES = + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED); + + private final IdGenerator idGenerator; + private final ScheduleRepository scheduleRepository; + private final AttendanceRepository attendanceRepository; + private final StudyMemberRepository studyMemberRepository; + private final StudyAccessValidator studyAccessValidator; + private final GetMemberInfoPort getMemberInfoPort; + private final ApplicationEventPublisher eventPublisher; + private final AttendanceTokenEncryptor tokenEncryptor; + + public void startAttendance(long studyId, long scheduleId, long memberId) { + studyAccessValidator.validateStudyLeader(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.startAttendance(studyId); + + createAttendancesForAllMembers(studyId, scheduleId); + + String qrContent = generateEncryptedToken(studyId, scheduleId); + eventPublisher.publishEvent(AttendanceStartedEvent.of(studyId, scheduleId, qrContent)); + } + + public void stopAttendance(long studyId, long scheduleId, long memberId) { + studyAccessValidator.validateStudyLeader(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.stopAttendance(studyId); + + markAbsentForUndecidedAttendances(scheduleId); + } + + private void markAbsentForUndecidedAttendances(long scheduleId) { + List attendances = attendanceRepository.findAllByScheduleId(scheduleId); + attendances.forEach(Attendance::markAbsentIfUndecided); + } + + public void checkAttendance(String encryptedToken, long memberId) { + AttendanceTokenData tokenData = decryptToken(encryptedToken); + + studyAccessValidator.validateStudyMember(tokenData.studyId(), memberId); + + Schedule schedule = scheduleRepository.getById(tokenData.scheduleId()); + schedule.validateAttendanceCheckable(); + + Attendance attendance = attendanceRepository + .findByScheduleIdAndMemberInfoMemberId(tokenData.scheduleId(), memberId) + .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_MEMBER_NOT_FOUND)); + + attendance.markAttendance(AttendanceStatus.PRESENT); + } + + private void createAttendancesForAllMembers(long studyId, long scheduleId) { + List activeMembers = studyMemberRepository + .findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_MEMBER_STATUSES); + + List memberIds = activeMembers.stream() + .map(StudyMember::getMemberId) + .toList(); + + Map memberInfoMap = getMemberInfoPort.getMemberInfo(memberIds); + + List attendances = activeMembers.stream() + .map(studyMember -> { + MemberInfoResponse info = memberInfoMap.get(studyMember.getMemberId()); + MemberInfo memberInfo = MemberInfo.of( + studyMember.getMemberId(), + info.name(), + info.profileImageUrl() + ); + Attendance attendance = Attendance.createPending(idGenerator.nextId(), scheduleId, + memberInfo); + if (studyMember.getStudyMemberStatus() == StudyMemberStatus.OWNER) { + attendance.markAttendance(AttendanceStatus.PRESENT); + } + return attendance; + }) + .toList(); + + attendanceRepository.saveAll(attendances); + } + + private String generateEncryptedToken(long studyId, long scheduleId) { + String plainText = String.format("%d:%d:%d", studyId, scheduleId, Instant.now().toEpochMilli()); + return tokenEncryptor.encrypt(plainText); + } + + private AttendanceTokenData decryptToken(String encryptedToken) { + String plainText = tokenEncryptor.decrypt(encryptedToken); + String[] parts = plainText.split(":"); + if (parts.length != 3) { + throw new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN); + } + return new AttendanceTokenData(Long.parseLong(parts[0]), Long.parseLong(parts[1])); + } + + private record AttendanceTokenData(long studyId, long scheduleId) { + + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/command/ManageScheduleService.java b/modules/study/src/main/java/kr/spot/schedule/application/command/ManageScheduleService.java new file mode 100644 index 00000000..81f6f548 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/command/ManageScheduleService.java @@ -0,0 +1,46 @@ +package kr.spot.schedule.application.command; + +import kr.spot.IdGenerator; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.command.dto.CreateScheduleRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ManageScheduleService { + + private final IdGenerator idGenerator; + private final ScheduleRepository scheduleRepository; + + public long createSchedule(CreateScheduleRequest request, long studyId, long creatorId) { + validateScheduleTimeConflict(request, studyId); + + long scheduleId = idGenerator.nextId(); + Schedule schedule = Schedule.of(scheduleId, studyId, creatorId, request.title(), + request.locationInfo(), + request.startAt(), request.endAt()); + + scheduleRepository.save(schedule); + return scheduleId; + } + + public void deleteSchedule(long studyId, long scheduleId) { + Schedule schedule = scheduleRepository.getById(scheduleId); + schedule.delete(studyId); + } + + private void validateScheduleTimeConflict(CreateScheduleRequest request, long studyId) { + boolean hasConflict = scheduleRepository.existsByStudyIdAndStartAtLessThanAndEndAtGreaterThan( + studyId, request.endAt(), request.startAt()); + + if (hasConflict) { + throw new GeneralException(ErrorStatus._SCHEDULE_TIME_CONFLICT); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java new file mode 100644 index 00000000..3bc6750f --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceQrCodeUploadListener.java @@ -0,0 +1,55 @@ +package kr.spot.schedule.application.event; + +import kr.spot.ports.FileStoragePort; +import kr.spot.ports.dto.UploadResult; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.infrastructure.qr.ByteArrayMultipartFile; +import kr.spot.schedule.infrastructure.qr.QrCodeGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AttendanceQrCodeUploadListener { + + private static final String FILE_DIR = "attendance/qr/"; + private static final String CONTENT_TYPE = "image/png"; + + private final QrCodeGenerator qrCodeGenerator; + private final FileStoragePort fileStoragePort; + private final ScheduleRepository scheduleRepository; + + @Async("imageUploadExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleAttendanceStarted(AttendanceStartedEvent event) { + try { + byte[] qrCodeImage = qrCodeGenerator.generate(event.qrContent()); + String filename = generateFilename(event.studyId(), event.scheduleId()); + MultipartFile multipartFile = new ByteArrayMultipartFile(qrCodeImage, filename, CONTENT_TYPE); + + UploadResult result = fileStoragePort.upload(multipartFile, FILE_DIR); + + Schedule schedule = scheduleRepository.getById(event.scheduleId()); + schedule.updateQrCodeImageUrl(result.url()); + + log.info("Successfully uploaded QR code image for schedule: {}", event.scheduleId()); + } catch (Exception e) { + log.error("Failed to upload QR code image for schedule: {}", event.scheduleId(), e); + } + } + + private String generateFilename(long studyId, long scheduleId) { + return String.format("study_%d_schedule_%d_%d.png", studyId, scheduleId, + System.currentTimeMillis()); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java new file mode 100644 index 00000000..e1a70fb8 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/AttendanceStartedEvent.java @@ -0,0 +1,12 @@ +package kr.spot.schedule.application.event; + +public record AttendanceStartedEvent( + long studyId, + long scheduleId, + String qrContent +) { + + public static AttendanceStartedEvent of(long studyId, long scheduleId, String qrContent) { + return new AttendanceStartedEvent(studyId, scheduleId, qrContent); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawn.java b/modules/study/src/main/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawn.java new file mode 100644 index 00000000..9c11f2e9 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/event/CleanupAttendanceOnMemberWithdrawn.java @@ -0,0 +1,22 @@ +package kr.spot.schedule.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Order(2) +@Component +@RequiredArgsConstructor +public class CleanupAttendanceOnMemberWithdrawn { + + private final AttendanceRepository attendanceRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + attendanceRepository.deleteByMemberInfoMemberId(event.memberId()); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java new file mode 100644 index 00000000..e944aaf3 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetAttendanceService.java @@ -0,0 +1,57 @@ +package kr.spot.schedule.application.query; + +import java.util.List; +import kr.spot.schedule.domain.Attendance; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.AttendanceRepository; +import kr.spot.schedule.infrastructure.jpa.ScheduleRepository; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse.AttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse.MemberInfoResponse; +import kr.spot.study.application.validator.StudyAccessValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetAttendanceService { + + private final ScheduleRepository scheduleRepository; + private final AttendanceRepository attendanceRepository; + private final StudyAccessValidator studyAccessValidator; + + public GetAttendanceInfoResponse getAttendanceQrCode(long studyId, long scheduleId, + long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + Schedule schedule = scheduleRepository.getById(scheduleId); + return GetAttendanceInfoResponse.of( + schedule.isAttendanceActive(), + schedule.getAttendanceQrCodeImageUrl() + ); + } + + public GetAttendanceListResponse getAttendanceList(long studyId, long scheduleId, + long memberId) { + studyAccessValidator.validateStudyMember(studyId, memberId); + + List attendances = attendanceRepository.findAllByScheduleId(scheduleId); + + List attendanceInfos = attendances.stream() + .map(attendance -> AttendanceInfoResponse.from( + MemberInfoResponse.from( + attendance.getMemberInfo().getMemberId(), + attendance.getMemberInfo().getMemberName(), + attendance.getMemberInfo().getMemberProfileImageUrl() + ), + attendance.getAttendanceStatus().name(), + attendance.getAttendedAt() + )) + .toList(); + + return GetAttendanceListResponse.from(attendanceInfos, attendanceInfos.size()); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java b/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java new file mode 100644 index 00000000..6a865a30 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java @@ -0,0 +1,52 @@ +package kr.spot.schedule.application.query; + +import java.time.LocalDate; +import java.util.List; +import kr.spot.schedule.domain.Schedule; +import kr.spot.schedule.infrastructure.jpa.querydsl.ScheduleQueryRepository; +import kr.spot.schedule.presentation.query.dto.GetScheduleListResponse; +import kr.spot.schedule.presentation.query.dto.GetScheduleListResponse.ScheduleResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetScheduleService { + + private static final int UPCOMING_LIMIT = 2; + + private final ScheduleQueryRepository scheduleQueryRepository; + + public GetScheduleListResponse getMonthlySchedules(long studyId, int year, int month, long memberId) { + LocalDate date = LocalDate.of(year, month, 1); + List schedules = scheduleQueryRepository.findMonthlySchedules(studyId, date); + return toResponse(schedules, memberId); + } + + public GetScheduleListResponse getWeeklySchedules(long studyId, LocalDate date, long memberId) { + List schedules = scheduleQueryRepository.findWeeklySchedules(studyId, date); + return toResponse(schedules, memberId); + } + + public GetScheduleListResponse getUpcomingSchedules(long studyId, long memberId) { + List schedules = scheduleQueryRepository.findUpcomingSchedules(studyId, UPCOMING_LIMIT); + return toResponse(schedules, memberId); + } + + private GetScheduleListResponse toResponse(List schedules, long memberId) { + List responses = schedules.stream() + .map(schedule -> ScheduleResponse.from( + schedule.getId(), + schedule.getTitle(), + schedule.getStartAt(), + schedule.getEndAt(), + schedule.isAttendanceActive(), + schedule.getCreatorId() != null && schedule.getCreatorId() == memberId + )) + .toList(); + + return GetScheduleListResponse.from(responses); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java new file mode 100644 index 00000000..843eb03a --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Attendance.java @@ -0,0 +1,56 @@ +package kr.spot.schedule.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import kr.spot.domain.BaseEntity; +import kr.spot.schedule.domain.enums.AttendanceStatus; +import kr.spot.schedule.domain.vo.MemberInfo; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE attendance SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Attendance extends BaseEntity { + + @Id + private Long id; + + private Long scheduleId; + + private MemberInfo memberInfo; + + @Enumerated(EnumType.STRING) + private AttendanceStatus attendanceStatus; + + private LocalDateTime attendedAt; + + public static Attendance of(long id, long scheduleId, MemberInfo memberInfo) { + return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, null); + } + + public static Attendance createPending(long id, long scheduleId, MemberInfo memberInfo) { + return new Attendance(id, scheduleId, memberInfo, AttendanceStatus.UNDECIDED, null); + } + + public void markAttendance(AttendanceStatus status) { + this.attendanceStatus = status; + this.attendedAt = LocalDateTime.now(); + } + + public void markAbsentIfUndecided() { + if (this.attendanceStatus == AttendanceStatus.UNDECIDED) { + this.attendanceStatus = AttendanceStatus.ABSENT; + } + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java new file mode 100644 index 00000000..20a64c97 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java @@ -0,0 +1,108 @@ +package kr.spot.schedule.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.BaseEntity; +import kr.spot.exception.GeneralException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE schedule SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Schedule extends BaseEntity { + + @Id + private Long id; + + private Long studyId; + + private Long creatorId; + + @Column(nullable = false) + private String title; + + private String locationMemo; + + private LocalDateTime startAt; + + private LocalDateTime endAt; + + private boolean attendanceActive; + + private String attendanceQrCodeImageUrl; + + public static Schedule of(Long id, Long studyId, long creatorId, String title, String locationMemo, + LocalDateTime startAt, LocalDateTime endAt + ) { + return new Schedule(id, studyId, creatorId, title, locationMemo, startAt, endAt, false, null); + } + + public void delete(long studyId) { + validateIsValidAccess(studyId); + super.delete(); + } + + public boolean isOngoing(LocalDateTime now) { + if (startAt == null || endAt == null) { + return false; + } + return !now.isBefore(startAt) && !now.isAfter(endAt); + } + + public void startAttendance(long studyId) { + validateIsValidAccess(studyId); + validateIsOngoing(); + validateIsNotAttendanceActive(); + this.attendanceActive = true; + } + + public void stopAttendance(long studyId) { + validateIsValidAccess(studyId); + this.attendanceActive = false; + this.attendanceQrCodeImageUrl = null; + } + + public void updateQrCodeImageUrl(String url) { + this.attendanceQrCodeImageUrl = url; + } + + public void validateAttendanceCheckable() { + validateIsAttendanceActive(); + validateIsOngoing(); + } + + private void validateIsValidAccess(long studyId) { + if (studyId != this.studyId) { + throw new GeneralException(ErrorStatus._SCHEDULE_ACCESS_DENIED); + } + } + + private void validateIsOngoing() { + if (!isOngoing(LocalDateTime.now())) { + throw new GeneralException(ErrorStatus._ATTENDANCE_NOT_IN_SCHEDULE_TIME); + } + } + + private void validateIsAttendanceActive() { + if (!this.attendanceActive) { + throw new GeneralException(ErrorStatus._ATTENDANCE_NOT_STARTED); + } + } + + private void validateIsNotAttendanceActive() { + if (this.attendanceActive) { + throw new GeneralException(ErrorStatus._SCHEDULE_QR_CODE_ALREADY_ASSIGNED); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java b/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java new file mode 100644 index 00000000..b17fd2cd --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/domain/enums/AttendanceStatus.java @@ -0,0 +1,7 @@ +package kr.spot.schedule.domain.enums; + +public enum AttendanceStatus { + PRESENT, // 출석 + ABSENT, // 결석 + UNDECIDED // 미결 +} diff --git a/modules/notification/src/main/java/kr/spot/domain/vo/Content.java b/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java similarity index 50% rename from modules/notification/src/main/java/kr/spot/domain/vo/Content.java rename to modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java index 6db73050..128adf2b 100644 --- a/modules/notification/src/main/java/kr/spot/domain/vo/Content.java +++ b/modules/study/src/main/java/kr/spot/schedule/domain/vo/MemberInfo.java @@ -1,4 +1,4 @@ -package kr.spot.domain.vo; +package kr.spot.schedule.domain.vo; import jakarta.persistence.Embeddable; import lombok.AccessLevel; @@ -12,15 +12,15 @@ @EqualsAndHashCode @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Content { +public class MemberInfo { - private String title; + private Long memberId; - private String content; + private String memberName; - private String imageUrl; + private String memberProfileImageUrl; - public static Content of(String title, String content, String imageUrl) { - return new Content(title, content, imageUrl); + public static MemberInfo of(Long memberId, String memberName, String memberProfileImageUrl) { + return new MemberInfo(memberId, memberName, memberProfileImageUrl); } -} +} \ No newline at end of file diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java new file mode 100644 index 00000000..ed387a7d --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/crypto/AttendanceTokenEncryptor.java @@ -0,0 +1,73 @@ +package kr.spot.schedule.infrastructure.crypto; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AttendanceTokenEncryptor { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; + private static final int GCM_IV_LENGTH = 12; + + private final SecretKeySpec secretKey; + + public AttendanceTokenEncryptor( + @Value("${app.attendance.encryption-key}") String encryptionKey) { + byte[] keyBytes = encryptionKey.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length != 16 && keyBytes.length != 24 && keyBytes.length != 32) { + throw new IllegalArgumentException("AES key must be 16, 24, or 32 bytes"); + } + this.secretKey = new SecretKeySpec(keyBytes, "AES"); + } + + public String encrypt(String plainText) { + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + byte[] iv = generateIv(); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec); + + byte[] encryptedBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + encryptedBytes.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(encryptedBytes, 0, combined, iv.length, encryptedBytes.length); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(combined); + } catch (Exception e) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } + + public String decrypt(String encryptedText) { + try { + byte[] combined = Base64.getUrlDecoder().decode(encryptedText); + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] encryptedBytes = new byte[combined.length - GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH); + System.arraycopy(combined, GCM_IV_LENGTH, encryptedBytes, 0, encryptedBytes.length); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec); + + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + return new String(decryptedBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new GeneralException(ErrorStatus._INVALID_ATTENDANCE_TOKEN); + } + } + + private byte[] generateIv() { + byte[] iv = new byte[GCM_IV_LENGTH]; + new java.security.SecureRandom().nextBytes(iv); + return iv; + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java new file mode 100644 index 00000000..20fd8151 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/AttendanceRepository.java @@ -0,0 +1,17 @@ +package kr.spot.schedule.infrastructure.jpa; + +import java.util.List; +import java.util.Optional; +import kr.spot.schedule.domain.Attendance; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendanceRepository extends JpaRepository { + + Optional findByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); + + boolean existsByScheduleIdAndMemberInfoMemberId(Long scheduleId, Long memberId); + + List findAllByScheduleId(Long scheduleId); + + void deleteByMemberInfoMemberId(long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/ScheduleRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/ScheduleRepository.java new file mode 100644 index 00000000..896506a1 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/ScheduleRepository.java @@ -0,0 +1,18 @@ +package kr.spot.schedule.infrastructure.jpa; + +import java.time.LocalDateTime; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.schedule.domain.Schedule; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScheduleRepository extends JpaRepository { + + boolean existsByStudyIdAndStartAtLessThanAndEndAtGreaterThan( + Long studyId, LocalDateTime endAt, LocalDateTime startAt); + + default Schedule getById(long scheduleId) { + return findById(scheduleId).orElseThrow( + () -> new GeneralException(ErrorStatus._SCHEDULE_NOT_FOUND)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/querydsl/ScheduleQueryRepository.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/querydsl/ScheduleQueryRepository.java new file mode 100644 index 00000000..e1904edd --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/querydsl/ScheduleQueryRepository.java @@ -0,0 +1,73 @@ +package kr.spot.schedule.infrastructure.jpa.querydsl; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.List; +import kr.spot.schedule.domain.QSchedule; +import kr.spot.schedule.domain.Schedule; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ScheduleQueryRepository { + + private final JPAQueryFactory query; + + public List findMonthlySchedules(Long studyId, LocalDate date) { + QSchedule schedule = QSchedule.schedule; + + YearMonth yearMonth = YearMonth.from(date); + LocalDateTime startOfMonth = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime endOfMonth = yearMonth.atEndOfMonth().atTime(LocalTime.MAX); + + return query + .selectFrom(schedule) + .where( + schedule.studyId.eq(studyId), + schedule.startAt.between(startOfMonth, endOfMonth) + .or(schedule.endAt.between(startOfMonth, endOfMonth)) + ) + .orderBy(schedule.startAt.asc()) + .fetch(); + } + + public List findWeeklySchedules(Long studyId, LocalDate date) { + QSchedule schedule = QSchedule.schedule; + + LocalDate monday = date.with(DayOfWeek.MONDAY); + LocalDate sunday = date.with(DayOfWeek.SUNDAY); + LocalDateTime startOfWeek = monday.atStartOfDay(); + LocalDateTime endOfWeek = sunday.atTime(LocalTime.MAX); + + return query + .selectFrom(schedule) + .where( + schedule.studyId.eq(studyId), + schedule.startAt.between(startOfWeek, endOfWeek) + .or(schedule.endAt.between(startOfWeek, endOfWeek)) + ) + .orderBy(schedule.startAt.asc()) + .fetch(); + } + + public List findUpcomingSchedules(Long studyId, int limit) { + QSchedule schedule = QSchedule.schedule; + + LocalDateTime now = LocalDateTime.now(); + + return query + .selectFrom(schedule) + .where( + schedule.studyId.eq(studyId), + schedule.endAt.goe(now) + ) + .orderBy(schedule.startAt.asc()) + .limit(limit) + .fetch(); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java new file mode 100644 index 00000000..49eba323 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/ByteArrayMultipartFile.java @@ -0,0 +1,62 @@ +package kr.spot.schedule.infrastructure.qr; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import org.springframework.web.multipart.MultipartFile; + +public class ByteArrayMultipartFile implements MultipartFile { + + private final byte[] content; + private final String name; + private final String originalFilename; + private final String contentType; + + public ByteArrayMultipartFile(byte[] content, String filename, String contentType) { + this.content = content; + this.name = filename; + this.originalFilename = filename; + this.contentType = contentType; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content == null || content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() throws IOException { + return content; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + throw new UnsupportedOperationException("transferTo is not supported"); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java new file mode 100644 index 00000000..7806b789 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/qr/QrCodeGenerator.java @@ -0,0 +1,46 @@ +package kr.spot.schedule.infrastructure.qr; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Map; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import org.springframework.stereotype.Component; + +@Component +public class QrCodeGenerator { + + private static final int DEFAULT_WIDTH = 300; + private static final int DEFAULT_HEIGHT = 300; + private static final String IMAGE_FORMAT = "PNG"; + + public byte[] generate(String content) { + return generate(content, DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + public byte[] generate(String content, int width, int height) { + try { + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + Map hints = Map.of( + EncodeHintType.CHARACTER_SET, "UTF-8", + EncodeHintType.MARGIN, 1 + ); + + BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, width, height, + hints); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + MatrixToImageWriter.writeToStream(bitMatrix, IMAGE_FORMAT, outputStream); + + return outputStream.toByteArray(); + } catch (WriterException | IOException e) { + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java new file mode 100644 index 00000000..7cd8fedf --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/AttendanceCommandController.java @@ -0,0 +1,165 @@ +package kr.spot.schedule.presentation.command; + +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.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.command.AttendanceCommandService; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 일정 - 출석체크", description = "스터디 일정 출석체크 관련 API") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") +@RequiredArgsConstructor +public class AttendanceCommandController { + + private final AttendanceCommandService attendanceCommandService; + + @Operation( + summary = "출석체크 시작", + description = """ + 스터디장이 출석체크를 시작합니다. + + **동작:** + - 모든 스터디 멤버(OWNER, APPROVED)에 대해 출석 기록 생성 + - 스터디장(OWNER)은 자동으로 출석(PRESENT) 처리 + - 일반 멤버는 미정(UNDECIDED) 상태로 생성 + - QR 코드 이미지는 비동기로 생성되며, `/qr` 엔드포인트에서 조회 가능 + + **권한:** 스터디장만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석체크 시작 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "일정 시간이 아님 (ATTENDANCE4000) / 이미 출석체크 진행 중 (SCHEDULE4000)", + content = @Content(examples = { + @ExampleObject(name = "일정 시간 외", value = """ + {"isSuccess": false, "code": "ATTENDANCE4000", "message": "일정 시간 외에는 출석체크를 진행할 수 없습니다."} + """), + @ExampleObject(name = "이미 진행 중", value = """ + {"isSuccess": false, "code": "SCHEDULE4000", "message": "이미 출석 QR 코드가 할당된 일정입니다."} + """) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디장이 아님 (STUDY403)", + content = @Content(examples = @ExampleObject(value = """ + {"isSuccess": false, "code": "STUDY403", "message": "스터디장만 접근 가능합니다."} + """)) + ) + }) + @PostMapping + public ResponseEntity> startAttendanceCheck( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.startAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation( + summary = "출석체크 종료", + description = """ + 스터디장이 출석체크를 종료합니다. + + **동작:** + - 출석체크 비활성화 + - QR 코드 이미지 URL 삭제 + + **권한:** 스터디장만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석체크 종료 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디장이 아님 (STUDY403)" + ) + }) + @DeleteMapping + public ResponseEntity> stopAttendanceCheck( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.stopAttendance(studyId, scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation( + summary = "출석체크 처리 (QR 스캔)", + description = """ + QR 코드를 스캔하여 출석체크를 처리합니다. + + **동작:** + - 암호화된 토큰을 복호화하여 studyId, scheduleId 추출 + - 해당 멤버의 출석 상태를 PRESENT로 변경 + + **QR 코드 토큰:** + - QR 코드에는 암호화된 토큰 문자열만 담겨 있음 + - 앱에서 QR 스캔 후 해당 토큰을 이 API의 token 파라미터로 전달 + + **권한:** 스터디 멤버만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석 처리 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "유효하지 않은 토큰 (ATTENDANCE4003) / 출석체크 미시작 (ATTENDANCE4001) / 일정 시간 외 (ATTENDANCE4000)", + content = @Content(examples = { + @ExampleObject(name = "유효하지 않은 토큰", value = """ + {"isSuccess": false, "code": "ATTENDANCE4003", "message": "유효하지 않은 출석 토큰입니다."} + """), + @ExampleObject(name = "출석체크 미시작", value = """ + {"isSuccess": false, "code": "ATTENDANCE4001", "message": "출석체크가 시작되지 않았습니다."} + """) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디 멤버가 아님 (STUDY4032)" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "출석 기록 없음 (STUDYMEMBER404)" + ) + }) + @PostMapping("/check") + public ResponseEntity> checkAttendance( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @Parameter( + description = "QR 코드에서 읽은 암호화된 출석 토큰", + required = true, + example = "dK8x2mP9qL3nR7vT..." + ) @RequestParam String token, + @CurrentMember @Parameter(hidden = true) Long memberId) { + attendanceCommandService.checkAttendance(token, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java new file mode 100644 index 00000000..0102ea1b --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java @@ -0,0 +1,63 @@ +package kr.spot.schedule.presentation.command; + + +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.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.command.ManageScheduleService; +import kr.spot.schedule.presentation.command.dto.CreateScheduleRequest; +import kr.spot.schedule.presentation.command.dto.CreateScheduleResponse; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 일정") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules") +@RequiredArgsConstructor +public class ScheduleCommandController { + + private final ManageScheduleService manageScheduleService; + + @Operation(summary = "스터디 일정 생성", description = "스터디에 새로운 일정을 생성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "일정 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`STUDY404`: 스터디를 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @PostMapping + public ResponseEntity> createSchedule( + @CurrentMember @Parameter(hidden = true) Long memberId, + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @RequestBody CreateScheduleRequest request) { + long scheduleId = manageScheduleService.createSchedule(request, studyId, memberId); + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._CREATED, CreateScheduleResponse.from(scheduleId))); + } + + @Operation(summary = "스터디 일정 삭제", description = "스터디의 일정을 삭제합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "일정 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "`SCHEDULE403`: 해당 일정에 대한 접근 권한이 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`SCHEDULE404`: 일정을 찾을 수 없습니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @DeleteMapping("/{scheduleId}") + public ResponseEntity> deleteSchedule( + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true) @PathVariable Long scheduleId) { + manageScheduleService.deleteSchedule(studyId, scheduleId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java new file mode 100644 index 00000000..b95b76ea --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java @@ -0,0 +1,21 @@ +package kr.spot.schedule.presentation.command.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +@Schema(description = "스터디 일정 생성 요청") +public record CreateScheduleRequest( + @Schema(description = "일정 제목", example = "주간 스터디 모임", requiredMode = Schema.RequiredMode.REQUIRED) + String title, + + @Schema(description = "장소 정보", example = "강남역 스터디카페 3층") + String locationInfo, + + @Schema(description = "시작 일시", example = "2025-01-15T14:00:00") + LocalDateTime startAt, + + @Schema(description = "종료 일시", example = "2025-01-15T16:00:00") + LocalDateTime endAt +) { + +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleResponse.java new file mode 100644 index 00000000..4ed403cc --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleResponse.java @@ -0,0 +1,8 @@ +package kr.spot.schedule.presentation.command.dto; + +public record CreateScheduleResponse(Long scheduleId) { + + public static CreateScheduleResponse from(long scheduleId) { + return new CreateScheduleResponse(scheduleId); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java new file mode 100644 index 00000000..cfe6bf63 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/AttendanceQueryController.java @@ -0,0 +1,167 @@ +package kr.spot.schedule.presentation.query; + +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.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.query.GetAttendanceService; +import kr.spot.schedule.presentation.query.dto.GetAttendanceInfoResponse; +import kr.spot.schedule.presentation.query.dto.GetAttendanceListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 일정 - 출석체크", description = "스터디 일정 출석체크 관련 API") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules/{scheduleId}/attendance") +@RequiredArgsConstructor +public class AttendanceQueryController { + + private final GetAttendanceService getAttendanceService; + + @Operation( + summary = "출석 목록 조회", + description = """ + 해당 일정의 출석 목록을 조회합니다. + + **출석 상태 (attendanceStatus):** + - `PRESENT`: 출석 + - `ABSENT`: 결석 + - `UNDECIDED`: 미정 (아직 출석 안 함) + + **권한:** 스터디 멤버만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "출석 목록 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = GetAttendanceListResponse.class), + examples = @ExampleObject(value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendances": [ + { + "member": { + "memberId": 1, + "memberName": "홍길동", + "memberProfileImageUrl": "https://example.com/profile1.jpg" + }, + "attendanceStatus": "PRESENT", + "attendedAt": "2024-01-15T14:30:00" + }, + { + "member": { + "memberId": 2, + "memberName": "김철수", + "memberProfileImageUrl": "https://example.com/profile2.jpg" + }, + "attendanceStatus": "UNDECIDED", + "attendedAt": null + } + ], + "totalCount": 2 + } + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디 멤버가 아님 (STUDY4032)" + ) + }) + @GetMapping + public ResponseEntity> getAttendanceList( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + GetAttendanceListResponse response = getAttendanceService.getAttendanceList(studyId, + scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @Operation( + summary = "출석체크 QR 코드 조회", + description = """ + 출석체크용 QR 코드 이미지 URL을 조회합니다. + + **응답 필드:** + - `attendanceActive`: 출석체크 진행 중 여부 + - `qrCodeImageUrl`: QR 코드 이미지 URL (출석체크 미시작 시 null) + + **QR 코드 내용:** + - QR 코드에는 암호화된 토큰 문자열만 담겨 있음 + - 멤버가 QR 스캔 후 토큰을 `/check` API에 전달하여 출석 처리 + + **권한:** 스터디 멤버만 가능 + """ + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "QR 코드 조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = GetAttendanceInfoResponse.class), + examples = { + @ExampleObject(name = "출석체크 진행 중", value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendanceActive": true, + "qrCodeImageUrl": "https://s3.amazonaws.com/.../qr-code.png" + } + } + """), + @ExampleObject(name = "출석체크 미시작", value = """ + { + "isSuccess": true, + "code": "200", + "message": "OK", + "result": { + "attendanceActive": false, + "qrCodeImageUrl": null + } + } + """) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "스터디 멤버가 아님 (STUDY4032)" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "일정을 찾을 수 없음 (SCHEDULE404)" + ) + }) + @GetMapping("/qr") + public ResponseEntity> getAttendanceQrCode( + @Parameter(description = "스터디 ID", required = true, example = "1") @PathVariable Long studyId, + @Parameter(description = "일정 ID", required = true, example = "100") @PathVariable Long scheduleId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + GetAttendanceInfoResponse response = getAttendanceService.getAttendanceQrCode(studyId, + scheduleId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java new file mode 100644 index 00000000..bfe2b351 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java @@ -0,0 +1,75 @@ +package kr.spot.schedule.presentation.query; + +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.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.schedule.application.query.GetScheduleService; +import kr.spot.schedule.presentation.query.dto.GetScheduleListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "스터디 일정") +@RestController +@RequestMapping("/api/studies/{studyId}/schedules") +@RequiredArgsConstructor +public class ScheduleQueryController { + + private final GetScheduleService getScheduleService; + + @Operation(summary = "월별 일정 조회", description = "해당 연/월의 모든 일정을 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @GetMapping("/monthly") + public ResponseEntity> getMonthlySchedules( + @CurrentMember @Parameter(hidden = true) Long memberId, + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "연도", example = "2025") @RequestParam Integer year, + @Parameter(description = "월", example = "1") @RequestParam Integer month) { + GetScheduleListResponse response = getScheduleService.getMonthlySchedules(studyId, year, month, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @Operation(summary = "주간 일정 조회", description = "해당 날짜가 속한 주(월~일)의 모든 일정을 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @GetMapping("/weekly") + public ResponseEntity> getWeeklySchedules( + @CurrentMember @Parameter(hidden = true) Long memberId, + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId, + @Parameter(description = "조회 기준 날짜 (해당 주 전체 조회), ISO 8601 표준 방식으로 입력해주세요. ", example = "2025-01-15") + @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate date) { + GetScheduleListResponse response = getScheduleService.getWeeklySchedules(studyId, date, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } + + @Operation(summary = "다가오는 일정 조회", description = "현재 시점 이후의 가장 가까운 일정 2개를 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + @GetMapping("/upcoming") + public ResponseEntity> getUpcomingSchedules( + @CurrentMember @Parameter(hidden = true) Long memberId, + @Parameter(description = "스터디 ID", required = true) @PathVariable Long studyId) { + GetScheduleListResponse response = getScheduleService.getUpcomingSchedules(studyId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java new file mode 100644 index 00000000..d4cb9c76 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceInfoResponse.java @@ -0,0 +1,11 @@ +package kr.spot.schedule.presentation.query.dto; + +public record GetAttendanceInfoResponse( + boolean attendanceActive, + String qrCodeImageUrl +) { + + public static GetAttendanceInfoResponse of(boolean attendanceActive, String qrCodeImageUrl) { + return new GetAttendanceInfoResponse(attendanceActive, qrCodeImageUrl); + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java new file mode 100644 index 00000000..262e03ca --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetAttendanceListResponse.java @@ -0,0 +1,39 @@ +package kr.spot.schedule.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record GetAttendanceListResponse( + List attendances, + int totalCount +) { + + public static GetAttendanceListResponse from(List attendances, + int totalCount) { + return new GetAttendanceListResponse(attendances, totalCount); + } + + public record AttendanceInfoResponse( + MemberInfoResponse member, + String attendanceStatus, + LocalDateTime attendedAt + ) { + + public static AttendanceInfoResponse from(MemberInfoResponse member, String attendanceStatus, + LocalDateTime attendedAt) { + return new AttendanceInfoResponse(member, attendanceStatus, attendedAt); + } + } + + public record MemberInfoResponse( + Long memberId, + String memberName, + String memberProfileImageUrl + ) { + + public static MemberInfoResponse from(Long memberId, String memberName, + String memberProfileImageUrl) { + return new MemberInfoResponse(memberId, memberName, memberProfileImageUrl); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetScheduleListResponse.java b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetScheduleListResponse.java new file mode 100644 index 00000000..680a6544 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetScheduleListResponse.java @@ -0,0 +1,31 @@ +package kr.spot.schedule.presentation.query.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record GetScheduleListResponse( + List schedules, + long totalCount +) { + + public static GetScheduleListResponse from(List schedules) { + return new GetScheduleListResponse(schedules, schedules.size()); + } + + public record ScheduleResponse( + Long scheduleId, + String title, + LocalDateTime startAt, + LocalDateTime endAt, + boolean isNow, + boolean isMine + ) { + + public static ScheduleResponse from(Long scheduleId, String title, + LocalDateTime startAt, LocalDateTime endAt, boolean isNow, boolean isMine) { + return new ScheduleResponse(scheduleId, title, startAt, endAt, isNow, isMine); + } + + } + +} diff --git a/modules/study/src/main/java/kr/spot/application/command/ApplyStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java similarity index 70% rename from modules/study/src/main/java/kr/spot/application/command/ApplyStudyService.java rename to modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java index bfa6f100..aaa5fbde 100644 --- a/modules/study/src/main/java/kr/spot/application/command/ApplyStudyService.java +++ b/modules/study/src/main/java/kr/spot/study/application/command/ApplyStudyService.java @@ -1,17 +1,19 @@ -package kr.spot.application.command; +package kr.spot.study.application.command; import java.util.List; import kr.spot.IdGenerator; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.Study; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.Decision; -import kr.spot.domain.enums.StudyMemberStatus; +import kr.spot.event.StudyApplicationProcessedEvent; import kr.spot.exception.GeneralException; -import kr.spot.infrastructure.jpa.StudyRepository; -import kr.spot.infrastructure.jpa.associations.StudyMemberRepository; -import kr.spot.presentation.command.dto.request.ApplyStudyRequest; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,6 +29,7 @@ public class ApplyStudyService { ); private final IdGenerator idGenerator; + private final ApplicationEventPublisher eventPublisher; private final StudyRepository studyRepository; private final StudyMemberRepository studyMemberRepository; @@ -39,8 +42,20 @@ public void processStudyApplication( Study study = studyRepository.getStudyById(application.getStudyId()); study.processApplication(application, requesterId, decision); + + eventPublisher.publishEvent( + StudyApplicationProcessedEvent.of( + study.getId(), + application.getMemberId(), + requesterId, + decision.name(), + study.getName(), + study.getImageUrl() + ) + ); } + @Deprecated(forRemoval = true) public void decideFinalParticipation( Long applicationId, Long requesterId, diff --git a/modules/study/src/main/java/kr/spot/application/command/CreateStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java similarity index 65% rename from modules/study/src/main/java/kr/spot/application/command/CreateStudyService.java rename to modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java index 423039f1..7d52ace2 100644 --- a/modules/study/src/main/java/kr/spot/application/command/CreateStudyService.java +++ b/modules/study/src/main/java/kr/spot/study/application/command/CreateStudyService.java @@ -1,21 +1,19 @@ -package kr.spot.application.command; +package kr.spot.study.application.command; import kr.spot.IdGenerator; -import kr.spot.domain.Study; -import kr.spot.domain.associations.StudyCategory; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.associations.StudyRegion; -import kr.spot.domain.associations.StudyStats; -import kr.spot.domain.associations.StudyStyle; -import kr.spot.domain.vo.Fee; -import kr.spot.infrastructure.jpa.StudyRepository; -import kr.spot.infrastructure.jpa.associations.StudyCategoryRepository; -import kr.spot.infrastructure.jpa.associations.StudyMemberRepository; -import kr.spot.infrastructure.jpa.associations.StudyRegionRepository; -import kr.spot.infrastructure.jpa.associations.StudyStatsRepository; -import kr.spot.infrastructure.jpa.associations.StudyStyleRepository; -import kr.spot.application.event.StudyCreatedEvent; -import kr.spot.presentation.command.dto.request.CreateStudyRequest; +import kr.spot.study.application.event.StudyCreatedEvent; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyCategory; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.associations.StudyRegion; +import kr.spot.study.domain.associations.StudyStyle; +import kr.spot.study.domain.vo.Fee; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyCategoryRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyRegionRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyStyleRepository; +import kr.spot.study.presentation.command.dto.request.CreateStudyRequest; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -34,18 +32,15 @@ public class CreateStudyService { private final StudyStyleRepository studyStyleRepository; private final StudyRegionRepository studyRegionRepository; private final StudyCategoryRepository studyCategoryRepository; - private final StudyStatsRepository studyStatsRepository; private final StudyMemberRepository studyMemberRepository; - public void createStudy(CreateStudyRequest request, Long leaderId, MultipartFile imageFile) { + public long createStudy(CreateStudyRequest request, long leaderId, MultipartFile imageFile) { long studyId = idGenerator.nextId(); Study study = Study.of(studyId, leaderId, request.name(), request.maxMembers(), - Fee.of(request.hasFee(), request.amount()), request.description()); - StudyStats studyStats = StudyStats.of(studyId); + Fee.of(request.hasFee(), request.amount()), null, request.description(), request.isOnline()); StudyMember studyMember = StudyMember.create(idGenerator.nextId(), studyId, leaderId); studyRepository.save(study); - studyStatsRepository.save(studyStats); studyMemberRepository.save(studyMember); saveAllStudyCategories(request, studyId); @@ -53,6 +48,7 @@ public void createStudy(CreateStudyRequest request, Long leaderId, MultipartFile saveAllStudyRegions(request, studyId); eventPublisher.publishEvent(StudyCreatedEvent.of(studyId, imageFile)); + return studyId; } private void saveAllStudyCategories(CreateStudyRequest request, long studyId) { diff --git a/modules/study/src/main/java/kr/spot/study/application/command/ReportStudyMemberService.java b/modules/study/src/main/java/kr/spot/study/application/command/ReportStudyMemberService.java new file mode 100644 index 00000000..8bd6f67c --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/command/ReportStudyMemberService.java @@ -0,0 +1,29 @@ +package kr.spot.study.application.command; + +import jakarta.transaction.Transactional; +import kr.spot.IdGenerator; +import kr.spot.study.domain.associations.StudyMemberReport; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberReportRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.presentation.command.dto.request.RepostStudyMemberRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class ReportStudyMemberService { + + private final IdGenerator idGenerator; + private final StudyMemberRepository studyMemberRepository; + private final StudyMemberReportRepository studyMemberReportRepository; + + public void reportStudyMember(Long studyId, Long memberId, RepostStudyMemberRequest request) { + var studyMember = studyMemberRepository.getByMemberIdAndStudyId(memberId, studyId); + + StudyMemberReport studyMemberReport = StudyMemberReport.create(idGenerator.nextId(), + studyMember.getId(), request.reason()); + + studyMemberReportRepository.save(studyMemberReport); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/command/StudyLikeService.java b/modules/study/src/main/java/kr/spot/study/application/command/StudyLikeService.java new file mode 100644 index 00000000..68f607e9 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/command/StudyLikeService.java @@ -0,0 +1,36 @@ +package kr.spot.study.application.command; + +import kr.spot.IdGenerator; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyLikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class StudyLikeService { + + private final IdGenerator idGenerator; + private final StudyLikeRepository studyLikeRepository; + private final StudyRepository studyRepository; + + public void likeStudy(long studyId, long memberId) { + int inserted = studyLikeRepository.saveStudyLike(idGenerator.nextId(), studyId, memberId); + increaseLikeCount(studyId, inserted); + } + + private void increaseLikeCount(long studyId, int inserted) { + if (inserted == 1) { + studyRepository.increaseLike(studyId); + } + } + + public void unlikeStudy(long studyId, long memberId) { + int deleted = studyLikeRepository.hardDelete(studyId, memberId); + if (deleted > 0) { + studyRepository.decreaseLike(studyId); + } + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/command/WithdrawStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/WithdrawStudyService.java new file mode 100644 index 00000000..fbe67112 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/command/WithdrawStudyService.java @@ -0,0 +1,52 @@ +package kr.spot.study.application.command; + +import jakarta.transaction.Transactional; +import java.util.List; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.presentation.command.dto.request.WithdrawStudyRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@Transactional +@RequiredArgsConstructor +public class WithdrawStudyService { + + private final StudyRepository studyRepository; + private final StudyMemberRepository studyMemberRepository; + + public void withdrawStudy(long studyId, long memberId, WithdrawStudyRequest request) { + StudyMember studyMember = studyMemberRepository.getByMemberIdAndStudyIdAndStudyMemberStatusIn( + memberId, + studyId, + List.of(StudyMemberStatus.APPROVED, StudyMemberStatus.OWNER)); + Study study = studyRepository.getStudyById(studyId); + + StudyMember nextOwner = findNextOwner(studyId, request.nextOwnerId()); + + studyMember.withdrawStudy(request.withdrawReason(), nextOwner); + study.decreaseMemberCount(); + } + + public void deleteStudy(long studyId, long memberId) { + StudyMember studyMember = studyMemberRepository.getByMemberIdAndStudyId(memberId, studyId); + Study study = studyRepository.getStudyById(studyId); + + study.finishStudy(studyMember); + } + + private StudyMember findNextOwner(long studyId, Long nextOwnerId) { + if (nextOwnerId == null) { + return null; + } + + return studyMemberRepository.findByMemberIdAndStudyId(nextOwnerId, studyId) + .orElseThrow(() -> new GeneralException(ErrorStatus._NEXT_OWNER_NOT_EXIST_IN_STUDY)); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawn.java b/modules/study/src/main/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawn.java new file mode 100644 index 00000000..9fb887a1 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/event/CleanupStudyMembershipOnMemberWithdrawn.java @@ -0,0 +1,26 @@ +package kr.spot.study.application.event; + +import kr.spot.domain.events.MemberWithdrawnEvent; +import kr.spot.study.infrastructure.jpa.associations.StudyLikeRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Order(1) +@Component +@RequiredArgsConstructor +public class CleanupStudyMembershipOnMemberWithdrawn { + + private final StudyMemberRepository studyMemberRepository; + private final StudyLikeRepository studyLikeRepository; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(MemberWithdrawnEvent event) { + long memberId = event.memberId(); + studyMemberRepository.deleteByMemberId(memberId); + studyLikeRepository.deleteAllByMemberId(memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/application/event/StudyCreatedEvent.java b/modules/study/src/main/java/kr/spot/study/application/event/StudyCreatedEvent.java similarity index 90% rename from modules/study/src/main/java/kr/spot/application/event/StudyCreatedEvent.java rename to modules/study/src/main/java/kr/spot/study/application/event/StudyCreatedEvent.java index 162ea21b..61000796 100644 --- a/modules/study/src/main/java/kr/spot/application/event/StudyCreatedEvent.java +++ b/modules/study/src/main/java/kr/spot/study/application/event/StudyCreatedEvent.java @@ -1,4 +1,4 @@ -package kr.spot.application.event; +package kr.spot.study.application.event; import org.springframework.web.multipart.MultipartFile; diff --git a/modules/study/src/main/java/kr/spot/application/event/StudyImageUploadListener.java b/modules/study/src/main/java/kr/spot/study/application/event/StudyImageUploadListener.java similarity index 91% rename from modules/study/src/main/java/kr/spot/application/event/StudyImageUploadListener.java rename to modules/study/src/main/java/kr/spot/study/application/event/StudyImageUploadListener.java index 9680fd0c..22c9de29 100644 --- a/modules/study/src/main/java/kr/spot/application/event/StudyImageUploadListener.java +++ b/modules/study/src/main/java/kr/spot/study/application/event/StudyImageUploadListener.java @@ -1,9 +1,9 @@ -package kr.spot.application.event; +package kr.spot.study.application.event; -import kr.spot.domain.Study; -import kr.spot.infrastructure.jpa.StudyRepository; import kr.spot.ports.FileStoragePort; import kr.spot.ports.dto.UploadResult; +import kr.spot.study.domain.Study; +import kr.spot.study.infrastructure.jpa.StudyRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; diff --git a/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java new file mode 100644 index 00000000..e46df652 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java @@ -0,0 +1,43 @@ +package kr.spot.study.application.mapper; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import kr.spot.study.domain.Study; +import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; +import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse.StudyOverview; + +public class StudyDTOMapper { + + public static GetStudyOverviewResponse toDTO( + List studies, + Set likedStudyIds, + Set ownedStudyIds, + Set aloneStudyIds, + boolean hasNext, + Long nextCursor, + Long totalElements + ) { + Set safeLikedIds = likedStudyIds != null ? likedStudyIds : Collections.emptySet(); + Set safeOwnedStudyIds = ownedStudyIds != null ? ownedStudyIds : Collections.emptySet(); + Set safeAloneStudyIds = aloneStudyIds != null ? aloneStudyIds : Collections.emptySet(); + + List list = studies.stream().map( + study -> StudyOverview.of( + study.getId(), + study.getName(), + study.getDescription(), + study.getMaxMembers(), + study.getCurrentMembers(), + 0, + safeLikedIds.contains(study.getId()), + safeOwnedStudyIds.contains(study.getId()), + safeAloneStudyIds.contains(study.getId()), + 0, + study.getImageUrl() + ) + ).toList(); + + return GetStudyOverviewResponse.of(list, hasNext, nextCursor, totalElements); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyMemberIdsService.java b/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyMemberIdsService.java new file mode 100644 index 00000000..ca8a5efc --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyMemberIdsService.java @@ -0,0 +1,32 @@ +package kr.spot.study.application.ports; + +import java.util.List; +import kr.spot.ports.GetStudyMemberIdsPort; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetStudyMemberIdsService implements GetStudyMemberIdsPort { + + private static final List ACTIVE_STATUSES = List.of( + StudyMemberStatus.OWNER, + StudyMemberStatus.APPROVED + ); + + private final StudyMemberRepository studyMemberRepository; + + @Override + public List getMemberIdsByStudyId(long studyId) { + return studyMemberRepository + .findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_STATUSES) + .stream() + .map(StudyMember::getMemberId) + .toList(); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyParticipationInfoService.java b/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyParticipationInfoService.java new file mode 100644 index 00000000..2d7fc13e --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyParticipationInfoService.java @@ -0,0 +1,45 @@ +package kr.spot.study.application.ports; + +import java.util.List; +import kr.spot.ports.StudyParticipationInfoPort; +import kr.spot.ports.dto.StudyParticipationInfo; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetStudyParticipationInfoService implements StudyParticipationInfoPort { + + private final StudyMemberRepository studyMemberRepository; + private final StudyRepository studyRepository; + + @Override + public StudyParticipationInfo getStudyParticipationInfo(long memberId) { + long participatingStudyCount = studyMemberRepository.countByMemberIdAndStudyMemberStatusIn( + memberId, + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) + ); + + long recruitingStudyCount = studyRepository.countByLeaderIdAndRecruitingStatus( + memberId, + RecruitingStatus.RECRUITING + ); + + long appliedStudyCount = studyMemberRepository.countByMemberIdAndStudyMemberStatus( + memberId, + StudyMemberStatus.APPLIED + ); + + return StudyParticipationInfo.from( + participatingStudyCount, + recruitingStudyCount, + appliedStudyCount + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/ports/HasActiveStudyAsLeaderService.java b/modules/study/src/main/java/kr/spot/study/application/ports/HasActiveStudyAsLeaderService.java new file mode 100644 index 00000000..132929fe --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/ports/HasActiveStudyAsLeaderService.java @@ -0,0 +1,20 @@ +package kr.spot.study.application.ports; + +import kr.spot.ports.HasActiveStudyAsLeaderPort; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class HasActiveStudyAsLeaderService implements HasActiveStudyAsLeaderPort { + + private final StudyRepository studyRepository; + + @Override + public boolean hasActiveStudyAsLeader(long memberId) { + return studyRepository.existsByLeaderId(memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java new file mode 100644 index 00000000..79bdc529 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java @@ -0,0 +1,278 @@ +package kr.spot.study.application.query; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import kr.spot.ports.GetPreferredCategoryPort; +import kr.spot.ports.GetPreferredRegionPort; +import kr.spot.study.application.mapper.StudyDTOMapper; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.FeeCategory; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.SortBy; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.associations.StudyLikeRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.infrastructure.jpa.querydsl.StudyQueryRepository; +import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetMyStudyInfoService { + + public static final int MAX_PAGE_SIZE = 50; + + private final GetPreferredRegionPort getPreferredRegionPort; + private final GetPreferredCategoryPort getPreferredCategoryPort; + private final StudyQueryRepository studyQueryRepository; + private final StudyLikeRepository studyLikeRepository; + private final StudyMemberRepository studyMemberRepository; + + public GetStudyOverviewResponse getMyStudyOverview( + long viewerId, + List statuses, + Long cursor, + int size + ) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + List rows = studyQueryRepository.findMyStudies( + viewerId, + statuses, + cursor, + pageSize + 1 + ); + + long totalElements = studyQueryRepository.countMyStudies( + viewerId, + statuses + ); + return toCursorPage(rows, viewerId, pageSize, totalElements); + } + + public GetStudyOverviewResponse getMyPreferredRegionStudies( + long viewerId, + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + List categories, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int size, + List regionCodes + ) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + List preferredRegionCodes = getPreferredRegionPort.get(viewerId); + List filteredRegionCodes = filterPreferredRegionCodes(regionCodes, + preferredRegionCodes); + + List rows = studyQueryRepository.findMyPreferredRegionStudies( + recruitingStatus, + feeCategory, + categories, + isOnline, + sortBy, + cursor, + pageSize + 1, + filteredRegionCodes + ); + + long totalElements = studyQueryRepository.countMyPreferredRegionStudies( + recruitingStatus, + feeCategory, + categories, + isOnline, + filteredRegionCodes + ); + return toCursorPage(rows, viewerId, pageSize, totalElements); + } + + public GetStudyOverviewResponse getMyPreferredCategoryStudies( + long viewerId, + Category category, + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int size + ) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + List preferredCategories = getCategories(viewerId, category); + + List rows = studyQueryRepository.findMyPreferredCategoryStudies( + recruitingStatus, + feeCategory, + isOnline, + sortBy, + cursor, + pageSize + 1, + preferredCategories + ); + + long totalElements = studyQueryRepository.countMyPreferredCategoryStudies( + recruitingStatus, + feeCategory, + isOnline, + preferredCategories + ); + return toCursorPage(rows, viewerId, pageSize, totalElements); + } + + public GetStudyOverviewResponse getRecruitingStudies( + long viewerId, + FeeCategory feeCategory, + List categories, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int size + ) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + + List rows = studyQueryRepository.findRecruitingStudies( + feeCategory, + categories, + isOnline, + sortBy, + cursor, + pageSize + 1 + ); + + long totalElements = studyQueryRepository.countRecruitingStudies( + feeCategory, + categories, + isOnline + ); + return toCursorPage(rows, viewerId, pageSize, totalElements); + } + + public GetStudyOverviewResponse getStudiesByCategory( + long viewerId, + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Category category, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int size + ) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + + List rows = studyQueryRepository.findStudiesByCategory( + recruitingStatus, + feeCategory, + category, + isOnline, + sortBy, + cursor, + pageSize + 1 + ); + + long totalElements = studyQueryRepository.countStudiesByCategory( + recruitingStatus, + feeCategory, + category, + isOnline + ); + return toCursorPage(rows, viewerId, pageSize, totalElements); + } + + public GetStudyOverviewResponse getLikedStudies(long viewerId, Long cursor, int size) { + final int pageSize = Math.min(size, MAX_PAGE_SIZE); + List rows = studyQueryRepository.findLikedStudies(viewerId, cursor, pageSize + 1); + long totalElements = studyQueryRepository.countLikedStudies(viewerId); + return toCursorPage(rows, viewerId, pageSize, totalElements); + } + + public GetStudyOverviewResponse getRecommendedStudies(long viewerId) { + final int recommendCount = 3; + + List preferredCategories = getPreferredCategories(viewerId); + List result = pickRandomStudiesFromPreferred(preferredCategories, recommendCount); + fillWithPopularStudies(result, recommendCount); + + Set likedStudyIds = studyLikeRepository.findStudyIdsByMemberId(viewerId); + Set ownedStudyIds = studyMemberRepository.findStudyIdsByMemberIdAndStudyMemberStatus( + viewerId, StudyMemberStatus.OWNER); + Set aloneStudyIds = studyMemberRepository.findAloneOwnerStudyIds(viewerId); + return StudyDTOMapper.toDTO(result, likedStudyIds, ownedStudyIds, aloneStudyIds, false, null, + (long) result.size()); + } + + private List getPreferredCategories(long memberId) { + return getPreferredCategoryPort.get(memberId) + .stream() + .map(Category::fromString) + .toList(); + } + + private List pickRandomStudiesFromPreferred(List categories, int count) { + if (categories.isEmpty()) { + return new ArrayList<>(); + } + + final int fetchLimit = 20; + List candidates = studyQueryRepository.findRecruitingStudiesByCategories( + categories, fetchLimit); + + List shuffled = new ArrayList<>(candidates); + Collections.shuffle(shuffled); + + return new ArrayList<>(shuffled.stream().limit(count).toList()); + } + + private void fillWithPopularStudies(List result, int targetCount) { + if (result.size() >= targetCount) { + return; + } + + List excludeIds = result.stream().map(Study::getId).toList(); + int remaining = targetCount - result.size(); + + List popularStudies = studyQueryRepository.findPopularRecruitingStudies( + excludeIds, remaining); + result.addAll(popularStudies); + } + + private GetStudyOverviewResponse toCursorPage(List rows, long viewerId, int pageSize, + long totalElements) { + boolean hasNext = rows.size() > pageSize; + List pageContent = hasNext ? rows.subList(0, pageSize) : rows; + Long nextCursor = hasNext ? pageContent.getLast().getId() : null; + Set likedStudyIds = studyLikeRepository.findStudyIdsByMemberId(viewerId); + Set ownedStudyIds = studyMemberRepository.findStudyIdsByMemberIdAndStudyMemberStatus( + viewerId, StudyMemberStatus.OWNER); + Set aloneStudyIds = studyMemberRepository.findAloneOwnerStudyIds(viewerId); + return StudyDTOMapper.toDTO(pageContent, likedStudyIds, ownedStudyIds, aloneStudyIds, hasNext, + nextCursor, totalElements); + } + + private List filterPreferredRegionCodes(List regionCodes, + List preferredRegionCodes) { + if (regionCodes == null || regionCodes.isEmpty()) { + return preferredRegionCodes; + } + return preferredRegionCodes.stream() + .filter(regionCodes::contains) + .toList(); + } + + private List getCategories(Long viewerId, Category category) { + List preferredRegionCodes; + if (category == null) { + preferredRegionCodes = getPreferredCategoryPort.get(viewerId) + .stream() + .map(Category::fromString) + .toList(); + } else { + preferredRegionCodes = List.of(category); + } + return preferredRegionCodes; + } +} diff --git a/modules/study/src/main/java/kr/spot/application/query/GetStudyApplicationService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetStudyApplicationService.java similarity index 76% rename from modules/study/src/main/java/kr/spot/application/query/GetStudyApplicationService.java rename to modules/study/src/main/java/kr/spot/study/application/query/GetStudyApplicationService.java index 00b5fc25..ebf2d680 100644 --- a/modules/study/src/main/java/kr/spot/application/query/GetStudyApplicationService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetStudyApplicationService.java @@ -1,18 +1,18 @@ -package kr.spot.application.query; +package kr.spot.study.application.query; import java.util.List; import java.util.Map; -import kr.spot.application.validator.StudyAccessValidator; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.infrastructure.jpa.StudyMemberRepositoryCustom; -import kr.spot.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; import kr.spot.ports.GetMemberInfoPort; import kr.spot.ports.dto.MemberInfoResponse; -import kr.spot.presentation.query.dto.response.GetAppliesResponse; -import kr.spot.presentation.query.dto.response.GetAppliesResponse.Apply; -import kr.spot.presentation.query.dto.response.GetMyAppliedStudyResponse; -import kr.spot.presentation.query.dto.response.GetMyAppliedStudyResponse.MyAppliedStudy; +import kr.spot.study.application.validator.StudyAccessValidator; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyMemberRepositoryCustom; +import kr.spot.study.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; +import kr.spot.study.presentation.query.dto.response.GetAppliesResponse; +import kr.spot.study.presentation.query.dto.response.GetAppliesResponse.Apply; +import kr.spot.study.presentation.query.dto.response.GetMyAppliedStudyResponse; +import kr.spot.study.presentation.query.dto.response.GetMyAppliedStudyResponse.MyAppliedStudy; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,6 +46,7 @@ public GetMyAppliedStudyResponse getMyAppliedStudy(Long memberId) { ); } + @Deprecated(forRemoval = true) public GetAppliesResponse getStudyApplications(Long studyId, Long requesterId) { studyAccessValidator.validateStudyLeader(studyId, requesterId); diff --git a/modules/study/src/main/java/kr/spot/study/application/query/GetStudyDetailService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetStudyDetailService.java new file mode 100644 index 00000000..3f1022c4 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetStudyDetailService.java @@ -0,0 +1,117 @@ +package kr.spot.study.application.query; + +import java.util.List; +import java.util.Map; +import kr.spot.ports.GetMemberInfoPort; +import kr.spot.ports.dto.MemberInfoResponse; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.StudyCategory; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyCategoryRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.presentation.query.dto.response.GetStudyInfoResponse; +import kr.spot.study.presentation.query.dto.response.GetStudyInfoResponse.Statistics; +import kr.spot.study.presentation.query.dto.response.GetStudyMembersResponse; +import kr.spot.study.presentation.query.dto.response.GetStudyMembersResponse.MemberResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class GetStudyDetailService { + + private static final List ACTIVE_MEMBER_STATUSES = + List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED); + + private final StudyRepository studyRepository; + private final StudyCategoryRepository studyCategoryRepository; + private final StudyMemberRepository studyMemberRepository; + private final StudyViewCountService studyViewCountService; + private final GetMemberInfoPort getMemberInfoPort; + + public GetStudyInfoResponse getStudyInfo(long studyId, long viewerId) { + Study study = findStudy(studyId); + List categories = findCategories(studyId); + Statistics statistics = buildStatistics(study, studyId, viewerId); + + return toStudyInfoResponse(study, categories, statistics); + } + + public GetStudyMembersResponse getStudyMembers(long studyId) { + List studyMembers = findActiveStudyMembers(studyId); + Map memberInfoMap = fetchMemberInfos(studyMembers); + List members = toMemberResponses(studyMembers, memberInfoMap); + + return GetStudyMembersResponse.of(members, members.size()); + } + + private Study findStudy(long studyId) { + return studyRepository.getStudyById(studyId); + } + + private List findCategories(long studyId) { + return studyCategoryRepository.findAllByStudyId(studyId).stream() + .map(StudyCategory::getCategory) + .toList(); + } + + private Statistics buildStatistics(Study study, long studyId, long viewerId) { + long displayViewCount = studyViewCountService.calculateDisplayViewCount(study, studyId, + viewerId); + return Statistics.of( + study.getMaxMembers(), + study.getCurrentMembers(), + study.getLikeCount(), + displayViewCount + ); + } + + private GetStudyInfoResponse toStudyInfoResponse(Study study, List categories, + Statistics statistics) { + return GetStudyInfoResponse.of( + study.getId(), + study.getName(), + study.getDescription(), + study.getImageUrl(), + categories, + statistics + ); + } + + private List findActiveStudyMembers(long studyId) { + return studyMemberRepository.findAllByStudyIdAndStudyMemberStatusIn( + studyId, ACTIVE_MEMBER_STATUSES); + } + + private Map fetchMemberInfos(List studyMembers) { + List memberIds = studyMembers.stream() + .map(StudyMember::getMemberId) + .toList(); + return getMemberInfoPort.getMemberInfo(memberIds); + } + + private List toMemberResponses(List studyMembers, + Map memberInfoMap) { + return studyMembers.stream() + .map(studyMember -> toMemberResponse(studyMember, memberInfoMap)) + .toList(); + } + + private MemberResponse toMemberResponse(StudyMember studyMember, + Map memberInfoMap) { + MemberInfoResponse memberInfo = memberInfoMap.get(studyMember.getMemberId()); + boolean isOwner = studyMember.getStudyMemberStatus() == StudyMemberStatus.OWNER; + + return MemberResponse.of( + studyMember.getMemberId(), + memberInfo.name(), + memberInfo.profileImageUrl(), + isOwner + ); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/application/query/StudyViewCountService.java b/modules/study/src/main/java/kr/spot/study/application/query/StudyViewCountService.java new file mode 100644 index 00000000..c3fd5703 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/query/StudyViewCountService.java @@ -0,0 +1,36 @@ +package kr.spot.study.application.query; + +import kr.spot.study.domain.Study; +import kr.spot.view.ViewAbuseGuard; +import kr.spot.view.ViewCounter; +import kr.spot.view.ViewableType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudyViewCountService { + + private final ViewCounter viewCounter; + private final ViewAbuseGuard viewAbuseGuard; + + public long calculateDisplayViewCount(Study study, long studyId, long viewerId) { + long baseViewCount = study.getViewCount(); + long viewDelta = getViewDeltaFromCounter(studyId, viewerId); + return baseViewCount + viewDelta; + } + + private long getViewDeltaFromCounter(long studyId, long viewerId) { + try { + if (viewAbuseGuard.shouldCount(ViewableType.STUDY, studyId, viewerId)) { + return viewCounter.incrementAndGet(ViewableType.STUDY, studyId); + } + return viewCounter.currentDelta(ViewableType.STUDY, studyId); + } catch (Exception e) { + log.warn("Redis view counter access failed for studyId: {}", studyId, e); + return 0L; + } + } +} diff --git a/modules/study/src/main/java/kr/spot/application/query/port/CategoryCatalogService.java b/modules/study/src/main/java/kr/spot/study/application/query/port/CategoryCatalogService.java similarity index 80% rename from modules/study/src/main/java/kr/spot/application/query/port/CategoryCatalogService.java rename to modules/study/src/main/java/kr/spot/study/application/query/port/CategoryCatalogService.java index 28536120..64b81d03 100644 --- a/modules/study/src/main/java/kr/spot/application/query/port/CategoryCatalogService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/port/CategoryCatalogService.java @@ -1,7 +1,7 @@ -package kr.spot.application.query.port; +package kr.spot.study.application.query.port; -import kr.spot.domain.enums.Category; import kr.spot.ports.CategoryCatalogPort; +import kr.spot.study.domain.enums.Category; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/modules/study/src/main/java/kr/spot/application/validator/StudyAccessValidator.java b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java similarity index 63% rename from modules/study/src/main/java/kr/spot/application/validator/StudyAccessValidator.java rename to modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java index e3a8eaba..7ad9d3ae 100644 --- a/modules/study/src/main/java/kr/spot/application/validator/StudyAccessValidator.java +++ b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java @@ -1,12 +1,12 @@ -package kr.spot.application.validator; +package kr.spot.study.application.validator; import java.util.List; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.Study; -import kr.spot.domain.enums.StudyMemberStatus; import kr.spot.exception.GeneralException; -import kr.spot.infrastructure.jpa.StudyRepository; -import kr.spot.infrastructure.jpa.associations.StudyMemberRepository; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyRepository; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -23,14 +23,18 @@ public void validateStudyLeader(Long studyId, Long memberId) { } public void validateStudyMember(Long studyId, Long memberId) { - boolean isMember = studyMemberRepository.existsByStudyIdAndMemberIdAndStudyMemberStatusIn( + boolean isMember = isStudyMember(studyId, memberId); + + if (!isMember) { + throw new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED); + } + } + + public boolean isStudyMember(Long studyId, Long memberId) { + return studyMemberRepository.existsByStudyIdAndMemberIdAndStudyMemberStatusIn( studyId, memberId, List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED) ); - - if (!isMember) { - throw new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED); - } } } diff --git a/modules/study/src/main/java/kr/spot/domain/Study.java b/modules/study/src/main/java/kr/spot/study/domain/Study.java similarity index 65% rename from modules/study/src/main/java/kr/spot/domain/Study.java rename to modules/study/src/main/java/kr/spot/study/domain/Study.java index fc973d79..23fa6309 100644 --- a/modules/study/src/main/java/kr/spot/domain/Study.java +++ b/modules/study/src/main/java/kr/spot/study/domain/Study.java @@ -1,4 +1,4 @@ -package kr.spot.domain; +package kr.spot.study.domain; import io.micrometer.common.util.StringUtils; import jakarta.persistence.Column; @@ -8,12 +8,13 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.Decision; -import kr.spot.domain.enums.RecruitingStatus; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.domain.vo.Fee; +import kr.spot.domain.BaseEntity; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.domain.vo.Fee; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -56,17 +57,29 @@ public class Study extends BaseEntity { @Enumerated(EnumType.STRING) private RecruitingStatus recruitingStatus; + @Column(nullable = false) + private Boolean isOnline = false; + + private Long viewCount; + + private Long likeCount; + public static Study of(Long id, Long leaderId, String name, Integer maxMembers, Fee fee, String description) { - return of(id, leaderId, name, maxMembers, fee, null, description); + return of(id, leaderId, name, maxMembers, fee, null, description, false); } public static Study of(Long id, Long leaderId, String name, Integer maxMembers, Fee fee, String imageUrl, String description) { + return of(id, leaderId, name, maxMembers, fee, imageUrl, description, false); + } + + public static Study of(Long id, Long leaderId, String name, Integer maxMembers, Fee fee, + String imageUrl, String description, Boolean isOnline) { validateStudyNameIsNotBlank(name); validateMaxMembers(maxMembers); return new Study(id, leaderId, name, maxMembers, CURRENT_MEMBERS, fee, imageUrl, description, - RecruitingStatus.RECRUITING); + RecruitingStatus.RECRUITING, isOnline != null && isOnline, 0L, 0L); } private static void validateStudyNameIsNotBlank(String name) { @@ -82,9 +95,29 @@ private static void validateMaxMembers(Integer maxMembers) { } public void processApplication(StudyMember application, Long requesterId, Decision decision) { + validateIsFull(); validateIsStudyOwner(requesterId); validateIsValidStatusToProcessApply(application); application.decide(decision); + increaseMemberCountIfApproved(decision); + } + + private void validateIsFull() { + if (this.currentMembers >= this.maxMembers) { + throw new GeneralException(ErrorStatus._STUDY_IS_FULL); + } + } + + private void increaseMemberCountIfApproved(Decision decision) { + if (decision == Decision.APPROVE) { + this.currentMembers += 1; + } + } + + public void decreaseMemberCount() { + if (this.currentMembers > 0) { + this.currentMembers -= 1; + } } public void validateIsStudyOwner(Long requesterId) { @@ -106,4 +139,18 @@ public StudyMember receiveApplication(Long id, Long memberId, String message) { public void updateImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + + public void finishStudy(StudyMember studyMember) { + validateIsStudyOwner(studyMember.getMemberId()); + checkStudyCanDelete(); + this.recruitingStatus = RecruitingStatus.COMPLETED; + this.currentMembers = 0; + super.delete(); + } + + private void checkStudyCanDelete() { + if (currentMembers > 1) { + throw new GeneralException(ErrorStatus._CANNOT_DELETE_STUDY_WITH_MEMBERS); + } + } } diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyCategory.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyCategory.java similarity index 91% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyCategory.java rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyCategory.java index 9e9cc183..0bb12d8e 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyCategory.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyCategory.java @@ -1,11 +1,11 @@ -package kr.spot.domain.associations; +package kr.spot.study.domain.associations; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import kr.spot.domain.BaseEntity; -import kr.spot.domain.enums.Category; +import kr.spot.study.domain.enums.Category; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/study/domain/associations/StudyLike.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyLike.java new file mode 100644 index 00000000..b2a246c3 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyLike.java @@ -0,0 +1,36 @@ +package kr.spot.study.domain.associations; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import kr.spot.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE study_like SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Table(name = "study_like", + uniqueConstraints = @UniqueConstraint(name = "uk_study_member", columnNames = {"study_id", + "member_id"})) +public class StudyLike extends BaseEntity { + + @Id + private Long id; + + private Long studyId; + + private Long memberId; + + public static StudyLike of(Long id, Long studyId, Long memberId) { + return new StudyLike(id, studyId, memberId); + } +} diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyMember.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java similarity index 71% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyMember.java rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java index b9af2495..a934b98f 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyMember.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java @@ -1,4 +1,4 @@ -package kr.spot.domain.associations; +package kr.spot.study.domain.associations; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -6,9 +6,10 @@ import jakarta.persistence.Id; import kr.spot.code.status.ErrorStatus; import kr.spot.domain.BaseEntity; -import kr.spot.domain.enums.Decision; -import kr.spot.domain.enums.StudyMemberStatus; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.domain.enums.WithdrawReason; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -46,7 +47,7 @@ public static StudyMember apply(Long id, Long studyId, Long memberId, String mes public void decide(Decision decision) { if (decision == Decision.APPROVE) { - this.studyMemberStatus = StudyMemberStatus.AWAITING_SELF_APPROVAL; + this.studyMemberStatus = StudyMemberStatus.APPROVED; } else if (decision == Decision.REJECT) { this.studyMemberStatus = StudyMemberStatus.REJECTED; } @@ -67,4 +68,24 @@ public void decideFinalByApplicant(Long requesterId, Decision decision) { this.studyMemberStatus = StudyMemberStatus.SELF_REJECTED; } } + + public boolean isOwner() { + return this.studyMemberStatus == StudyMemberStatus.OWNER; + } + + public void promoteToOwner() { + this.studyMemberStatus = StudyMemberStatus.OWNER; + } + + public void withdrawStudy(WithdrawReason withdrawReason, StudyMember nextOwner) { + if (studyMemberStatus.equals(StudyMemberStatus.OWNER) && nextOwner == null) { + throw new GeneralException(ErrorStatus._NEXT_OWNER_ID_REQUIRED_FOR_OWNER_WITHDRAWAL); + } + + if (nextOwner != null) { + nextOwner.promoteToOwner(); + } + + this.studyMemberStatus = StudyMemberStatus.WITHDRAWN; + } } diff --git a/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMemberReport.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMemberReport.java new file mode 100644 index 00000000..09015970 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMemberReport.java @@ -0,0 +1,32 @@ +package kr.spot.study.domain.associations; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import kr.spot.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Getter +@Entity +@SQLDelete(sql = "UPDATE study_member_report SET status = 'INACTIVE' WHERE id = ?") +@SQLRestriction("status = 'ACTIVE'") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class StudyMemberReport extends BaseEntity { + + @Id + private Long id; + + private Long studyMemberId; + + private String reportReason; + + public static StudyMemberReport create(Long id, Long studyMemberId, String reportReason) { + return new StudyMemberReport(id, studyMemberId, reportReason); + } + +} diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyRegion.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyRegion.java similarity index 94% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyRegion.java rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyRegion.java index 973253fa..8e36bdab 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyRegion.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyRegion.java @@ -1,4 +1,4 @@ -package kr.spot.domain.associations; +package kr.spot.study.domain.associations; import jakarta.persistence.Entity; import jakarta.persistence.Id; diff --git a/modules/study/src/main/java/kr/spot/domain/associations/StudyStyle.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyStyle.java similarity index 91% rename from modules/study/src/main/java/kr/spot/domain/associations/StudyStyle.java rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyStyle.java index dcd86133..b60e8802 100644 --- a/modules/study/src/main/java/kr/spot/domain/associations/StudyStyle.java +++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyStyle.java @@ -1,11 +1,11 @@ -package kr.spot.domain.associations; +package kr.spot.study.domain.associations; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import kr.spot.domain.BaseEntity; -import kr.spot.domain.enums.Style; +import kr.spot.study.domain.enums.Style; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/domain/enums/Category.java b/modules/study/src/main/java/kr/spot/study/domain/enums/Category.java similarity index 96% rename from modules/study/src/main/java/kr/spot/domain/enums/Category.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/Category.java index b826bf13..e7fc42e3 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/Category.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/Category.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; diff --git a/modules/study/src/main/java/kr/spot/domain/enums/Decision.java b/modules/study/src/main/java/kr/spot/study/domain/enums/Decision.java similarity index 55% rename from modules/study/src/main/java/kr/spot/domain/enums/Decision.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/Decision.java index b92d379e..710b542a 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/Decision.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/Decision.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum Decision { APPROVE, REJECT diff --git a/modules/study/src/main/java/kr/spot/domain/enums/FeeCategory.java b/modules/study/src/main/java/kr/spot/study/domain/enums/FeeCategory.java similarity index 95% rename from modules/study/src/main/java/kr/spot/domain/enums/FeeCategory.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/FeeCategory.java index ff6f139b..10be2cc8 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/FeeCategory.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/FeeCategory.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum FeeCategory { diff --git a/modules/study/src/main/java/kr/spot/domain/enums/RecruitingStatus.java b/modules/study/src/main/java/kr/spot/study/domain/enums/RecruitingStatus.java similarity index 66% rename from modules/study/src/main/java/kr/spot/domain/enums/RecruitingStatus.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/RecruitingStatus.java index eed20776..cd1b7549 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/RecruitingStatus.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/RecruitingStatus.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum RecruitingStatus { diff --git a/modules/study/src/main/java/kr/spot/domain/enums/SortBy.java b/modules/study/src/main/java/kr/spot/study/domain/enums/SortBy.java similarity index 58% rename from modules/study/src/main/java/kr/spot/domain/enums/SortBy.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/SortBy.java index ef59bb27..64e35a49 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/SortBy.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/SortBy.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum SortBy { diff --git a/modules/study/src/main/java/kr/spot/domain/enums/StudyMemberStatus.java b/modules/study/src/main/java/kr/spot/study/domain/enums/StudyMemberStatus.java similarity index 85% rename from modules/study/src/main/java/kr/spot/domain/enums/StudyMemberStatus.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/StudyMemberStatus.java index 31705f74..c8089ac3 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/StudyMemberStatus.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/StudyMemberStatus.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; @@ -10,7 +10,9 @@ public enum StudyMemberStatus { AWAITING_SELF_APPROVAL, // 본인 승인 대기 APPROVED, // 신청 승인 완료 SELF_REJECTED, // 본인 승인 거절 - REJECTED; // 신청 거절 + REJECTED, // 신청 거절 + WITHDRAWN // 스터디 탈퇴 + ; public static StudyMemberStatus convert(String status) { for (StudyMemberStatus memberStatus : StudyMemberStatus.values()) { diff --git a/modules/study/src/main/java/kr/spot/domain/enums/Style.java b/modules/study/src/main/java/kr/spot/study/domain/enums/Style.java similarity index 93% rename from modules/study/src/main/java/kr/spot/domain/enums/Style.java rename to modules/study/src/main/java/kr/spot/study/domain/enums/Style.java index bb9f492d..58d21ec9 100644 --- a/modules/study/src/main/java/kr/spot/domain/enums/Style.java +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/Style.java @@ -1,4 +1,4 @@ -package kr.spot.domain.enums; +package kr.spot.study.domain.enums; public enum Style { diff --git a/modules/study/src/main/java/kr/spot/study/domain/enums/WithdrawReason.java b/modules/study/src/main/java/kr/spot/study/domain/enums/WithdrawReason.java new file mode 100644 index 00000000..753ea3f1 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/domain/enums/WithdrawReason.java @@ -0,0 +1,9 @@ +package kr.spot.study.domain.enums; + +public enum WithdrawReason { + FINISHED, + NO_MORE_NEEDS, + NOT_FIT, + JOIN_ANOTHER_STUDY, + ETC +} diff --git a/modules/study/src/main/java/kr/spot/domain/vo/Fee.java b/modules/study/src/main/java/kr/spot/study/domain/vo/Fee.java similarity index 92% rename from modules/study/src/main/java/kr/spot/domain/vo/Fee.java rename to modules/study/src/main/java/kr/spot/study/domain/vo/Fee.java index 3d126fce..3cc781f9 100644 --- a/modules/study/src/main/java/kr/spot/domain/vo/Fee.java +++ b/modules/study/src/main/java/kr/spot/study/domain/vo/Fee.java @@ -1,11 +1,11 @@ -package kr.spot.domain.vo; +package kr.spot.study.domain.vo; import jakarta.persistence.Embeddable; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import kr.spot.code.status.ErrorStatus; -import kr.spot.domain.enums.FeeCategory; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.enums.FeeCategory; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlushJob.java b/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlushJob.java new file mode 100644 index 00000000..5f8614e5 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlushJob.java @@ -0,0 +1,157 @@ +package kr.spot.study.infrastructure.batch; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyViewFlushJob { + + private static final String KEY_PREFIX = "view:study:"; + private static final String KEY_PATTERN = KEY_PREFIX + "*"; + private static final String LOCK_KEY = "lock:study-view-flush"; + + private final StringRedisTemplate redis; + private final StudyViewFlusher studyViewFlusher; + + @Scheduled(cron = "0 * * * * *") + public void flush() { + if (!tryAcquireLock()) { + log.debug("다른 인스턴스에서 실행 중"); + return; + } + try { + executeBatch(); + } finally { + releaseLock(); + } + } + + private void executeBatch() { + log.info("StudyViewFlushJob 시작"); + long startTime = System.currentTimeMillis(); + + BatchResult result = processAllKeys(); + + long duration = System.currentTimeMillis() - startTime; + log.info("StudyViewFlushJob 완료: 성공 {}건, 실패 {}건, {}ms", + result.successCount, result.failureCount, duration); + } + + private BatchResult processAllKeys() { + BatchResult result = new BatchResult(); + ScanOptions options = ScanOptions.scanOptions() + .match(KEY_PATTERN) + .count(500) + .build(); + + redis.execute((RedisCallback) connection -> { + try (Cursor cursor = connection.scan(options)) { + while (cursor.hasNext()) { + byte[] keyBytes = cursor.next(); + processKey(keyBytes, result); + } + } catch (Exception e) { + log.error("SCAN 처리 중 오류", e); + } + return null; + }); + + return result; + } + + private void processKey(byte[] keyBytes, BatchResult result) { + String key = new String(keyBytes, StandardCharsets.UTF_8); + long delta = 0; + + try { + String valueStr = redis.opsForValue().getAndDelete(key); + if (valueStr == null) { + return; + } + + delta = parseLong(valueStr); + if (delta <= 0) { + log.warn("유효하지 않은 값 무시: key={}, value={}", key, valueStr); + return; + } + + Long studyId = extractStudyIdFromKey(key); + if (studyId == null) { + log.error("studyId 추출 실패, 값 손실: key={}, delta={}", key, delta); + return; + } + + studyViewFlusher.updateViewCount(studyId, delta); + result.recordSuccess(); + log.debug("처리 완료: studyId={}, delta={}", studyId, delta); + + } catch (Exception e) { + result.recordFailure(); + log.error("처리 실패: key={}", key, e); + if (delta > 0) { + restoreValue(key, delta); + } + } + } + + private void restoreValue(String key, long delta) { + try { + redis.opsForValue().increment(key, delta); + log.warn("DB 반영 실패, Redis에 값 복구 완료: key={}, delta={}", key, delta); + } catch (Exception e) { + log.error("복구 실패, 데이터 손실: key={}, delta={}", key, delta, e); + } + } + + private boolean tryAcquireLock() { + Boolean acquired = redis.opsForValue() + .setIfAbsent(LOCK_KEY, "1", 55, TimeUnit.SECONDS); + return Boolean.TRUE.equals(acquired); + } + + private void releaseLock() { + redis.delete(LOCK_KEY); + } + + private long parseLong(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + log.warn("Long 파싱 실패: {}", value); + return 0L; + } + } + + private Long extractStudyIdFromKey(String key) { + try { + return Long.parseLong(key.substring(KEY_PREFIX.length())); + } catch (Exception e) { + log.error("studyId 추출 실패: {}", key, e); + return null; + } + } + + private static class BatchResult { + + int successCount = 0; + int failureCount = 0; + + void recordSuccess() { + successCount++; + } + + void recordFailure() { + failureCount++; + } + } +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlusher.java b/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlusher.java new file mode 100644 index 00000000..475d5074 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/batch/StudyViewFlusher.java @@ -0,0 +1,22 @@ +package kr.spot.study.infrastructure.batch; + +import kr.spot.study.infrastructure.jpa.StudyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyViewFlusher { + + private final StudyRepository studyRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void updateViewCount(long studyId, long delta) { + studyRepository.increaseViewBy(studyId, delta); + log.debug("스터디 조회수 DB 업데이트: studyId={}, delta={}", studyId, delta); + } +} diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyMemberRepositoryCustom.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyMemberRepositoryCustom.java similarity index 56% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyMemberRepositoryCustom.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyMemberRepositoryCustom.java index 9d7eee16..361468bf 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyMemberRepositoryCustom.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyMemberRepositoryCustom.java @@ -1,9 +1,9 @@ -package kr.spot.infrastructure.jpa; +package kr.spot.study.infrastructure.jpa; import java.util.List; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; public interface StudyMemberRepositoryCustom { diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java new file mode 100644 index 00000000..4d3052cf --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java @@ -0,0 +1,39 @@ +package kr.spot.study.infrastructure.jpa; + +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.enums.RecruitingStatus; +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; + +public interface StudyRepository extends JpaRepository { + + default Study getStudyById(Long studyId) { + return findById(studyId) + .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_NOT_FOUND)); + } + + long countByLeaderIdAndRecruitingStatus(long leaderId, RecruitingStatus recruitingStatus); + + boolean existsByLeaderId(long leaderId); + + @Modifying + @Query(""" + update Study s + set s.viewCount = s.viewCount + :delta, + s.updatedAt = CURRENT_TIMESTAMP + where s.id = :studyId + """) + int increaseViewBy(@Param("studyId") long studyId, @Param("delta") long delta); + + @Modifying + @Query("update Study s set s.likeCount = s.likeCount + 1 where s.id = :studyId") + int increaseLike(@Param("studyId") long studyId); + + @Modifying + @Query("update Study s set s.likeCount = case when s.likeCount > 0 then s.likeCount - 1 else 0 end where s.id = :studyId") + int decreaseLike(@Param("studyId") long studyId); +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java new file mode 100644 index 00000000..564ecbda --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java @@ -0,0 +1,10 @@ +package kr.spot.study.infrastructure.jpa.associations; + +import java.util.List; +import kr.spot.study.domain.associations.StudyCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyCategoryRepository extends JpaRepository { + + List findAllByStudyId(long studyId); +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java new file mode 100644 index 00000000..abb71e6f --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyLikeRepository.java @@ -0,0 +1,36 @@ +package kr.spot.study.infrastructure.jpa.associations; + +import kr.spot.study.domain.associations.StudyLike; +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; + +public interface StudyLikeRepository extends JpaRepository { + + @Modifying + @Query( + value = """ + INSERT IGNORE INTO study_like(id, study_id, member_id, status, created_at, updated_at) + VALUES (:id, :studyId, :memberId, 'ACTIVE', NOW(), NOW()) + """, nativeQuery = true) + int saveStudyLike(@Param("id") long id, + @Param("studyId") long studyId, + @Param("memberId") long memberId); + + @Modifying + @Query(value = """ + DELETE FROM study_like + WHERE study_id = :studyId + AND member_id = :memberId + """, nativeQuery = true) + int hardDelete(@Param("studyId") long studyId, + @Param("memberId") long memberId); + + @Modifying + @Query(value = "DELETE FROM study_like WHERE member_id = :memberId", nativeQuery = true) + void deleteAllByMemberId(@Param("memberId") long memberId); + + @Query("SELECT sl.studyId FROM StudyLike sl WHERE sl.memberId = :memberId") + java.util.Set findStudyIdsByMemberId(@Param("memberId") long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberReportRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberReportRepository.java new file mode 100644 index 00000000..d95ac25d --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberReportRepository.java @@ -0,0 +1,8 @@ +package kr.spot.study.infrastructure.jpa.associations; + +import kr.spot.study.domain.associations.StudyMemberReport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudyMemberReportRepository extends JpaRepository { + +} diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java new file mode 100644 index 00000000..c525ba03 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java @@ -0,0 +1,63 @@ +package kr.spot.study.infrastructure.jpa.associations; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import kr.spot.code.status.ErrorStatus; +import kr.spot.exception.GeneralException; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StudyMemberRepository extends JpaRepository { + + default StudyMember getStudyMemberById(Long studyMemberId) { + return findById(studyMemberId) + .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_MEMBER_NOT_FOUND)); + } + + default StudyMember getByMemberIdAndStudyId(Long memberId, Long studyId) { + return findByMemberIdAndStudyId(memberId, studyId) + .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_MEMBER_NOT_FOUND)); + } + + default StudyMember getByMemberIdAndStudyIdAndStudyMemberStatusIn(Long memberId, Long studyId, + List studyMemberStatuses) { + return findByMemberIdAndStudyIdAndStudyMemberStatusIn(memberId, studyId, studyMemberStatuses) + .orElseThrow(() -> new GeneralException(ErrorStatus._STUDY_MEMBER_NOT_FOUND)); + } + + Optional findByMemberIdAndStudyIdAndStudyMemberStatusIn(Long memberId, Long studyId, + List studyMemberStatuses); + + Optional findByMemberIdAndStudyId(Long memberId, Long studyId); + + boolean existsByStudyIdAndMemberIdAndStudyMemberStatusIn(Long studyId, Long memberId, + List studyMemberStatuses); + + List findAllByStudyIdAndStudyMemberStatusIn(Long studyId, + List studyMemberStatuses); + + long countByMemberIdAndStudyMemberStatusIn(long memberId, + List studyMemberStatuses); + + long countByMemberIdAndStudyMemberStatus(long memberId, StudyMemberStatus studyMemberStatus); + + void deleteByMemberId(long memberId); + + @Query("SELECT sm.studyId FROM StudyMember sm " + + "WHERE sm.memberId = :memberId " + + "AND sm.studyMemberStatus = :studyMemberStatus") + Set findStudyIdsByMemberIdAndStudyMemberStatus( + @Param("memberId") long memberId, + @Param("studyMemberStatus") StudyMemberStatus studyMemberStatus); + + @Query("SELECT sm.studyId FROM StudyMember sm " + + "JOIN Study s ON sm.studyId = s.id " + + "WHERE sm.memberId = :memberId " + + "AND sm.studyMemberStatus = 'OWNER' " + + "AND s.currentMembers = 1") + Set findAloneOwnerStudyIds(@Param("memberId") long memberId); +} diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyRegionRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyRegionRepository.java similarity index 57% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyRegionRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyRegionRepository.java index 40924b8b..b73f272d 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyRegionRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyRegionRepository.java @@ -1,8 +1,8 @@ -package kr.spot.infrastructure.jpa.associations; +package kr.spot.study.infrastructure.jpa.associations; -import kr.spot.domain.associations.StudyRegion; +import kr.spot.study.domain.associations.StudyRegion; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyRegionRepository extends JpaRepository { - + } diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStyleRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStyleRepository.java similarity index 57% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStyleRepository.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStyleRepository.java index 87df8b93..bc24930d 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStyleRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStyleRepository.java @@ -1,8 +1,8 @@ -package kr.spot.infrastructure.jpa.associations; +package kr.spot.study.infrastructure.jpa.associations; -import kr.spot.domain.associations.StudyStyle; +import kr.spot.study.domain.associations.StudyStyle; import org.springframework.data.jpa.repository.JpaRepository; public interface StudyStyleRepository extends JpaRepository { - + } diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java similarity index 79% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java index 7269a737..abead239 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java @@ -1,14 +1,14 @@ -package kr.spot.infrastructure.jpa.querydsl; +package kr.spot.study.infrastructure.jpa.querydsl; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; -import kr.spot.domain.QStudy; -import kr.spot.domain.associations.QStudyMember; -import kr.spot.domain.associations.StudyMember; -import kr.spot.domain.enums.StudyMemberStatus; -import kr.spot.infrastructure.jpa.StudyMemberRepositoryCustom; -import kr.spot.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; +import kr.spot.study.domain.QStudy; +import kr.spot.study.domain.associations.QStudyMember; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.StudyMemberRepositoryCustom; +import kr.spot.study.infrastructure.jpa.querydsl.dto.StudyApplicationInfo; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java new file mode 100644 index 00000000..4b5c44c2 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java @@ -0,0 +1,403 @@ +package kr.spot.study.infrastructure.jpa.querydsl; + +import static com.querydsl.jpa.JPAExpressions.selectOne; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import kr.spot.study.domain.QStudy; +import kr.spot.study.domain.Study; +import kr.spot.study.domain.associations.QStudyCategory; +import kr.spot.study.domain.associations.QStudyLike; +import kr.spot.study.domain.associations.QStudyMember; +import kr.spot.study.domain.associations.QStudyRegion; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.FeeCategory; +import kr.spot.study.domain.enums.RecruitingStatus; +import kr.spot.study.domain.enums.SortBy; +import kr.spot.study.domain.enums.StudyMemberStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class StudyQueryRepository { + + private final JPAQueryFactory query; + + public List findMyStudies( + Long viewerId, + List statuses, + Long cursor, + int limit) { + QStudy study = QStudy.study; + QStudyMember studyMember = QStudyMember.studyMember; + + return query + .select(study) + .from(studyMember) + .join(study).on(study.id.eq(studyMember.studyId)) + .where( + studyMember.memberId.eq(viewerId), + studyMember.studyMemberStatus.in(statuses), + ltCursor(cursor, study) + ) + .orderBy(study.id.desc()) + .limit(limit) + .fetch(); + } + + public long countMyStudies( + Long viewerId, + List statuses + ) { + QStudy study = QStudy.study; + QStudyMember studyMember = QStudyMember.studyMember; + + return query + .select(study.id.countDistinct()) + .from(studyMember) + .join(study).on(study.id.eq(studyMember.studyId)) + .where( + studyMember.memberId.eq(viewerId), + studyMember.studyMemberStatus.in(statuses) + ) + .fetchOne(); + } + + public List findMyPreferredRegionStudies( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + List categories, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int limit, + List regionCodes + ) { + QStudy study = QStudy.study; + QStudyRegion studyRegion = QStudyRegion.studyRegion; + + return query + .select(study) + .from(study) + .where( + existsRegion(study, studyRegion, regionCodes), + existsCategories(study, categories), + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqIsOnline(isOnline, study), + ltCursor(cursor, study) + ) + .orderBy( + orderBy(sortBy, study), + study.id.desc() + ) + .limit(limit) + .fetch(); + } + + public long countMyPreferredRegionStudies( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + List categories, + Boolean isOnline, + List regionCodes + ) { + QStudy study = QStudy.study; + QStudyRegion studyRegion = QStudyRegion.studyRegion; + + return query + .select(study.id.count()) + .from(study) + .where( + existsRegion(study, studyRegion, regionCodes), + existsCategories(study, categories), + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqIsOnline(isOnline, study) + ) + .fetchOne(); + } + + public List findMyPreferredCategoryStudies( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int limit, + List categories + ) { + QStudy study = QStudy.study; + + return query + .select(study) + .from(study) + .where( + existsCategories(study, categories), + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqIsOnline(isOnline, study), + ltCursor(cursor, study) + ) + .orderBy( + orderBy(sortBy, study), + study.id.desc() + ) + .limit(limit) + .fetch(); + } + + public long countMyPreferredCategoryStudies( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Boolean isOnline, + List categories + ) { + QStudy study = QStudy.study; + + return query + .select(study.id.count()) + .from(study) + .where( + existsCategories(study, categories), + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqIsOnline(isOnline, study) + ) + .fetchOne(); + } + + public List findRecruitingStudies( + FeeCategory feeCategory, + List categories, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int limit + ) { + QStudy study = QStudy.study; + + return query + .select(study) + .from(study) + .where( + study.recruitingStatus.eq(RecruitingStatus.RECRUITING), + existsCategories(study, categories), + eqFeeCategory(feeCategory, study), + eqIsOnline(isOnline, study), + ltCursor(cursor, study) + ) + .orderBy( + orderBy(sortBy, study), + study.id.desc() + ) + .limit(limit) + .fetch(); + } + + public long countRecruitingStudies( + FeeCategory feeCategory, + List categories, + Boolean isOnline + ) { + QStudy study = QStudy.study; + + return query + .select(study.id.count()) + .from(study) + .where( + study.recruitingStatus.eq(RecruitingStatus.RECRUITING), + existsCategories(study, categories), + eqFeeCategory(feeCategory, study), + eqIsOnline(isOnline, study) + ) + .fetchOne(); + } + + public List findStudiesByCategory( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Category category, + Boolean isOnline, + SortBy sortBy, + Long cursor, + int limit + ) { + QStudy study = QStudy.study; + + return query + .select(study) + .from(study) + .where( + existsCategory(study, category), + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqIsOnline(isOnline, study), + ltCursor(cursor, study) + ) + .orderBy( + orderBy(sortBy, study), + study.id.desc() + ) + .limit(limit) + .fetch(); + } + + public long countStudiesByCategory( + RecruitingStatus recruitingStatus, + FeeCategory feeCategory, + Category category, + Boolean isOnline + ) { + QStudy study = QStudy.study; + + return query + .select(study.id.count()) + .from(study) + .where( + existsCategory(study, category), + eqRecruitingStatus(recruitingStatus, study), + eqFeeCategory(feeCategory, study), + eqIsOnline(isOnline, study) + ) + .fetchOne(); + } + + private BooleanExpression eqRecruitingStatus(RecruitingStatus recruitingStatus, QStudy study) { + return recruitingStatus == null ? null : study.recruitingStatus.eq(recruitingStatus); + } + + private BooleanExpression eqFeeCategory(FeeCategory feeCategory, QStudy study) { + return feeCategory == null ? null : study.fee.feeCategory.eq(feeCategory); + } + + private BooleanExpression eqIsOnline(Boolean isOnline, QStudy study) { + return isOnline == null ? null : study.isOnline.eq(isOnline); + } + + private BooleanExpression ltCursor(Long cursor, QStudy study) { + return cursor == null ? null : study.id.lt(cursor); + } + + private BooleanExpression existsCategories(QStudy study, List categories) { + if (categories == null || categories.isEmpty()) { + return null; + } + QStudyCategory studyCategory = QStudyCategory.studyCategory; + return selectOne() + .from(studyCategory) + .where( + studyCategory.studyId.eq(study.id), + studyCategory.category.in(categories) + ) + .exists(); + } + + private BooleanExpression existsCategory(QStudy study, Category category) { + if (category == null) { + return null; + } + QStudyCategory studyCategory = QStudyCategory.studyCategory; + return selectOne() + .from(studyCategory) + .where( + studyCategory.studyId.eq(study.id), + studyCategory.category.eq(category) + ) + .exists(); + } + + private BooleanExpression existsRegion(QStudy study, QStudyRegion studyRegion, List regionCodes) { + if (regionCodes == null || regionCodes.isEmpty()) { + return null; + } + return selectOne() + .from(studyRegion) + .where( + studyRegion.studyId.eq(study.id), + studyRegion.regionCode.in(regionCodes) + ) + .exists(); + } + + private OrderSpecifier orderBy(SortBy sortBy, QStudy study) { + if (sortBy == null) { + return study.id.desc(); + } + + return switch (sortBy) { + case RECENT -> study.id.desc(); + case LIKES -> study.likeCount.desc(); + case HITS -> study.viewCount.desc(); + }; + } + + public List findLikedStudies(long memberId, Long cursor, int limit) { + QStudy study = QStudy.study; + QStudyLike studyLike = QStudyLike.studyLike; + + return query + .select(study) + .from(studyLike) + .join(study).on(study.id.eq(studyLike.studyId)) + .where( + studyLike.memberId.eq(memberId), + ltCursor(cursor, study) + ) + .orderBy(study.id.desc()) + .limit(limit) + .fetch(); + } + + public long countLikedStudies(long memberId) { + QStudy study = QStudy.study; + QStudyLike studyLike = QStudyLike.studyLike; + + return query + .select(study.id.countDistinct()) + .from(studyLike) + .join(study).on(study.id.eq(studyLike.studyId)) + .where(studyLike.memberId.eq(memberId)) + .fetchOne(); + } + + public List findRecruitingStudiesByCategories(List categories, int limit) { + QStudy study = QStudy.study; + + return query + .select(study) + .from(study) + .where( + existsCategories(study, categories), + study.recruitingStatus.eq(RecruitingStatus.RECRUITING) + ) + .orderBy(study.id.desc()) + .limit(limit) + .fetch(); + } + + public List findPopularRecruitingStudies(List excludeIds, int limit) { + QStudy study = QStudy.study; + + return query + .select(study) + .from(study) + .where( + study.recruitingStatus.eq(RecruitingStatus.RECRUITING), + notInIds(excludeIds, study) + ) + .orderBy(study.likeCount.desc(), study.id.desc()) + .limit(limit) + .fetch(); + } + + private BooleanExpression notInIds(List excludeIds, QStudy study) { + if (excludeIds == null || excludeIds.isEmpty()) { + return null; + } + return study.id.notIn(excludeIds); + } +} diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java similarity index 81% rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java index 1c76ed4f..a122b3f0 100644 --- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java @@ -1,4 +1,4 @@ -package kr.spot.infrastructure.jpa.querydsl.dto; +package kr.spot.study.infrastructure.jpa.querydsl.dto; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/modules/study/src/main/java/kr/spot/presentation/command/StudyApplyCommandController.java b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java similarity index 89% rename from modules/study/src/main/java/kr/spot/presentation/command/StudyApplyCommandController.java rename to modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java index 18dc9a32..8d5b0421 100644 --- a/modules/study/src/main/java/kr/spot/presentation/command/StudyApplyCommandController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyApplyCommandController.java @@ -1,4 +1,4 @@ -package kr.spot.presentation.command; +package kr.spot.study.presentation.command; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -7,10 +7,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.spot.ApiResponse; import kr.spot.annotations.CurrentMember; -import kr.spot.application.command.ApplyStudyService; import kr.spot.code.status.SuccessStatus; -import kr.spot.domain.enums.Decision; -import kr.spot.presentation.command.dto.request.ApplyStudyRequest; +import kr.spot.study.application.command.ApplyStudyService; +import kr.spot.study.domain.enums.Decision; +import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; @@ -54,7 +54,9 @@ public ResponseEntity> approveStudyApplication( return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, null)); } - @Operation(summary = "스터디 최종 참가 여부 결정", description = "승인된 스터디에 최종 참가할지 여부를 결정합니다.") + // 비활성화 + @Deprecated + @Operation(summary = "[사용 X] 스터디 최종 참가 여부 결정", description = "승인된 스터디에 최종 참가할지 여부를 결정합니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "스터디 참가 여부 결정 성공") }) diff --git a/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java new file mode 100644 index 00000000..f80de2a8 --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java @@ -0,0 +1,119 @@ +package kr.spot.study.presentation.command; + +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.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.code.status.SuccessStatus; +import kr.spot.study.application.command.CreateStudyService; +import kr.spot.study.application.command.ReportStudyMemberService; +import kr.spot.study.application.command.StudyLikeService; +import kr.spot.study.application.command.WithdrawStudyService; +import kr.spot.study.presentation.command.dto.request.CreateStudyRequest; +import kr.spot.study.presentation.command.dto.request.RepostStudyMemberRequest; +import kr.spot.study.presentation.command.dto.request.WithdrawStudyRequest; +import kr.spot.study.presentation.command.dto.response.CreateStudyResponse; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "스터디") +@RestController +@RequestMapping("/api/studies") +@RequiredArgsConstructor +public class StudyCommandController { + + private final CreateStudyService createStudyService; + private final ReportStudyMemberService reportStudyMemberService; + private final StudyLikeService studyLikeService; + private final WithdrawStudyService withdrawStudyService; + + @Operation(summary = "스터디 생성", description = + "새로운 스터디를 생성합니다. 요청 정보는 `multipart/form-data` 형식으로 보내야 하며, 이미지 파일은 선택 사항입니다." + + "request는 application/json 형식으로 보내야 합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = """ + - `COMMON4000`: 잘못된 요청입니다. (예: 유효하지 않은 카테고리/스타일/지역 코드, 스터디 이름 공백, 최대 인원 수 음수, 유효하지 않은 스터디 비용) + - `MEMBER4001`: 이름은 null 또는 공백일 수 없습니다. + - `STUDY4000`: 최대 인원 수는 양수여야 합니다. + - `STUDY4000`: 유효하지 않은 스터디 비용입니다. + - `STUDY4001`: 존재하지 않는 카테고리입니다. + - `REGION4000`: 존재하지 않는 지역 코드입니다. + """, content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증되지 않은 사용자입니다.", content = @Content(schema = @Schema(implementation = kr.spot.ApiResponse.class))) + }) + @PostMapping + public ResponseEntity> createStudy( + @RequestPart CreateStudyRequest request, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestPart(required = false) MultipartFile imageFile) { + long studyId = createStudyService.createStudy(request, memberId, imageFile); + return ResponseEntity.ok( + ApiResponse.onSuccess(SuccessStatus._CREATED, CreateStudyResponse.from(studyId))); + } + + @Operation(summary = "스터디 좋아요", description = "스터디에 좋아요를 누릅니다.") + @PostMapping("/{studyId}/like") + public ResponseEntity> likeStudy( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + studyLikeService.likeStudy(studyId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "스터디 좋아요 취소", description = "스터디 좋아요를 취소합니다.") + @DeleteMapping("/{studyId}/like") + public ResponseEntity> unlikeStudy( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + studyLikeService.unlikeStudy(studyId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "스터디 탈퇴", description = "스터디에서 탈퇴합니다.") + @PostMapping("{studyId}/withdraw") + public ResponseEntity> withdrawStudy( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestBody WithdrawStudyRequest request + ) { + withdrawStudyService.withdrawStudy(studyId, memberId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "스터디원 신고", description = "스터디원(본인 제외)을 신고합니다.") + @PostMapping("/{studyId}/members/{targetMemberId}/report") + public ResponseEntity> reportStudyMember( + @PathVariable Long studyId, + @PathVariable Long targetMemberId, + @CurrentMember @Parameter(hidden = true) Long memberId, + @RequestBody RepostStudyMemberRequest request + ) { + reportStudyMemberService.reportStudyMember(studyId, targetMemberId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } + + @Operation(summary = "스터디 삭제", description = "스터디를 삭제합니다.") + @DeleteMapping("/{studyId}") + public ResponseEntity> deleteStudy( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + withdrawStudyService.deleteStudy(studyId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/ApplyStudyRequest.java b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/ApplyStudyRequest.java new file mode 100644 index 00000000..68ef6d8a --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/ApplyStudyRequest.java @@ -0,0 +1,7 @@ +package kr.spot.study.presentation.command.dto.request; + +public record ApplyStudyRequest( + String message +) { + +} diff --git a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/CreateStudyRequest.java b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java similarity index 60% rename from modules/study/src/main/java/kr/spot/presentation/command/dto/request/CreateStudyRequest.java rename to modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java index 815d3de6..06094850 100644 --- a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/CreateStudyRequest.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/CreateStudyRequest.java @@ -1,8 +1,8 @@ -package kr.spot.presentation.command.dto.request; +package kr.spot.study.presentation.command.dto.request; import java.util.Set; -import kr.spot.domain.enums.Category; -import kr.spot.domain.enums.Style; +import kr.spot.study.domain.enums.Category; +import kr.spot.study.domain.enums.Style; public record CreateStudyRequest( String name, @@ -10,6 +10,7 @@ public record CreateStudyRequest( Boolean hasFee, Integer amount, String description, + Boolean isOnline, Set categories, Set