From 9545fdb67060afd3eafdd7274ba565671fdfc21b Mon Sep 17 00:00:00 2001
From: Minseok Kim <77945998+msk226@users.noreply.github.com>
Date: Fri, 26 Dec 2025 17:35:41 +0900
Subject: [PATCH 001/126] =?UTF-8?q?[Docs]=20README.md=20=EC=83=9D=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 49 insertions(+)
create mode 100644 README.md
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..f90b891b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,49 @@
+# ๐ย ์คํฐ๋ ๋งค์นญ ๋ฐ ๊ด๋ฆฌ ํ๋ซํผ, ์คํ
+
+> **๊ธฐ๊ฐ**: 2025.09 - ์งํ ์ค
+>
+
+> **ํ ๊ตฌ์ฑ**: P.O 1๋ช
, ๋์์ธ 1๋ช
, AOS 2๋ช
, ๋ฐฑ์๋ 1๋ช
+>
+
+> **์ญํ **: ๋ฐฑ์๋ ๊ฐ๋ฐ, ์ธํ๋ผ ๊ด๋ฆฌ (๊ธฐ์ฌ๋ 100%)
+>
+
+---
+
+
+
+
+---
+
+# ๐ ํ๋ก์ ํธ ๊ฐ์
+
+- ์คํฐ๋ ๋งค์นญ ๋ฐ ๊ด๋ฆฌ ํ๋ซํผ
+- ํ์ฌ Ver.1 ์ถ์ ์๋ฃ, Ver.2 ๊ฐ๋ฐ ์งํ ์ค
+
+---
+
+# ๐ ์ฃผ์ ๊ธฐ์ ์คํ ๋ฐ ์ํคํ
์ฒ
+
+### ๋ชจ๋๋ฌ ๋ชจ๋๋ฆฌ์ (Modular Monolith) ์ํคํ
์ฒ
+
+**โผ ์ ์ฒด ์ํคํ
์ฒ ๊ฐ์**
+
+
+
+
+**โผ ๋ชจ๋ ๊ฐ ์ํต ๋ฐฉ๋ฒ**
+
+
+
+
+- ๋๋ฉ์ธ ๊ฒฉ๋ฆฌ:
+ - ๋ชจ๋ ๊ฐ ํด๋์ค/์ํฐํฐ ์ง์ ์ฐธ์กฐ ๊ธ์ง
+- DB ๊ฒฉ๋ฆฌ:
+ - ๋๋ฉ์ธ ๊ฐ JOIN ๊ธ์ง, ํ ๋๋ฉ์ธ ์ํฐํฐ๋ ID(PK)๋ก๋ง ๊ฐ์ ์ฐธ์กฐ
+
+---
+
+### ์ธํ๋ผ ์ํคํ
์ฒ ๋ค์ด์ด๊ทธ๋จ
+
+
From 3b98320b1c8eb02c75f23ada6f331d1e57a5fac2 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 14:49:30 +0900
Subject: [PATCH 002/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EC=9D=BC=EC=A0=95=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=94?=
=?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=A0=95=EC=9D=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../main/java/kr/spot/domain/Schedule.java | 42 +++++++++++++++++++
1 file changed, 42 insertions(+)
create mode 100644 modules/study/src/main/java/kr/spot/domain/Schedule.java
diff --git a/modules/study/src/main/java/kr/spot/domain/Schedule.java b/modules/study/src/main/java/kr/spot/domain/Schedule.java
new file mode 100644
index 00000000..f0aafc31
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/domain/Schedule.java
@@ -0,0 +1,42 @@
+package kr.spot.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import java.time.LocalDateTime;
+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;
+
+ @Column(nullable = false)
+ private String title;
+
+ private String locationMemo;
+
+ private LocalDateTime startAt;
+
+ private LocalDateTime endAt;
+
+ public static Schedule of(Long id, Long studyId, String title, String locationMemo,
+ LocalDateTime startAt, LocalDateTime endAt
+ ) {
+ return new Schedule(id, studyId, title, locationMemo, startAt, endAt
+ );
+ }
+}
From 01a63bc8bd7a688d5f23f6c664b2cdb9b09e330c Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 14:55:05 +0900
Subject: [PATCH 003/126] =?UTF-8?q?[Refactor]=20=EC=8A=A4=ED=84=B0?=
=?UTF-8?q?=EB=94=94=20=EB=AA=A8=EB=93=88=20=EB=82=B4,=20=ED=95=98?=
=?UTF-8?q?=EC=9C=84=20=EB=8F=84=EB=A9=94=EC=9D=B8=EB=93=A4=EA=B3=BC=20?=
=?UTF-8?q?=EA=B5=AC=EB=B6=84=EC=9D=84=20=EC=9C=84=ED=95=B4=20=ED=8C=A8?=
=?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC=20=EC=A7=84=ED=96=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../spot/{ => schedule}/domain/Schedule.java | 3 +-
.../command/ApplyStudyService.java | 16 +++++-----
.../command/CreateStudyService.java | 32 +++++++++----------
.../application/event/StudyCreatedEvent.java | 2 +-
.../event/StudyImageUploadListener.java | 6 ++--
.../application/mapper/StudyDTOMapper.java | 8 ++---
.../query/GetMyStudyInfoService.java | 20 ++++++------
.../query/GetStudyApplicationService.java | 20 ++++++------
.../query/port/CategoryCatalogService.java | 4 +--
.../validator/StudyAccessValidator.java | 10 +++---
.../kr/spot/{ => study}/domain/Region.java | 2 +-
.../kr/spot/{ => study}/domain/Study.java | 12 +++----
.../domain/associations/StudyCategory.java | 4 +--
.../domain/associations/StudyMember.java | 6 ++--
.../domain/associations/StudyRegion.java | 2 +-
.../domain/associations/StudyStats.java | 2 +-
.../domain/associations/StudyStyle.java | 4 +--
.../{ => study}/domain/enums/Category.java | 2 +-
.../{ => study}/domain/enums/Decision.java | 2 +-
.../{ => study}/domain/enums/FeeCategory.java | 2 +-
.../domain/enums/RecruitingStatus.java | 2 +-
.../spot/{ => study}/domain/enums/SortBy.java | 2 +-
.../domain/enums/StudyMemberStatus.java | 2 +-
.../spot/{ => study}/domain/enums/Style.java | 2 +-
.../kr/spot/{ => study}/domain/vo/Fee.java | 4 +--
.../infrastructure/jpa/RegionRepository.java | 6 ++--
.../jpa/StudyMemberRepositoryCustom.java | 8 ++---
.../infrastructure/jpa/StudyRepository.java | 4 +--
.../associations/StudyCategoryRepository.java | 4 +--
.../associations/StudyMemberRepository.java | 6 ++--
.../associations/StudyRegionRepository.java | 6 ++--
.../associations/StudyStatsRepository.java | 6 ++--
.../associations/StudyStyleRepository.java | 6 ++--
.../StudyMemberRepositoryCustomImpl.java | 10 +++---
.../jpa/querydsl/StudyQueryRepository.java | 16 +++++-----
.../querydsl/dto/StudyApplicationInfo.java | 2 +-
.../command/StudyApplyCommandController.java | 8 ++---
.../command/StudyCommandController.java | 6 ++--
.../dto/request/ApplyStudyRequest.java | 2 +-
.../dto/request/CreateStudyRequest.java | 6 ++--
.../query/StudyApplyQueryController.java | 8 ++---
.../query/StudyQueryController.java | 16 +++++-----
.../request/StudySearchRequestForEqual.java | 8 ++---
.../request/StudySearchRequestForRange.java | 6 ++--
.../dto/response/GetAppliesResponse.java | 2 +-
.../response/GetMyAppliedStudyResponse.java | 2 +-
.../response/GetStudyOverviewResponse.java | 2 +-
.../command/ApplyStudyServiceTest.java | 15 +++++----
.../command/CreateStudyServiceTest.java | 23 ++++++-------
.../event/StudyCreatedEventTest.java | 1 +
.../event/StudyImageUploadListenerTest.java | 8 +++--
.../query/GetMyStudyInfoServiceTest.java | 17 +++++-----
.../query/GetStudyApplicationServiceTest.java | 16 +++++-----
.../spot/common/ApplyStudyRequestFixture.java | 2 +-
.../common/StudyApplicationInfoFixture.java | 2 +-
.../java/kr/spot/common/StudyFixture.java | 4 +--
.../kr/spot/common/StudyMemberFixture.java | 6 ++--
.../java/kr/spot/domain/StudyMemberTest.java | 6 ++--
.../test/java/kr/spot/domain/StudyTest.java | 11 ++++---
.../test/java/kr/spot/domain/vo/FeeTest.java | 1 +
60 files changed, 216 insertions(+), 207 deletions(-)
rename modules/study/src/main/java/kr/spot/{ => schedule}/domain/Schedule.java (93%)
rename modules/study/src/main/java/kr/spot/{ => study}/application/command/ApplyStudyService.java (83%)
rename modules/study/src/main/java/kr/spot/{ => study}/application/command/CreateStudyService.java (72%)
rename modules/study/src/main/java/kr/spot/{ => study}/application/event/StudyCreatedEvent.java (90%)
rename modules/study/src/main/java/kr/spot/{ => study}/application/event/StudyImageUploadListener.java (91%)
rename modules/study/src/main/java/kr/spot/{ => study}/application/mapper/StudyDTOMapper.java (72%)
rename modules/study/src/main/java/kr/spot/{ => study}/application/query/GetMyStudyInfoService.java (88%)
rename modules/study/src/main/java/kr/spot/{ => study}/application/query/GetStudyApplicationService.java (77%)
rename modules/study/src/main/java/kr/spot/{ => study}/application/query/port/CategoryCatalogService.java (80%)
rename modules/study/src/main/java/kr/spot/{ => study}/application/validator/StudyAccessValidator.java (77%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/Region.java (96%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/Study.java (92%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyCategory.java (91%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyMember.java (93%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyRegion.java (94%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyStats.java (94%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/associations/StudyStyle.java (91%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/Category.java (96%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/Decision.java (55%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/FeeCategory.java (95%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/RecruitingStatus.java (66%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/SortBy.java (58%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/StudyMemberStatus.java (94%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/enums/Style.java (93%)
rename modules/study/src/main/java/kr/spot/{ => study}/domain/vo/Fee.java (92%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/RegionRepository.java (63%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/StudyMemberRepositoryCustom.java (56%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/StudyRepository.java (83%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyCategoryRepository.java (57%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyMemberRepository.java (78%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyRegionRepository.java (57%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyStatsRepository.java (57%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/associations/StudyStyleRepository.java (57%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java (84%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/querydsl/StudyQueryRepository.java (95%)
rename modules/study/src/main/java/kr/spot/{ => study}/infrastructure/jpa/querydsl/dto/StudyApplicationInfo.java (81%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/command/StudyApplyCommandController.java (94%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/command/StudyCommandController.java (93%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/command/dto/request/ApplyStudyRequest.java (51%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/command/dto/request/CreateStudyRequest.java (64%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/StudyApplyQueryController.java (90%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/StudyQueryController.java (92%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/request/StudySearchRequestForEqual.java (50%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/request/StudySearchRequestForRange.java (58%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/response/GetAppliesResponse.java (91%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/response/GetMyAppliedStudyResponse.java (91%)
rename modules/study/src/main/java/kr/spot/{ => study}/presentation/query/dto/response/GetStudyOverviewResponse.java (95%)
diff --git a/modules/study/src/main/java/kr/spot/domain/Schedule.java b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java
similarity index 93%
rename from modules/study/src/main/java/kr/spot/domain/Schedule.java
rename to modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java
index f0aafc31..d7431104 100644
--- a/modules/study/src/main/java/kr/spot/domain/Schedule.java
+++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java
@@ -1,9 +1,10 @@
-package kr.spot.domain;
+package kr.spot.schedule.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.time.LocalDateTime;
+import kr.spot.domain.BaseEntity;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
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 83%
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..df7136dc 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,16 +1,16 @@
-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.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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
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 72%
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..7a7f03ef 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,21 @@
-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.StudyStats;
+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.StudyStatsRepository;
+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;
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/application/mapper/StudyDTOMapper.java b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java
similarity index 72%
rename from modules/study/src/main/java/kr/spot/application/mapper/StudyDTOMapper.java
rename to modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java
index eb483cf3..97c37fc9 100644
--- a/modules/study/src/main/java/kr/spot/application/mapper/StudyDTOMapper.java
+++ b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java
@@ -1,9 +1,9 @@
-package kr.spot.application.mapper;
+package kr.spot.study.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;
+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 {
diff --git a/modules/study/src/main/java/kr/spot/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java
similarity index 88%
rename from modules/study/src/main/java/kr/spot/application/query/GetMyStudyInfoService.java
rename to modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java
index 18084b09..d5e4f127 100644
--- a/modules/study/src/main/java/kr/spot/application/query/GetMyStudyInfoService.java
+++ b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java
@@ -1,17 +1,17 @@
-package kr.spot.application.query;
+package kr.spot.study.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 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.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;
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 77%
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..19d997f3 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;
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 77%
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..c5f4b40c 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;
diff --git a/modules/study/src/main/java/kr/spot/domain/Region.java b/modules/study/src/main/java/kr/spot/study/domain/Region.java
similarity index 96%
rename from modules/study/src/main/java/kr/spot/domain/Region.java
rename to modules/study/src/main/java/kr/spot/study/domain/Region.java
index efe867aa..98dc1fb1 100644
--- a/modules/study/src/main/java/kr/spot/domain/Region.java
+++ b/modules/study/src/main/java/kr/spot/study/domain/Region.java
@@ -1,4 +1,4 @@
-package kr.spot.domain;
+package kr.spot.study.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
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 92%
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..522ad61d 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,12 @@
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.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;
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/domain/associations/StudyMember.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyMember.java
similarity index 93%
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..93b743c6 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,9 @@
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 lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
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/StudyStats.java b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyStats.java
similarity index 94%
rename from modules/study/src/main/java/kr/spot/domain/associations/StudyStats.java
rename to modules/study/src/main/java/kr/spot/study/domain/associations/StudyStats.java
index 29de3c49..f5773615 100644
--- a/modules/study/src/main/java/kr/spot/domain/associations/StudyStats.java
+++ b/modules/study/src/main/java/kr/spot/study/domain/associations/StudyStats.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 94%
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..f531523c 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;
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/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/infrastructure/jpa/RegionRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java
similarity index 63%
rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/RegionRepository.java
rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java
index 84cbf4c2..fa6e2772 100644
--- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/RegionRepository.java
+++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java
@@ -1,8 +1,8 @@
-package kr.spot.infrastructure.jpa;
+package kr.spot.study.infrastructure.jpa;
-import kr.spot.domain.Region;
+import kr.spot.study.domain.Region;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RegionRepository extends JpaRepository {
-
+
}
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/infrastructure/jpa/StudyRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java
similarity index 83%
rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyRepository.java
rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java
index 99dda341..ec9bbb0f 100644
--- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/StudyRepository.java
+++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/StudyRepository.java
@@ -1,8 +1,8 @@
-package kr.spot.infrastructure.jpa;
+package kr.spot.study.infrastructure.jpa;
import kr.spot.code.status.ErrorStatus;
-import kr.spot.domain.Study;
import kr.spot.exception.GeneralException;
+import kr.spot.study.domain.Study;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StudyRepository extends JpaRepository {
diff --git a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyCategoryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java
similarity index 57%
rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyCategoryRepository.java
rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java
index 5e8bb5ba..32040953 100644
--- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyCategoryRepository.java
+++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyCategoryRepository.java
@@ -1,6 +1,6 @@
-package kr.spot.infrastructure.jpa.associations;
+package kr.spot.study.infrastructure.jpa.associations;
-import kr.spot.domain.associations.StudyCategory;
+import kr.spot.study.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/study/infrastructure/jpa/associations/StudyMemberRepository.java
similarity index 78%
rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyMemberRepository.java
rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java
index 15450ee7..76649a6f 100644
--- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyMemberRepository.java
+++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java
@@ -1,10 +1,10 @@
-package kr.spot.infrastructure.jpa.associations;
+package kr.spot.study.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 kr.spot.study.domain.associations.StudyMember;
+import kr.spot.study.domain.enums.StudyMemberStatus;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StudyMemberRepository extends JpaRepository {
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/StudyStatsRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java
similarity index 57%
rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStatsRepository.java
rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java
index a2a4ba68..42d3e873 100644
--- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/associations/StudyStatsRepository.java
+++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyStatsRepository.java
@@ -1,8 +1,8 @@
-package kr.spot.infrastructure.jpa.associations;
+package kr.spot.study.infrastructure.jpa.associations;
-import kr.spot.domain.associations.StudyStats;
+import kr.spot.study.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/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 84%
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..ca9ec0c6 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.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/infrastructure/jpa/querydsl/StudyQueryRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java
similarity index 95%
rename from modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyQueryRepository.java
rename to modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java
index 5754bf44..9f94a210 100644
--- a/modules/study/src/main/java/kr/spot/infrastructure/jpa/querydsl/StudyQueryRepository.java
+++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyQueryRepository.java
@@ -1,27 +1,27 @@
-package kr.spot.infrastructure.jpa.querydsl;
+package kr.spot.study.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 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 lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class StudyQueryRepository {
-
+
private final JPAQueryFactory query;
public List findMyStudies(
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 94%
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..9531b206 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;
diff --git a/modules/study/src/main/java/kr/spot/presentation/command/StudyCommandController.java b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java
similarity index 93%
rename from modules/study/src/main/java/kr/spot/presentation/command/StudyCommandController.java
rename to modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java
index e2f7ba8c..41df1348 100644
--- a/modules/study/src/main/java/kr/spot/presentation/command/StudyCommandController.java
+++ b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.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;
@@ -8,9 +8,9 @@
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 kr.spot.study.application.command.CreateStudyService;
+import kr.spot.study.presentation.command.dto.request.CreateStudyRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
diff --git a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/ApplyStudyRequest.java b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/ApplyStudyRequest.java
similarity index 51%
rename from modules/study/src/main/java/kr/spot/presentation/command/dto/request/ApplyStudyRequest.java
rename to modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/ApplyStudyRequest.java
index 170e3659..68ef6d8a 100644
--- a/modules/study/src/main/java/kr/spot/presentation/command/dto/request/ApplyStudyRequest.java
+++ b/modules/study/src/main/java/kr/spot/study/presentation/command/dto/request/ApplyStudyRequest.java
@@ -1,4 +1,4 @@
-package kr.spot.presentation.command.dto.request;
+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 64%
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..551eba53 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,
diff --git a/modules/study/src/main/java/kr/spot/presentation/query/StudyApplyQueryController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyApplyQueryController.java
similarity index 90%
rename from modules/study/src/main/java/kr/spot/presentation/query/StudyApplyQueryController.java
rename to modules/study/src/main/java/kr/spot/study/presentation/query/StudyApplyQueryController.java
index c7b97dcc..77bc60bf 100644
--- a/modules/study/src/main/java/kr/spot/presentation/query/StudyApplyQueryController.java
+++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyApplyQueryController.java
@@ -1,4 +1,4 @@
-package kr.spot.presentation.query;
+package kr.spot.study.presentation.query;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -9,10 +9,10 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.spot.ApiResponse;
import kr.spot.annotations.CurrentMember;
-import kr.spot.application.query.GetStudyApplicationService;
import kr.spot.code.status.SuccessStatus;
-import kr.spot.presentation.query.dto.response.GetAppliesResponse;
-import kr.spot.presentation.query.dto.response.GetMyAppliedStudyResponse;
+import kr.spot.study.application.query.GetStudyApplicationService;
+import kr.spot.study.presentation.query.dto.response.GetAppliesResponse;
+import kr.spot.study.presentation.query.dto.response.GetMyAppliedStudyResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
diff --git a/modules/study/src/main/java/kr/spot/presentation/query/StudyQueryController.java b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java
similarity index 92%
rename from modules/study/src/main/java/kr/spot/presentation/query/StudyQueryController.java
rename to modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java
index 4ef425a9..be518511 100644
--- a/modules/study/src/main/java/kr/spot/presentation/query/StudyQueryController.java
+++ b/modules/study/src/main/java/kr/spot/study/presentation/query/StudyQueryController.java
@@ -1,4 +1,4 @@
-package kr.spot.presentation.query;
+package kr.spot.study.presentation.query;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -8,14 +8,14 @@
import java.util.List;
import kr.spot.ApiResponse;
import kr.spot.annotations.CurrentMember;
-import kr.spot.application.query.GetMyStudyInfoService;
import kr.spot.code.status.SuccessStatus;
-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.presentation.query.dto.response.GetStudyOverviewResponse;
+import kr.spot.study.application.query.GetMyStudyInfoService;
+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.presentation.query.dto.response.GetStudyOverviewResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForEqual.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForEqual.java
similarity index 50%
rename from modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForEqual.java
rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForEqual.java
index d0b251a5..3ba041ea 100644
--- a/modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForEqual.java
+++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForEqual.java
@@ -1,9 +1,9 @@
-package kr.spot.presentation.query.dto.request;
+package kr.spot.study.presentation.query.dto.request;
import java.util.List;
-import kr.spot.domain.enums.Category;
-import kr.spot.domain.enums.FeeCategory;
-import kr.spot.domain.enums.RecruitingStatus;
+import kr.spot.study.domain.enums.Category;
+import kr.spot.study.domain.enums.FeeCategory;
+import kr.spot.study.domain.enums.RecruitingStatus;
public record StudySearchRequestForEqual(
FeeCategory feeCategory,
diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForRange.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForRange.java
similarity index 58%
rename from modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForRange.java
rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForRange.java
index 136474f3..15446c1d 100644
--- a/modules/study/src/main/java/kr/spot/presentation/query/dto/request/StudySearchRequestForRange.java
+++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/request/StudySearchRequestForRange.java
@@ -1,8 +1,8 @@
-package kr.spot.presentation.query.dto.request;
+package kr.spot.study.presentation.query.dto.request;
import java.util.List;
-import kr.spot.domain.enums.Category;
-import kr.spot.domain.enums.RecruitingStatus;
+import kr.spot.study.domain.enums.Category;
+import kr.spot.study.domain.enums.RecruitingStatus;
public record StudySearchRequestForRange(
boolean hasFee,
diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetAppliesResponse.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetAppliesResponse.java
similarity index 91%
rename from modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetAppliesResponse.java
rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetAppliesResponse.java
index cc567932..b64a9014 100644
--- a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetAppliesResponse.java
+++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetAppliesResponse.java
@@ -1,4 +1,4 @@
-package kr.spot.presentation.query.dto.response;
+package kr.spot.study.presentation.query.dto.response;
import java.util.List;
diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetMyAppliedStudyResponse.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetMyAppliedStudyResponse.java
similarity index 91%
rename from modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetMyAppliedStudyResponse.java
rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetMyAppliedStudyResponse.java
index 1526242d..6634ad93 100644
--- a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetMyAppliedStudyResponse.java
+++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetMyAppliedStudyResponse.java
@@ -1,4 +1,4 @@
-package kr.spot.presentation.query.dto.response;
+package kr.spot.study.presentation.query.dto.response;
import java.util.List;
diff --git a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetStudyOverviewResponse.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java
similarity index 95%
rename from modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetStudyOverviewResponse.java
rename to modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java
index 5cce5d6e..b6b84a48 100644
--- a/modules/study/src/main/java/kr/spot/presentation/query/dto/response/GetStudyOverviewResponse.java
+++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java
@@ -1,4 +1,4 @@
-package kr.spot.presentation.query.dto.response;
+package kr.spot.study.presentation.query.dto.response;
import java.util.List;
diff --git a/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java b/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java
index bbcf35e1..aaf9e725 100644
--- a/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java
@@ -18,14 +18,15 @@
import kr.spot.common.ApplyStudyRequestFixture;
import kr.spot.common.StudyFixture;
import kr.spot.common.StudyMemberFixture;
-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.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.application.command.ApplyStudyService;
+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 org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
diff --git a/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java b/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java
index 14bbdc65..507010b3 100644
--- a/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java
@@ -7,17 +7,18 @@
import java.util.Set;
import kr.spot.IdGenerator;
-import kr.spot.application.event.StudyCreatedEvent;
-import kr.spot.domain.Study;
-import kr.spot.domain.enums.Category;
-import kr.spot.domain.enums.Style;
-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.presentation.command.dto.request.CreateStudyRequest;
+import kr.spot.study.application.command.CreateStudyService;
+import kr.spot.study.application.event.StudyCreatedEvent;
+import kr.spot.study.domain.Study;
+import kr.spot.study.domain.enums.Category;
+import kr.spot.study.domain.enums.Style;
+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.StudyStatsRepository;
+import kr.spot.study.infrastructure.jpa.associations.StudyStyleRepository;
+import kr.spot.study.presentation.command.dto.request.CreateStudyRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
diff --git a/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java b/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java
index bee96991..5f658d9c 100644
--- a/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java
+++ b/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java
@@ -2,6 +2,7 @@
import static org.assertj.core.api.Assertions.assertThat;
+import kr.spot.study.application.event.StudyCreatedEvent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
diff --git a/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java b/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java
index ff643ca8..3bca88c5 100644
--- a/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java
+++ b/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java
@@ -13,11 +13,13 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import kr.spot.domain.Study;
-import kr.spot.domain.vo.Fee;
-import kr.spot.infrastructure.jpa.StudyRepository;
import kr.spot.ports.FileStoragePort;
import kr.spot.ports.dto.UploadResult;
+import kr.spot.study.application.event.StudyCreatedEvent;
+import kr.spot.study.application.event.StudyImageUploadListener;
+import kr.spot.study.domain.Study;
+import kr.spot.study.domain.vo.Fee;
+import kr.spot.study.infrastructure.jpa.StudyRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
diff --git a/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java b/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java
index 7d317947..8927a8aa 100644
--- a/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java
@@ -10,16 +10,17 @@
import java.util.Collections;
import java.util.List;
-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 kr.spot.study.application.query.GetMyStudyInfoService;
+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.querydsl.StudyQueryRepository;
+import kr.spot.study.presentation.query.dto.response.GetStudyOverviewResponse;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
diff --git a/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java b/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java
index 18958e59..6fd4382f 100644
--- a/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java
@@ -11,19 +11,19 @@
import java.util.Collections;
import java.util.List;
import java.util.Map;
-import kr.spot.application.validator.StudyAccessValidator;
import kr.spot.code.status.ErrorStatus;
-import kr.spot.common.MemberInfoResponseFixture;
import kr.spot.common.StudyMemberFixture;
-import kr.spot.domain.associations.StudyMember;
-import kr.spot.domain.enums.StudyMemberStatus;
import kr.spot.exception.GeneralException;
-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.GetMyAppliedStudyResponse;
+import kr.spot.study.application.query.GetStudyApplicationService;
+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.GetMyAppliedStudyResponse;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
diff --git a/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java b/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java
index 74441dc3..626e015c 100644
--- a/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java
+++ b/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java
@@ -1,6 +1,6 @@
package kr.spot.common;
-import kr.spot.presentation.command.dto.request.ApplyStudyRequest;
+import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest;
public class ApplyStudyRequestFixture {
diff --git a/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java b/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java
index 858784e6..175465a7 100644
--- a/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java
+++ b/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java
@@ -2,7 +2,7 @@
import java.util.List;
import java.util.stream.LongStream;
-import kr.spot.infrastructure.jpa.querydsl.dto.StudyApplicationInfo;
+import kr.spot.study.infrastructure.jpa.querydsl.dto.StudyApplicationInfo;
public class StudyApplicationInfoFixture {
diff --git a/modules/study/src/test/java/kr/spot/common/StudyFixture.java b/modules/study/src/test/java/kr/spot/common/StudyFixture.java
index 1cdfc6ae..ae2f42fc 100644
--- a/modules/study/src/test/java/kr/spot/common/StudyFixture.java
+++ b/modules/study/src/test/java/kr/spot/common/StudyFixture.java
@@ -2,8 +2,8 @@
import java.util.List;
import java.util.stream.LongStream;
-import kr.spot.domain.Study;
-import kr.spot.domain.vo.Fee;
+import kr.spot.study.domain.Study;
+import kr.spot.study.domain.vo.Fee;
public class StudyFixture {
diff --git a/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java b/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java
index e3101492..02bddc82 100644
--- a/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java
+++ b/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java
@@ -1,7 +1,7 @@
package kr.spot.common;
-import kr.spot.domain.associations.StudyMember;
-import kr.spot.domain.enums.StudyMemberStatus;
+import kr.spot.study.domain.associations.StudyMember;
+import kr.spot.study.domain.enums.Decision;
public class StudyMemberFixture {
@@ -29,7 +29,7 @@ public static StudyMember applied() {
public static StudyMember awaitingSelfApproval(Long id, Long studyId, Long memberId) {
StudyMember studyMember = StudyMember.apply(id, studyId, memberId, MESSAGE);
- studyMember.decide(kr.spot.domain.enums.Decision.APPROVE);
+ studyMember.decide(Decision.APPROVE);
return studyMember;
}
diff --git a/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java b/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java
index e4e12bc9..fddbf648 100644
--- a/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java
+++ b/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java
@@ -11,10 +11,10 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import kr.spot.code.status.ErrorStatus;
-import kr.spot.domain.associations.StudyMember;
-import kr.spot.domain.enums.Decision;
-import kr.spot.domain.enums.StudyMemberStatus;
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.StudyMemberStatus;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
diff --git a/modules/study/src/test/java/kr/spot/domain/StudyTest.java b/modules/study/src/test/java/kr/spot/domain/StudyTest.java
index ff8bf2d1..82792662 100644
--- a/modules/study/src/test/java/kr/spot/domain/StudyTest.java
+++ b/modules/study/src/test/java/kr/spot/domain/StudyTest.java
@@ -14,12 +14,13 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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.exception.GeneralException;
+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.RecruitingStatus;
+import kr.spot.study.domain.enums.StudyMemberStatus;
+import kr.spot.study.domain.vo.Fee;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
diff --git a/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java b/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java
index e9360bb8..1538e08a 100644
--- a/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java
+++ b/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java
@@ -5,6 +5,7 @@
import kr.spot.code.status.ErrorStatus;
import kr.spot.exception.GeneralException;
+import kr.spot.study.domain.vo.Fee;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
From f9ca31924a3ca971bfbb0ddb3396bf86ef6d6f38 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 14:57:38 +0900
Subject: [PATCH 004/126] =?UTF-8?q?[Refactor]=20Q=20=ED=81=B4=EB=9E=98?=
=?UTF-8?q?=EC=8A=A4=20import=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/main/java/kr/spot/study/domain/Region.java | 1 +
.../src/main/java/kr/spot/study/domain/Study.java | 1 +
.../jpa/querydsl/StudyMemberRepositoryCustomImpl.java | 4 ++--
.../jpa/querydsl/StudyQueryRepository.java | 10 +++++-----
4 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/modules/study/src/main/java/kr/spot/study/domain/Region.java b/modules/study/src/main/java/kr/spot/study/domain/Region.java
index 98dc1fb1..5333852c 100644
--- a/modules/study/src/main/java/kr/spot/study/domain/Region.java
+++ b/modules/study/src/main/java/kr/spot/study/domain/Region.java
@@ -2,6 +2,7 @@
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
+import kr.spot.domain.BaseEntity;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
diff --git a/modules/study/src/main/java/kr/spot/study/domain/Study.java b/modules/study/src/main/java/kr/spot/study/domain/Study.java
index 522ad61d..4c74946b 100644
--- a/modules/study/src/main/java/kr/spot/study/domain/Study.java
+++ b/modules/study/src/main/java/kr/spot/study/domain/Study.java
@@ -8,6 +8,7 @@
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import kr.spot.code.status.ErrorStatus;
+import kr.spot.domain.BaseEntity;
import kr.spot.exception.GeneralException;
import kr.spot.study.domain.associations.StudyMember;
import kr.spot.study.domain.enums.Decision;
diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java
index ca9ec0c6..abead239 100644
--- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java
+++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/querydsl/StudyMemberRepositoryCustomImpl.java
@@ -3,8 +3,8 @@
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.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;
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
index 9f94a210..831b78e9 100644
--- 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
@@ -4,12 +4,12 @@
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.associations.QStudyCategory;
-import kr.spot.domain.associations.QStudyMember;
-import kr.spot.domain.associations.QStudyRegion;
-import kr.spot.domain.associations.QStudyStats;
+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.QStudyMember;
+import kr.spot.study.domain.associations.QStudyRegion;
+import kr.spot.study.domain.associations.QStudyStats;
import kr.spot.study.domain.enums.Category;
import kr.spot.study.domain.enums.FeeCategory;
import kr.spot.study.domain.enums.RecruitingStatus;
From 9f65555b1437047ccc3f8a78c186a44ac355c50d Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 15:15:21 +0900
Subject: [PATCH 005/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?=
=?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20API=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/kr/spot/code/status/ErrorStatus.java | 4 ++
.../command/ManageScheduleService.java | 31 ++++++++++++++
.../kr/spot/schedule/domain/Schedule.java | 13 ++++++
.../jpa/ScheduleRepository.java | 14 +++++++
.../command/ScheduleCommandController.java | 41 +++++++++++++++++++
.../command/dto/CreateScheduleRequest.java | 12 ++++++
.../query/ScheduleQueryController.java | 15 +++++++
7 files changed, 130 insertions(+)
create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/command/ManageScheduleService.java
create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/ScheduleRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java
create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java
create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java
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..d0d5e295 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
@@ -60,6 +60,10 @@ 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", "ํด๋น ์คํฐ๋์ ์ํ๋ ์ผ์ ์ด ์๋๋๋ค."),
;
private final int httpStatus;
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..e75dfa3a
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/schedule/application/command/ManageScheduleService.java
@@ -0,0 +1,31 @@
+package kr.spot.schedule.application.command;
+
+import kr.spot.IdGenerator;
+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 void createSchedule(CreateScheduleRequest request, long studyId) {
+ Schedule schedule = Schedule.of(idGenerator.nextId(), studyId, request.title(),
+ request.locationInfo(),
+ request.startAt(), request.endAt());
+
+ scheduleRepository.save(schedule);
+ }
+
+ public void deleteSchedule(long studyId, long scheduleId) {
+ Schedule schedule = scheduleRepository.getById(scheduleId);
+ schedule.delete(studyId);
+ }
+}
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
index d7431104..0971c3c1 100644
--- a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java
+++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java
@@ -4,7 +4,9 @@
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;
@@ -40,4 +42,15 @@ public static Schedule of(Long id, Long studyId, String title, String locationMe
return new Schedule(id, studyId, title, locationMemo, startAt, endAt
);
}
+
+ public void delete(long studyId) {
+ validateIsValidAccess(studyId);
+ super.delete();
+ }
+
+ private void validateIsValidAccess(long studyId) {
+ if (studyId != this.studyId) {
+ throw new GeneralException(ErrorStatus._SCHEDULE_ACCESS_DENIED);
+ }
+ }
}
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..4982c3c4
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/ScheduleRepository.java
@@ -0,0 +1,14 @@
+package kr.spot.schedule.infrastructure.jpa;
+
+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 {
+
+ 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/presentation/command/ScheduleCommandController.java b/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java
new file mode 100644
index 00000000..7b73e73e
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/ScheduleCommandController.java
@@ -0,0 +1,41 @@
+package kr.spot.schedule.presentation.command;
+
+
+import io.swagger.v3.oas.annotations.tags.Tag;
+import kr.spot.ApiResponse;
+import kr.spot.code.status.SuccessStatus;
+import kr.spot.schedule.application.command.ManageScheduleService;
+import kr.spot.schedule.presentation.command.dto.CreateScheduleRequest;
+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;
+
+ @PostMapping
+ public ResponseEntity> createSchedule(
+ @PathVariable Long studyId,
+ @RequestBody CreateScheduleRequest request) {
+ manageScheduleService.createSchedule(request, studyId);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ }
+
+ @DeleteMapping("/{scheduleId}")
+ public ResponseEntity> deleteSchedule(
+ @PathVariable Long studyId,
+ @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..055068bd
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/schedule/presentation/command/dto/CreateScheduleRequest.java
@@ -0,0 +1,12 @@
+package kr.spot.schedule.presentation.command.dto;
+
+import java.time.LocalDateTime;
+
+public record CreateScheduleRequest(
+ String title,
+ String locationInfo,
+ LocalDateTime startAt,
+ LocalDateTime endAt
+) {
+
+}
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..db9bbb90
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/ScheduleQueryController.java
@@ -0,0 +1,15 @@
+package kr.spot.schedule.presentation.query;
+
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+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 ScheduleQueryController {
+
+ // TODO: ์ ๋จ์ ์กฐํ, ์ฃผ ๋จ์ ์กฐํ ํ์
+}
From a3beaaaf533c8a51f84c59e3ad062382dae72e1a Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 15:32:56 +0900
Subject: [PATCH 006/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?=
=?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=ED=85=8C?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManageScheduleServiceTest.java | 153 ++++++++++++++++++
.../spot/schedule/common/ScheduleFixture.java | 31 ++++
.../kr/spot/schedule/domain/ScheduleTest.java | 92 +++++++++++
3 files changed, 276 insertions(+)
create mode 100644 modules/study/src/test/java/kr/spot/schedule/application/command/ManageScheduleServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/schedule/common/ScheduleFixture.java
create mode 100644 modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java
diff --git a/modules/study/src/test/java/kr/spot/schedule/application/command/ManageScheduleServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/command/ManageScheduleServiceTest.java
new file mode 100644
index 00000000..17e82a22
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/schedule/application/command/ManageScheduleServiceTest.java
@@ -0,0 +1,153 @@
+package kr.spot.schedule.application.command;
+
+import static kr.spot.schedule.common.ScheduleFixture.LOCATION_MEMO;
+import static kr.spot.schedule.common.ScheduleFixture.STUDY_ID;
+import static kr.spot.schedule.common.ScheduleFixture.TITLE;
+import static kr.spot.schedule.common.ScheduleFixture.createScheduleRequest;
+import static kr.spot.schedule.common.ScheduleFixture.schedule;
+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.verify;
+import static org.mockito.Mockito.when;
+
+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 org.junit.jupiter.api.BeforeEach;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class ManageScheduleServiceTest {
+
+ @Mock
+ IdGenerator idGenerator;
+
+ @Mock
+ ScheduleRepository scheduleRepository;
+
+ @Captor
+ ArgumentCaptor scheduleCaptor;
+
+ ManageScheduleService manageScheduleService;
+
+ @BeforeEach
+ void setUp() {
+ manageScheduleService = new ManageScheduleService(idGenerator, scheduleRepository);
+ }
+
+ @Nested
+ @DisplayName("์ผ์ ์์ฑ (createSchedule)")
+ class CreateSchedule {
+
+ @Test
+ @DisplayName("์ผ์ ์ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_schedule_successfully() {
+ // given
+ Long generatedId = 1L;
+ CreateScheduleRequest request = createScheduleRequest();
+
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(scheduleRepository.save(any(Schedule.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ manageScheduleService.createSchedule(request, STUDY_ID);
+
+ // then
+ verify(scheduleRepository).save(scheduleCaptor.capture());
+
+ Schedule capturedSchedule = scheduleCaptor.getValue();
+ assertThat(capturedSchedule.getId()).isEqualTo(generatedId);
+ assertThat(capturedSchedule.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(capturedSchedule.getTitle()).isEqualTo(TITLE);
+ assertThat(capturedSchedule.getLocationMemo()).isEqualTo(LOCATION_MEMO);
+ assertThat(capturedSchedule.getStartAt()).isEqualTo(request.startAt());
+ assertThat(capturedSchedule.getEndAt()).isEqualTo(request.endAt());
+ }
+
+ @Test
+ @DisplayName("์์น ์ ๋ณด ์์ด ์ผ์ ์ ์์ฑํ ์ ์๋ค")
+ void should_create_schedule_without_location() {
+ // given
+ Long generatedId = 1L;
+ CreateScheduleRequest request = new CreateScheduleRequest(
+ TITLE, null, createScheduleRequest().startAt(), createScheduleRequest().endAt()
+ );
+
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(scheduleRepository.save(any(Schedule.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ manageScheduleService.createSchedule(request, STUDY_ID);
+
+ // then
+ verify(scheduleRepository).save(scheduleCaptor.capture());
+
+ Schedule capturedSchedule = scheduleCaptor.getValue();
+ assertThat(capturedSchedule.getLocationMemo()).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("์ผ์ ์ญ์ (deleteSchedule)")
+ class DeleteSchedule {
+
+ @Test
+ @DisplayName("์ผ์ ์ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_schedule_successfully() {
+ // given
+ long scheduleId = 1L;
+ Schedule schedule = schedule(scheduleId, STUDY_ID);
+
+ when(scheduleRepository.getById(anyLong())).thenReturn(schedule);
+
+ // when
+ manageScheduleService.deleteSchedule(STUDY_ID, scheduleId);
+
+ // then
+ verify(scheduleRepository).getById(scheduleId);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ์ผ์ ์ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_other_study_schedule() {
+ // given
+ long scheduleId = 1L;
+ long otherStudyId = 999L;
+ Schedule schedule = schedule(scheduleId, STUDY_ID);
+
+ when(scheduleRepository.getById(anyLong())).thenReturn(schedule);
+
+ // when & then
+ assertThatThrownBy(() -> manageScheduleService.deleteSchedule(otherStudyId, scheduleId))
+ .isInstanceOf(GeneralException.class);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ์ผ์ ์ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_schedule_not_found() {
+ // given
+ long scheduleId = 999L;
+
+ when(scheduleRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._SCHEDULE_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(() -> manageScheduleService.deleteSchedule(STUDY_ID, scheduleId))
+ .isInstanceOf(GeneralException.class);
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/schedule/common/ScheduleFixture.java b/modules/study/src/test/java/kr/spot/schedule/common/ScheduleFixture.java
new file mode 100644
index 00000000..b0c9225c
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/schedule/common/ScheduleFixture.java
@@ -0,0 +1,31 @@
+package kr.spot.schedule.common;
+
+import java.time.LocalDateTime;
+import kr.spot.schedule.domain.Schedule;
+import kr.spot.schedule.presentation.command.dto.CreateScheduleRequest;
+
+public class ScheduleFixture {
+
+ public static final Long ID = 1L;
+ public static final Long STUDY_ID = 100L;
+ public static final String TITLE = "Weekly Meeting";
+ public static final String LOCATION_MEMO = "๊ฐ๋จ์ญ ์คํฐ๋์นดํ";
+ public static final LocalDateTime START_AT = LocalDateTime.of(2025, 1, 15, 14, 0);
+ public static final LocalDateTime END_AT = LocalDateTime.of(2025, 1, 15, 16, 0);
+
+ public static Schedule schedule() {
+ return Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, START_AT, END_AT);
+ }
+
+ public static Schedule schedule(Long id, Long studyId) {
+ return Schedule.of(id, studyId, TITLE, LOCATION_MEMO, START_AT, END_AT);
+ }
+
+ public static CreateScheduleRequest createScheduleRequest() {
+ return new CreateScheduleRequest(TITLE, LOCATION_MEMO, START_AT, END_AT);
+ }
+
+ public static CreateScheduleRequest createScheduleRequest(String title) {
+ return new CreateScheduleRequest(title, LOCATION_MEMO, START_AT, END_AT);
+ }
+}
\ No newline at end of file
diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java
new file mode 100644
index 00000000..e80c1e9a
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java
@@ -0,0 +1,92 @@
+package kr.spot.schedule.domain;
+
+import static kr.spot.schedule.common.ScheduleFixture.END_AT;
+import static kr.spot.schedule.common.ScheduleFixture.ID;
+import static kr.spot.schedule.common.ScheduleFixture.LOCATION_MEMO;
+import static kr.spot.schedule.common.ScheduleFixture.START_AT;
+import static kr.spot.schedule.common.ScheduleFixture.STUDY_ID;
+import static kr.spot.schedule.common.ScheduleFixture.TITLE;
+import static kr.spot.schedule.common.ScheduleFixture.schedule;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class ScheduleTest {
+
+ @Nested
+ @DisplayName("์ผ์ ์์ฑ (of)")
+ class CreateSchedule {
+
+ @Test
+ @DisplayName("์ผ์ ๊ฐ์ฒด๋ฅผ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_schedule_successfully() {
+ // when
+ Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, START_AT, END_AT);
+
+ // then
+ assertThat(schedule).isNotNull();
+ assertThat(schedule.getId()).isEqualTo(ID);
+ assertThat(schedule.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(schedule.getTitle()).isEqualTo(TITLE);
+ assertThat(schedule.getLocationMemo()).isEqualTo(LOCATION_MEMO);
+ assertThat(schedule.getStartAt()).isEqualTo(START_AT);
+ assertThat(schedule.getEndAt()).isEqualTo(END_AT);
+ }
+
+ @Test
+ @DisplayName("์์น ์ ๋ณด ์์ด ์ผ์ ๊ฐ์ฒด๋ฅผ ์์ฑํ ์ ์๋ค")
+ void should_create_schedule_without_location() {
+ // when
+ Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, null, START_AT, END_AT);
+
+ // then
+ assertThat(schedule).isNotNull();
+ assertThat(schedule.getLocationMemo()).isNull();
+ }
+
+ @Test
+ @DisplayName("์์/์ข
๋ฃ ์๊ฐ ์์ด ์ผ์ ๊ฐ์ฒด๋ฅผ ์์ฑํ ์ ์๋ค")
+ void should_create_schedule_without_time() {
+ // when
+ Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, null, null);
+
+ // then
+ assertThat(schedule).isNotNull();
+ assertThat(schedule.getStartAt()).isNull();
+ assertThat(schedule.getEndAt()).isNull();
+ }
+ }
+
+ @Nested
+ @DisplayName("์ผ์ ์ญ์ (delete)")
+ class DeleteSchedule {
+
+ @Test
+ @DisplayName("ํด๋น ์คํฐ๋์ ์ผ์ ์ ์ญ์ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ์ง ์๋๋ค")
+ void should_delete_schedule_successfully() {
+ // given
+ Schedule schedule = schedule();
+
+ // when & then (์์ธ๊ฐ ๋ฐ์ํ์ง ์์ผ๋ฉด ์ฑ๊ณต)
+ schedule.delete(STUDY_ID);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ์ผ์ ์ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_with_different_study_id() {
+ // given
+ Schedule schedule = schedule();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> schedule.delete(otherStudyId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._SCHEDULE_ACCESS_DENIED);
+ }
+ }
+}
From c8ea59fbd7cd1d97d88f7a93c5800ba0765d5d54 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 15:33:03 +0900
Subject: [PATCH 007/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?=
=?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20API=20?=
=?UTF-8?q?=EB=AA=85=EC=84=B8=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ScheduleCommandController.java | 24 ++++++++++++++++---
.../command/dto/CreateScheduleRequest.java | 9 +++++++
2 files changed, 30 insertions(+), 3 deletions(-)
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
index 7b73e73e..d3d613ab 100644
--- 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
@@ -1,6 +1,11 @@
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.code.status.SuccessStatus;
@@ -23,18 +28,31 @@ 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(
- @PathVariable Long studyId,
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
@RequestBody CreateScheduleRequest request) {
manageScheduleService.createSchedule(request, studyId);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
}
+ @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(
- @PathVariable Long studyId,
- @PathVariable Long scheduleId) {
+ @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
index 055068bd..b95b76ea 100644
--- 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
@@ -1,11 +1,20 @@
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
) {
From a45bcd3717582b704f9f98877558f7dcb2a36743 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 22:11:14 +0900
Subject: [PATCH 008/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?=
=?UTF-8?q?=EC=A1=B0=ED=9A=8C=20(=EC=9B=94,=20=EC=A3=BC,=20=EB=8B=A4?=
=?UTF-8?q?=EA=B0=80=EC=98=A4=EB=8A=94=20=EC=9D=BC=EC=A0=95)=20API=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../application/query/GetScheduleService.java | 54 ++++++++++++++
.../kr/spot/schedule/domain/Schedule.java | 7 ++
.../jpa/querydsl/ScheduleQueryRepository.java | 73 +++++++++++++++++++
.../query/ScheduleQueryController.java | 58 ++++++++++++++-
.../query/dto/GetScheduleListResponse.java | 30 ++++++++
5 files changed, 221 insertions(+), 1 deletion(-)
create mode 100644 modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java
create mode 100644 modules/study/src/main/java/kr/spot/schedule/infrastructure/jpa/querydsl/ScheduleQueryRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetScheduleListResponse.java
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..f3b11b9c
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/schedule/application/query/GetScheduleService.java
@@ -0,0 +1,54 @@
+package kr.spot.schedule.application.query;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+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 = 5;
+
+ private final ScheduleQueryRepository scheduleQueryRepository;
+
+ public GetScheduleListResponse getMonthlySchedules(Long studyId, int year, int month) {
+ LocalDate date = LocalDate.of(year, month, 1);
+ List schedules = scheduleQueryRepository.findMonthlySchedules(studyId, date);
+ return toResponse(schedules);
+ }
+
+ public GetScheduleListResponse getWeeklySchedules(Long studyId, LocalDate date) {
+ List schedules = scheduleQueryRepository.findWeeklySchedules(studyId, date);
+ return toResponse(schedules);
+ }
+
+ public GetScheduleListResponse getUpcomingSchedules(Long studyId) {
+ List schedules = scheduleQueryRepository.findUpcomingSchedules(studyId, UPCOMING_LIMIT);
+ return toResponse(schedules);
+ }
+
+ private GetScheduleListResponse toResponse(List schedules) {
+ LocalDateTime now = LocalDateTime.now();
+
+ List responses = schedules.stream()
+ .map(schedule -> ScheduleResponse.from(
+ schedule.getId(),
+ schedule.getTitle(),
+ schedule.getStartAt(),
+ schedule.getEndAt(),
+ schedule.isOngoing(now)
+ ))
+ .toList();
+
+ return GetScheduleListResponse.from(responses);
+ }
+}
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
index 0971c3c1..85a7ccc6 100644
--- a/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java
+++ b/modules/study/src/main/java/kr/spot/schedule/domain/Schedule.java
@@ -48,6 +48,13 @@ public void delete(long studyId) {
super.delete();
}
+ public boolean isOngoing(LocalDateTime now) {
+ if (startAt == null || endAt == null) {
+ return false;
+ }
+ return !now.isBefore(startAt) && !now.isAfter(endAt);
+ }
+
private void validateIsValidAccess(long studyId) {
if (studyId != this.studyId) {
throw new GeneralException(ErrorStatus._SCHEDULE_ACCESS_DENIED);
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..ed308735
--- /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.startAt.goe(now)
+ )
+ .orderBy(schedule.startAt.asc())
+ .limit(limit)
+ .fetch();
+ }
+}
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
index db9bbb90..7861eef5 100644
--- 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
@@ -1,8 +1,24 @@
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.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 = "์คํฐ๋ ์ผ์ ")
@@ -11,5 +27,45 @@
@RequiredArgsConstructor
public class ScheduleQueryController {
- // TODO: ์ ๋จ์ ์กฐํ, ์ฃผ ๋จ์ ์กฐํ ํ์
+ 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(
+ @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);
+ 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(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "์กฐํ ๊ธฐ์ค ๋ ์ง (ํด๋น ์ฃผ ์ ์ฒด ์กฐํ)", example = "2025-01-15")
+ @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate date) {
+ GetScheduleListResponse response = getScheduleService.getWeeklySchedules(studyId, date);
+ 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(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId) {
+ GetScheduleListResponse response = getScheduleService.getUpcomingSchedules(studyId);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response));
+ }
}
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..3174c818
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/schedule/presentation/query/dto/GetScheduleListResponse.java
@@ -0,0 +1,30 @@
+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
+ ) {
+
+ public static ScheduleResponse from(long scheduleId, String title,
+ LocalDateTime startAt, LocalDateTime endAt, boolean isNow) {
+ return new ScheduleResponse(scheduleId, title, startAt, endAt, isNow);
+ }
+
+ }
+
+}
From 87de5813de272b2b8f497903ec9c2cf492e95ef5 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 22:14:26 +0900
Subject: [PATCH 009/126] =?UTF-8?q?[Feature]=20=EC=9D=BC=EC=A0=95=20?=
=?UTF-8?q?=EC=A1=B0=ED=9A=8C=20(=EC=9B=94,=20=EC=A3=BC,=20=EB=8B=A4?=
=?UTF-8?q?=EA=B0=80=EC=98=A4=EB=8A=94=20=EC=9D=BC=EC=A0=95)=20=ED=85=8C?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../application/query/GetScheduleService.java | 2 +-
.../query/GetScheduleServiceTest.java | 161 ++++++++++++++++++
.../kr/spot/schedule/domain/ScheduleTest.java | 102 +++++++++++
3 files changed, 264 insertions(+), 1 deletion(-)
create mode 100644 modules/study/src/test/java/kr/spot/schedule/application/query/GetScheduleServiceTest.java
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
index f3b11b9c..2d9b4f67 100644
--- 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
@@ -16,7 +16,7 @@
@RequiredArgsConstructor
public class GetScheduleService {
- private static final int UPCOMING_LIMIT = 5;
+ private static final int UPCOMING_LIMIT = 2;
private final ScheduleQueryRepository scheduleQueryRepository;
diff --git a/modules/study/src/test/java/kr/spot/schedule/application/query/GetScheduleServiceTest.java b/modules/study/src/test/java/kr/spot/schedule/application/query/GetScheduleServiceTest.java
new file mode 100644
index 00000000..722791a1
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/schedule/application/query/GetScheduleServiceTest.java
@@ -0,0 +1,161 @@
+package kr.spot.schedule.application.query;
+
+import static kr.spot.schedule.common.ScheduleFixture.STUDY_ID;
+import static kr.spot.schedule.common.ScheduleFixture.schedule;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDate;
+import java.util.Collections;
+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 org.junit.jupiter.api.BeforeEach;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class GetScheduleServiceTest {
+
+ @Mock
+ ScheduleQueryRepository scheduleQueryRepository;
+
+ GetScheduleService getScheduleService;
+
+ @BeforeEach
+ void setUp() {
+ getScheduleService = new GetScheduleService(scheduleQueryRepository);
+ }
+
+ @Nested
+ @DisplayName("์๋ณ ์ผ์ ์กฐํ (getMonthlySchedules)")
+ class GetMonthlySchedules {
+
+ @Test
+ @DisplayName("์๋ณ ์ผ์ ์ ์กฐํํ ์ ์๋ค")
+ void should_get_monthly_schedules() {
+ // given
+ int year = 2025;
+ int month = 1;
+ List schedules = List.of(
+ schedule(1L, STUDY_ID),
+ schedule(2L, STUDY_ID)
+ );
+
+ when(scheduleQueryRepository.findMonthlySchedules(anyLong(), any(LocalDate.class)))
+ .thenReturn(schedules);
+
+ // when
+ GetScheduleListResponse response = getScheduleService.getMonthlySchedules(STUDY_ID, year, month);
+
+ // then
+ assertThat(response.schedules()).hasSize(2);
+ assertThat(response.totalCount()).isEqualTo(2);
+ verify(scheduleQueryRepository).findMonthlySchedules(STUDY_ID, LocalDate.of(year, month, 1));
+ }
+
+ @Test
+ @DisplayName("์ผ์ ์ด ์์ผ๋ฉด ๋น ๋ฆฌ์คํธ๋ฅผ ๋ฐํํ๋ค")
+ void should_return_empty_list_when_no_schedules() {
+ // given
+ when(scheduleQueryRepository.findMonthlySchedules(anyLong(), any(LocalDate.class)))
+ .thenReturn(Collections.emptyList());
+
+ // when
+ GetScheduleListResponse response = getScheduleService.getMonthlySchedules(STUDY_ID, 2025, 1);
+
+ // then
+ assertThat(response.schedules()).isEmpty();
+ assertThat(response.totalCount()).isZero();
+ }
+ }
+
+ @Nested
+ @DisplayName("์ฃผ๊ฐ ์ผ์ ์กฐํ (getWeeklySchedules)")
+ class GetWeeklySchedules {
+
+ @Test
+ @DisplayName("์ฃผ๊ฐ ์ผ์ ์ ์กฐํํ ์ ์๋ค")
+ void should_get_weekly_schedules() {
+ // given
+ LocalDate date = LocalDate.of(2025, 1, 15);
+ List schedules = List.of(schedule(1L, STUDY_ID));
+
+ when(scheduleQueryRepository.findWeeklySchedules(anyLong(), any(LocalDate.class)))
+ .thenReturn(schedules);
+
+ // when
+ GetScheduleListResponse response = getScheduleService.getWeeklySchedules(STUDY_ID, date);
+
+ // then
+ assertThat(response.schedules()).hasSize(1);
+ verify(scheduleQueryRepository).findWeeklySchedules(STUDY_ID, date);
+ }
+
+ @Test
+ @DisplayName("์ผ์ ์ด ์์ผ๋ฉด ๋น ๋ฆฌ์คํธ๋ฅผ ๋ฐํํ๋ค")
+ void should_return_empty_list_when_no_schedules() {
+ // given
+ LocalDate date = LocalDate.of(2025, 1, 15);
+
+ when(scheduleQueryRepository.findWeeklySchedules(anyLong(), any(LocalDate.class)))
+ .thenReturn(Collections.emptyList());
+
+ // when
+ GetScheduleListResponse response = getScheduleService.getWeeklySchedules(STUDY_ID, date);
+
+ // then
+ assertThat(response.schedules()).isEmpty();
+ assertThat(response.totalCount()).isZero();
+ }
+ }
+
+ @Nested
+ @DisplayName("๋ค๊ฐ์ค๋ ์ผ์ ์กฐํ (getUpcomingSchedules)")
+ class GetUpcomingSchedules {
+
+ @Test
+ @DisplayName("๋ค๊ฐ์ค๋ ์ผ์ ์ ์กฐํํ ์ ์๋ค")
+ void should_get_upcoming_schedules() {
+ // given
+ List schedules = List.of(
+ schedule(1L, STUDY_ID),
+ schedule(2L, STUDY_ID)
+ );
+
+ when(scheduleQueryRepository.findUpcomingSchedules(anyLong(), anyInt()))
+ .thenReturn(schedules);
+
+ // when
+ GetScheduleListResponse response = getScheduleService.getUpcomingSchedules(STUDY_ID);
+
+ // then
+ assertThat(response.schedules()).hasSize(2);
+ verify(scheduleQueryRepository).findUpcomingSchedules(STUDY_ID, 2);
+ }
+
+ @Test
+ @DisplayName("์ผ์ ์ด ์์ผ๋ฉด ๋น ๋ฆฌ์คํธ๋ฅผ ๋ฐํํ๋ค")
+ void should_return_empty_list_when_no_schedules() {
+ // given
+ when(scheduleQueryRepository.findUpcomingSchedules(anyLong(), anyInt()))
+ .thenReturn(Collections.emptyList());
+
+ // when
+ GetScheduleListResponse response = getScheduleService.getUpcomingSchedules(STUDY_ID);
+
+ // then
+ assertThat(response.schedules()).isEmpty();
+ assertThat(response.totalCount()).isZero();
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java
index e80c1e9a..ef42f07b 100644
--- a/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java
+++ b/modules/study/src/test/java/kr/spot/schedule/domain/ScheduleTest.java
@@ -10,6 +10,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import java.time.LocalDateTime;
import kr.spot.code.status.ErrorStatus;
import kr.spot.exception.GeneralException;
import org.junit.jupiter.api.DisplayName;
@@ -89,4 +90,105 @@ void should_throw_exception_when_delete_with_different_study_id() {
.hasFieldOrPropertyWithValue("status", ErrorStatus._SCHEDULE_ACCESS_DENIED);
}
}
+
+ @Nested
+ @DisplayName("์งํ ์ค ์ฌ๋ถ ํ์ธ (isOngoing)")
+ class IsOngoing {
+
+ @Test
+ @DisplayName("ํ์ฌ ์๊ฐ์ด ์ผ์ ์๊ฐ ๋ฒ์ ๋ด์ ์์ผ๋ฉด true๋ฅผ ๋ฐํํ๋ค")
+ void should_return_true_when_now_is_within_schedule() {
+ // given
+ Schedule schedule = schedule();
+ LocalDateTime duringSchedule = START_AT.plusMinutes(30);
+
+ // when
+ boolean result = schedule.isOngoing(duringSchedule);
+
+ // then
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ @DisplayName("ํ์ฌ ์๊ฐ์ด ์ผ์ ์์ ์๊ฐ๊ณผ ๊ฐ์ผ๋ฉด true๋ฅผ ๋ฐํํ๋ค")
+ void should_return_true_when_now_equals_start_time() {
+ // given
+ Schedule schedule = schedule();
+
+ // when
+ boolean result = schedule.isOngoing(START_AT);
+
+ // then
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ @DisplayName("ํ์ฌ ์๊ฐ์ด ์ผ์ ์ข
๋ฃ ์๊ฐ๊ณผ ๊ฐ์ผ๋ฉด true๋ฅผ ๋ฐํํ๋ค")
+ void should_return_true_when_now_equals_end_time() {
+ // given
+ Schedule schedule = schedule();
+
+ // when
+ boolean result = schedule.isOngoing(END_AT);
+
+ // then
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ @DisplayName("ํ์ฌ ์๊ฐ์ด ์ผ์ ์์ ์ ์ด๋ฉด false๋ฅผ ๋ฐํํ๋ค")
+ void should_return_false_when_now_is_before_schedule() {
+ // given
+ Schedule schedule = schedule();
+ LocalDateTime beforeSchedule = START_AT.minusMinutes(1);
+
+ // when
+ boolean result = schedule.isOngoing(beforeSchedule);
+
+ // then
+ assertThat(result).isFalse();
+ }
+
+ @Test
+ @DisplayName("ํ์ฌ ์๊ฐ์ด ์ผ์ ์ข
๋ฃ ํ์ด๋ฉด false๋ฅผ ๋ฐํํ๋ค")
+ void should_return_false_when_now_is_after_schedule() {
+ // given
+ Schedule schedule = schedule();
+ LocalDateTime afterSchedule = END_AT.plusMinutes(1);
+
+ // when
+ boolean result = schedule.isOngoing(afterSchedule);
+
+ // then
+ assertThat(result).isFalse();
+ }
+
+ @Test
+ @DisplayName("์์ ์๊ฐ์ด null์ด๋ฉด false๋ฅผ ๋ฐํํ๋ค")
+ void should_return_false_when_start_time_is_null() {
+ // given
+ Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, null, END_AT);
+ LocalDateTime now = LocalDateTime.now();
+
+ // when
+ boolean result = schedule.isOngoing(now);
+
+ // then
+ assertThat(result).isFalse();
+ }
+
+ @Test
+ @DisplayName("์ข
๋ฃ ์๊ฐ์ด null์ด๋ฉด false๋ฅผ ๋ฐํํ๋ค")
+ void should_return_false_when_end_time_is_null() {
+ // given
+ Schedule schedule = Schedule.of(ID, STUDY_ID, TITLE, LOCATION_MEMO, START_AT, null);
+ LocalDateTime now = LocalDateTime.now();
+
+ // when
+ boolean result = schedule.isOngoing(now);
+
+ // then
+ assertThat(result).isFalse();
+ }
+ }
}
From 9f3bd82de5c634fe95aea81e0337abb0e797146c Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 22:15:10 +0900
Subject: [PATCH 010/126] =?UTF-8?q?[Refactor]=20=EC=8A=A4=ED=84=B0?=
=?UTF-8?q?=EB=94=94=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?=
=?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84?=
=?UTF-8?q?=EB=A6=AC=20-=20study=20=ED=95=98=EC=9C=84=20=ED=8C=A8=ED=82=A4?=
=?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20-=20=EB=8B=A4?=
=?UTF-8?q?=EB=A5=B8=20=ED=8C=A8=ED=82=A4=EC=A7=80=EC=99=80=20=EA=B5=AC?=
=?UTF-8?q?=EB=B6=84=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=9D=B4=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ApplyStudyServiceTest.java | 17 ++++++-------
.../command/CreateStudyServiceTest.java | 3 +--
.../event/StudyCreatedEventTest.java | 3 +--
.../event/StudyImageUploadListenerTest.java | 18 ++++++-------
.../query/GetMyStudyInfoServiceTest.java | 5 ++--
.../query/GetStudyApplicationServiceTest.java | 11 ++++----
.../common/ApplyStudyRequestFixture.java | 2 +-
.../common/MemberInfoResponseFixture.java | 2 +-
.../common/StudyApplicationInfoFixture.java | 2 +-
.../spot/{ => study}/common/StudyFixture.java | 2 +-
.../common/StudyMemberFixture.java | 2 +-
.../{ => study}/domain/StudyMemberTest.java | 18 ++++++-------
.../kr/spot/{ => study}/domain/StudyTest.java | 25 +++++++++----------
.../spot/{ => study}/domain/vo/FeeTest.java | 3 +--
14 files changed, 52 insertions(+), 61 deletions(-)
rename modules/study/src/test/java/kr/spot/{ => study}/application/command/ApplyStudyServiceTest.java (95%)
rename modules/study/src/test/java/kr/spot/{ => study}/application/command/CreateStudyServiceTest.java (98%)
rename modules/study/src/test/java/kr/spot/{ => study}/application/event/StudyCreatedEventTest.java (95%)
rename modules/study/src/test/java/kr/spot/{ => study}/application/event/StudyImageUploadListenerTest.java (89%)
rename modules/study/src/test/java/kr/spot/{ => study}/application/query/GetMyStudyInfoServiceTest.java (98%)
rename modules/study/src/test/java/kr/spot/{ => study}/application/query/GetStudyApplicationServiceTest.java (95%)
rename modules/study/src/test/java/kr/spot/{ => study}/common/ApplyStudyRequestFixture.java (93%)
rename modules/study/src/test/java/kr/spot/{ => study}/common/MemberInfoResponseFixture.java (96%)
rename modules/study/src/test/java/kr/spot/{ => study}/common/StudyApplicationInfoFixture.java (97%)
rename modules/study/src/test/java/kr/spot/{ => study}/common/StudyFixture.java (97%)
rename modules/study/src/test/java/kr/spot/{ => study}/common/StudyMemberFixture.java (97%)
rename modules/study/src/test/java/kr/spot/{ => study}/domain/StudyMemberTest.java (91%)
rename modules/study/src/test/java/kr/spot/{ => study}/domain/StudyTest.java (93%)
rename modules/study/src/test/java/kr/spot/{ => study}/domain/vo/FeeTest.java (96%)
diff --git a/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java
similarity index 95%
rename from modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java
rename to modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java
index aaf9e725..6f1d3fde 100644
--- a/modules/study/src/test/java/kr/spot/application/command/ApplyStudyServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/study/application/command/ApplyStudyServiceTest.java
@@ -1,9 +1,9 @@
-package kr.spot.application.command;
+package kr.spot.study.application.command;
-import static kr.spot.common.ApplyStudyRequestFixture.create;
-import static kr.spot.common.StudyFixture.study;
-import static kr.spot.common.StudyMemberFixture.applied;
-import static kr.spot.common.StudyMemberFixture.awaitingSelfApproval;
+import static kr.spot.study.common.ApplyStudyRequestFixture.create;
+import static kr.spot.study.common.StudyFixture.study;
+import static kr.spot.study.common.StudyMemberFixture.applied;
+import static kr.spot.study.common.StudyMemberFixture.awaitingSelfApproval;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@@ -15,11 +15,10 @@
import java.util.List;
import kr.spot.IdGenerator;
import kr.spot.code.status.ErrorStatus;
-import kr.spot.common.ApplyStudyRequestFixture;
-import kr.spot.common.StudyFixture;
-import kr.spot.common.StudyMemberFixture;
import kr.spot.exception.GeneralException;
-import kr.spot.study.application.command.ApplyStudyService;
+import kr.spot.study.common.ApplyStudyRequestFixture;
+import kr.spot.study.common.StudyFixture;
+import kr.spot.study.common.StudyMemberFixture;
import kr.spot.study.domain.Study;
import kr.spot.study.domain.associations.StudyMember;
import kr.spot.study.domain.enums.Decision;
diff --git a/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/command/CreateStudyServiceTest.java
similarity index 98%
rename from modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java
rename to modules/study/src/test/java/kr/spot/study/application/command/CreateStudyServiceTest.java
index 507010b3..351f4339 100644
--- a/modules/study/src/test/java/kr/spot/application/command/CreateStudyServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/study/application/command/CreateStudyServiceTest.java
@@ -1,4 +1,4 @@
-package kr.spot.application.command;
+package kr.spot.study.application.command;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -7,7 +7,6 @@
import java.util.Set;
import kr.spot.IdGenerator;
-import kr.spot.study.application.command.CreateStudyService;
import kr.spot.study.application.event.StudyCreatedEvent;
import kr.spot.study.domain.Study;
import kr.spot.study.domain.enums.Category;
diff --git a/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java b/modules/study/src/test/java/kr/spot/study/application/event/StudyCreatedEventTest.java
similarity index 95%
rename from modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java
rename to modules/study/src/test/java/kr/spot/study/application/event/StudyCreatedEventTest.java
index 5f658d9c..38eb4144 100644
--- a/modules/study/src/test/java/kr/spot/application/event/StudyCreatedEventTest.java
+++ b/modules/study/src/test/java/kr/spot/study/application/event/StudyCreatedEventTest.java
@@ -1,8 +1,7 @@
-package kr.spot.application.event;
+package kr.spot.study.application.event;
import static org.assertj.core.api.Assertions.assertThat;
-import kr.spot.study.application.event.StudyCreatedEvent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
diff --git a/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java b/modules/study/src/test/java/kr/spot/study/application/event/StudyImageUploadListenerTest.java
similarity index 89%
rename from modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java
rename to modules/study/src/test/java/kr/spot/study/application/event/StudyImageUploadListenerTest.java
index 3bca88c5..6ef447a7 100644
--- a/modules/study/src/test/java/kr/spot/application/event/StudyImageUploadListenerTest.java
+++ b/modules/study/src/test/java/kr/spot/study/application/event/StudyImageUploadListenerTest.java
@@ -1,11 +1,11 @@
-package kr.spot.application.event;
-
-import static kr.spot.common.StudyFixture.DESCRIPTION;
-import static kr.spot.common.StudyFixture.FEE_AMOUNT;
-import static kr.spot.common.StudyFixture.HAS_FEE;
-import static kr.spot.common.StudyFixture.LEADER_ID;
-import static kr.spot.common.StudyFixture.MAX_MEMBERS;
-import static kr.spot.common.StudyFixture.NAME;
+package kr.spot.study.application.event;
+
+import static kr.spot.study.common.StudyFixture.DESCRIPTION;
+import static kr.spot.study.common.StudyFixture.FEE_AMOUNT;
+import static kr.spot.study.common.StudyFixture.HAS_FEE;
+import static kr.spot.study.common.StudyFixture.LEADER_ID;
+import static kr.spot.study.common.StudyFixture.MAX_MEMBERS;
+import static kr.spot.study.common.StudyFixture.NAME;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
@@ -15,8 +15,6 @@
import kr.spot.ports.FileStoragePort;
import kr.spot.ports.dto.UploadResult;
-import kr.spot.study.application.event.StudyCreatedEvent;
-import kr.spot.study.application.event.StudyImageUploadListener;
import kr.spot.study.domain.Study;
import kr.spot.study.domain.vo.Fee;
import kr.spot.study.infrastructure.jpa.StudyRepository;
diff --git a/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java
similarity index 98%
rename from modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java
rename to modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java
index 8927a8aa..3c8ea97d 100644
--- a/modules/study/src/test/java/kr/spot/application/query/GetMyStudyInfoServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/study/application/query/GetMyStudyInfoServiceTest.java
@@ -1,6 +1,6 @@
-package kr.spot.application.query;
+package kr.spot.study.application.query;
-import static kr.spot.common.StudyFixture.createStudies;
+import static kr.spot.study.common.StudyFixture.createStudies;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -12,7 +12,6 @@
import java.util.List;
import kr.spot.ports.GetPreferredCategoryPort;
import kr.spot.ports.GetPreferredRegionPort;
-import kr.spot.study.application.query.GetMyStudyInfoService;
import kr.spot.study.domain.Study;
import kr.spot.study.domain.enums.Category;
import kr.spot.study.domain.enums.FeeCategory;
diff --git a/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/query/GetStudyApplicationServiceTest.java
similarity index 95%
rename from modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java
rename to modules/study/src/test/java/kr/spot/study/application/query/GetStudyApplicationServiceTest.java
index 6fd4382f..76a857de 100644
--- a/modules/study/src/test/java/kr/spot/application/query/GetStudyApplicationServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/study/application/query/GetStudyApplicationServiceTest.java
@@ -1,8 +1,8 @@
-package kr.spot.application.query;
+package kr.spot.study.application.query;
-import static kr.spot.common.MemberInfoResponseFixture.createMap;
-import static kr.spot.common.StudyApplicationInfoFixture.createList;
-import static kr.spot.common.StudyMemberFixture.applied;
+import static kr.spot.study.common.MemberInfoResponseFixture.createMap;
+import static kr.spot.study.common.StudyApplicationInfoFixture.createList;
+import static kr.spot.study.common.StudyMemberFixture.applied;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.willThrow;
@@ -12,12 +12,11 @@
import java.util.List;
import java.util.Map;
import kr.spot.code.status.ErrorStatus;
-import kr.spot.common.StudyMemberFixture;
import kr.spot.exception.GeneralException;
import kr.spot.ports.GetMemberInfoPort;
import kr.spot.ports.dto.MemberInfoResponse;
-import kr.spot.study.application.query.GetStudyApplicationService;
import kr.spot.study.application.validator.StudyAccessValidator;
+import kr.spot.study.common.StudyMemberFixture;
import kr.spot.study.domain.associations.StudyMember;
import kr.spot.study.domain.enums.StudyMemberStatus;
import kr.spot.study.infrastructure.jpa.StudyMemberRepositoryCustom;
diff --git a/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java b/modules/study/src/test/java/kr/spot/study/common/ApplyStudyRequestFixture.java
similarity index 93%
rename from modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java
rename to modules/study/src/test/java/kr/spot/study/common/ApplyStudyRequestFixture.java
index 626e015c..4f12ba8b 100644
--- a/modules/study/src/test/java/kr/spot/common/ApplyStudyRequestFixture.java
+++ b/modules/study/src/test/java/kr/spot/study/common/ApplyStudyRequestFixture.java
@@ -1,4 +1,4 @@
-package kr.spot.common;
+package kr.spot.study.common;
import kr.spot.study.presentation.command.dto.request.ApplyStudyRequest;
diff --git a/modules/study/src/test/java/kr/spot/common/MemberInfoResponseFixture.java b/modules/study/src/test/java/kr/spot/study/common/MemberInfoResponseFixture.java
similarity index 96%
rename from modules/study/src/test/java/kr/spot/common/MemberInfoResponseFixture.java
rename to modules/study/src/test/java/kr/spot/study/common/MemberInfoResponseFixture.java
index b0af0894..d86761bc 100644
--- a/modules/study/src/test/java/kr/spot/common/MemberInfoResponseFixture.java
+++ b/modules/study/src/test/java/kr/spot/study/common/MemberInfoResponseFixture.java
@@ -1,4 +1,4 @@
-package kr.spot.common;
+package kr.spot.study.common;
import java.util.List;
import java.util.Map;
diff --git a/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java b/modules/study/src/test/java/kr/spot/study/common/StudyApplicationInfoFixture.java
similarity index 97%
rename from modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java
rename to modules/study/src/test/java/kr/spot/study/common/StudyApplicationInfoFixture.java
index 175465a7..dd0e5b60 100644
--- a/modules/study/src/test/java/kr/spot/common/StudyApplicationInfoFixture.java
+++ b/modules/study/src/test/java/kr/spot/study/common/StudyApplicationInfoFixture.java
@@ -1,4 +1,4 @@
-package kr.spot.common;
+package kr.spot.study.common;
import java.util.List;
import java.util.stream.LongStream;
diff --git a/modules/study/src/test/java/kr/spot/common/StudyFixture.java b/modules/study/src/test/java/kr/spot/study/common/StudyFixture.java
similarity index 97%
rename from modules/study/src/test/java/kr/spot/common/StudyFixture.java
rename to modules/study/src/test/java/kr/spot/study/common/StudyFixture.java
index ae2f42fc..c00b6d5a 100644
--- a/modules/study/src/test/java/kr/spot/common/StudyFixture.java
+++ b/modules/study/src/test/java/kr/spot/study/common/StudyFixture.java
@@ -1,4 +1,4 @@
-package kr.spot.common;
+package kr.spot.study.common;
import java.util.List;
import java.util.stream.LongStream;
diff --git a/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java b/modules/study/src/test/java/kr/spot/study/common/StudyMemberFixture.java
similarity index 97%
rename from modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java
rename to modules/study/src/test/java/kr/spot/study/common/StudyMemberFixture.java
index 02bddc82..aa28d858 100644
--- a/modules/study/src/test/java/kr/spot/common/StudyMemberFixture.java
+++ b/modules/study/src/test/java/kr/spot/study/common/StudyMemberFixture.java
@@ -1,4 +1,4 @@
-package kr.spot.common;
+package kr.spot.study.common;
import kr.spot.study.domain.associations.StudyMember;
import kr.spot.study.domain.enums.Decision;
diff --git a/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java b/modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java
similarity index 91%
rename from modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java
rename to modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java
index fddbf648..33ad9fb3 100644
--- a/modules/study/src/test/java/kr/spot/domain/StudyMemberTest.java
+++ b/modules/study/src/test/java/kr/spot/study/domain/StudyMemberTest.java
@@ -1,12 +1,12 @@
-package kr.spot.domain;
-
-import static kr.spot.common.StudyMemberFixture.ID;
-import static kr.spot.common.StudyMemberFixture.MEMBER_ID;
-import static kr.spot.common.StudyMemberFixture.MESSAGE;
-import static kr.spot.common.StudyMemberFixture.STUDY_ID;
-import static kr.spot.common.StudyMemberFixture.applied;
-import static kr.spot.common.StudyMemberFixture.awaitingSelfApproval;
-import static kr.spot.common.StudyMemberFixture.owner;
+package kr.spot.study.domain;
+
+import static kr.spot.study.common.StudyMemberFixture.ID;
+import static kr.spot.study.common.StudyMemberFixture.MEMBER_ID;
+import static kr.spot.study.common.StudyMemberFixture.MESSAGE;
+import static kr.spot.study.common.StudyMemberFixture.STUDY_ID;
+import static kr.spot.study.common.StudyMemberFixture.applied;
+import static kr.spot.study.common.StudyMemberFixture.awaitingSelfApproval;
+import static kr.spot.study.common.StudyMemberFixture.owner;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
diff --git a/modules/study/src/test/java/kr/spot/domain/StudyTest.java b/modules/study/src/test/java/kr/spot/study/domain/StudyTest.java
similarity index 93%
rename from modules/study/src/test/java/kr/spot/domain/StudyTest.java
rename to modules/study/src/test/java/kr/spot/study/domain/StudyTest.java
index 82792662..f2a33bcf 100644
--- a/modules/study/src/test/java/kr/spot/domain/StudyTest.java
+++ b/modules/study/src/test/java/kr/spot/study/domain/StudyTest.java
@@ -1,21 +1,20 @@
-package kr.spot.domain;
-
-import static kr.spot.common.StudyFixture.DESCRIPTION;
-import static kr.spot.common.StudyFixture.FEE_AMOUNT;
-import static kr.spot.common.StudyFixture.HAS_FEE;
-import static kr.spot.common.StudyFixture.ID;
-import static kr.spot.common.StudyFixture.IMAGE_URL;
-import static kr.spot.common.StudyFixture.LEADER_ID;
-import static kr.spot.common.StudyFixture.MAX_MEMBERS;
-import static kr.spot.common.StudyFixture.NAME;
-import static kr.spot.common.StudyFixture.study;
-import static kr.spot.common.StudyMemberFixture.applied;
+package kr.spot.study.domain;
+
+import static kr.spot.study.common.StudyFixture.DESCRIPTION;
+import static kr.spot.study.common.StudyFixture.FEE_AMOUNT;
+import static kr.spot.study.common.StudyFixture.HAS_FEE;
+import static kr.spot.study.common.StudyFixture.ID;
+import static kr.spot.study.common.StudyFixture.IMAGE_URL;
+import static kr.spot.study.common.StudyFixture.LEADER_ID;
+import static kr.spot.study.common.StudyFixture.MAX_MEMBERS;
+import static kr.spot.study.common.StudyFixture.NAME;
+import static kr.spot.study.common.StudyFixture.study;
+import static kr.spot.study.common.StudyMemberFixture.applied;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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.Decision;
import kr.spot.study.domain.enums.RecruitingStatus;
diff --git a/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java b/modules/study/src/test/java/kr/spot/study/domain/vo/FeeTest.java
similarity index 96%
rename from modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java
rename to modules/study/src/test/java/kr/spot/study/domain/vo/FeeTest.java
index 1538e08a..b5243747 100644
--- a/modules/study/src/test/java/kr/spot/domain/vo/FeeTest.java
+++ b/modules/study/src/test/java/kr/spot/study/domain/vo/FeeTest.java
@@ -1,11 +1,10 @@
-package kr.spot.domain.vo;
+package kr.spot.study.domain.vo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import kr.spot.code.status.ErrorStatus;
import kr.spot.exception.GeneralException;
-import kr.spot.study.domain.vo.Fee;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
From 0ddba922ac859bf69e8a66cb097fbe16014d12a4 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 22:23:52 +0900
Subject: [PATCH 011/126] =?UTF-8?q?[Refactor]=20=EC=A7=80=EC=97=AD=20?=
=?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B4=80=EB=A0=A8=20=ED=81=B4?=
=?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/kr/spot/study/domain/Region.java | 34 -------------------
.../infrastructure/jpa/RegionRepository.java | 8 -----
2 files changed, 42 deletions(-)
delete mode 100644 modules/study/src/main/java/kr/spot/study/domain/Region.java
delete mode 100644 modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java
diff --git a/modules/study/src/main/java/kr/spot/study/domain/Region.java b/modules/study/src/main/java/kr/spot/study/domain/Region.java
deleted file mode 100644
index 5333852c..00000000
--- a/modules/study/src/main/java/kr/spot/study/domain/Region.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package kr.spot.study.domain;
-
-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 region SET status = 'INACTIVE' WHERE id = ?")
-@SQLRestriction("status = 'ACTIVE'")
-@NoArgsConstructor(access = AccessLevel.PROTECTED)
-@AllArgsConstructor(access = AccessLevel.PRIVATE)
-public class Region extends BaseEntity {
-
- @Id
- private String code;
-
- private String province;
-
- private String district;
-
- private String neighborhood;
-
- public static Region of(String code, String province, String district, String neighborhood) {
- return new Region(code, province, district, neighborhood);
- }
-}
diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java
deleted file mode 100644
index fa6e2772..00000000
--- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/RegionRepository.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package kr.spot.study.infrastructure.jpa;
-
-import kr.spot.study.domain.Region;
-import org.springframework.data.jpa.repository.JpaRepository;
-
-public interface RegionRepository extends JpaRepository {
-
-}
From ab253c7fb8eb57d21ddbdacb206cf886a0ead33e Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 22:24:24 +0900
Subject: [PATCH 012/126] =?UTF-8?q?[Refactor]=20=EC=8B=9C=EA=B0=84?=
=?UTF-8?q?=EB=8C=80=20=EC=9E=85=EB=A0=A5=20=ED=8F=AC=EB=A7=B7=20=EA=B4=80?=
=?UTF-8?q?=EB=A0=A8=20=EB=AA=85=EC=84=B8=20=EC=B6=94=EA=B0=80=20=EC=9E=91?=
=?UTF-8?q?=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../schedule/presentation/query/ScheduleQueryController.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
index 7861eef5..ac9c5376 100644
--- 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
@@ -51,7 +51,7 @@ public ResponseEntity> getMonthlySchedules(
@GetMapping("/weekly")
public ResponseEntity> getWeeklySchedules(
@Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
- @Parameter(description = "์กฐํ ๊ธฐ์ค ๋ ์ง (ํด๋น ์ฃผ ์ ์ฒด ์กฐํ)", example = "2025-01-15")
+ @Parameter(description = "์กฐํ ๊ธฐ์ค ๋ ์ง (ํด๋น ์ฃผ ์ ์ฒด ์กฐํ), ISO 8601 ํ์ค ๋ฐฉ์์ผ๋ก ์
๋ ฅํด์ฃผ์ธ์. ", example = "2025-01-15")
@RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate date) {
GetScheduleListResponse response = getScheduleService.getWeeklySchedules(studyId, date);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response));
From 7f2fd0d4f00efc6c74a2118286b46c2fedfb4aed Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 22:51:30 +0900
Subject: [PATCH 013/126] =?UTF-8?q?[Refactor]=20=EC=A1=B0=ED=9A=8C?=
=?UTF-8?q?=EC=88=98=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20=EA=B3=B5=ED=86=B5=20?=
=?UTF-8?q?=EB=AA=A8=EB=93=88=EB=A1=9C=20=EC=B6=94=EC=83=81=ED=99=94=20=20?=
=?UTF-8?q?=20-=20common/view=20=EB=AA=A8=EB=93=88=20=EC=83=9D=EC=84=B1=20?=
=?UTF-8?q?(ViewableType,=20ViewAbuseGuard,=20ViewCounter)=20=20=20-=20POS?=
=?UTF-8?q?T,=20STUDY,=20STUDY=5FBOARD=20=ED=83=80=EC=9E=85=20=EC=A7=80?=
=?UTF-8?q?=EC=9B=90=20=20=20-=20Redis=20=EA=B5=AC=ED=98=84=EC=B2=B4=20?=
=?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20(RedisViewAbuseGuard,=20RedisView?=
=?UTF-8?q?Counter)=20=20=20-=20Post=20=EB=AA=A8=EB=93=88=20=EC=83=88=20?=
=?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=81?=
=?UTF-8?q?=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
common/view/build.gradle | 2 +
.../java/kr/spot/view/ViewAbuseGuard.java | 6 +++
.../main/java/kr/spot/view/ViewCounter.java | 8 +++
.../main/java/kr/spot/view/ViewableType.java | 15 ++++++
modules/post/build.gradle | 1 +
.../application/ports/PostViewCounter.java | 8 ---
.../application/ports/ViewAbuseGuard.java | 6 ---
.../application/query/GetPostService.java | 13 ++---
.../infrastructure/rdb/DbViewAbuseGuard.java | 20 ++++---
.../redis/RedisPostViewCounter.java | 31 -----------
.../redis/RedisViewAbuseGuard.java | 17 +++---
.../redis/RedisViewCounter.java | 32 +++++++++++
.../application/query/GetPostServiceTest.java | 43 +++++++--------
.../redis/RedisViewAbuseGuardTest.java | 37 +++++++++----
...terTest.java => RedisViewCounterTest.java} | 54 ++++++++++++-------
settings.gradle | 1 +
16 files changed, 181 insertions(+), 113 deletions(-)
create mode 100644 common/view/build.gradle
create mode 100644 common/view/src/main/java/kr/spot/view/ViewAbuseGuard.java
create mode 100644 common/view/src/main/java/kr/spot/view/ViewCounter.java
create mode 100644 common/view/src/main/java/kr/spot/view/ViewableType.java
delete mode 100644 modules/post/src/main/java/kr/spot/application/ports/PostViewCounter.java
delete mode 100644 modules/post/src/main/java/kr/spot/application/ports/ViewAbuseGuard.java
delete mode 100644 modules/post/src/main/java/kr/spot/infrastructure/redis/RedisPostViewCounter.java
create mode 100644 modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java
rename modules/post/src/test/java/kr/spot/infrastructure/redis/{RedisPostViewHistoryCounterTest.java => RedisViewCounterTest.java} (51%)
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/modules/post/build.gradle b/modules/post/build.gradle
index 94311469..0a955723 100644
--- a/modules/post/build.gradle
+++ b/modules/post/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:member-api")
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/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/infrastructure/redis/RedisViewAbuseGuard.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java
index 8a115d65..8c1d0140 100644
--- a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java
+++ b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java
@@ -1,7 +1,8 @@
package kr.spot.infrastructure.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/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java
new file mode 100644
index 00000000..b50e6c6b
--- /dev/null
+++ b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java
@@ -0,0 +1,32 @@
+package kr.spot.infrastructure.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/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/infrastructure/redis/RedisViewAbuseGuardTest.java b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java
index 98665708..d5a896f9 100644
--- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java
+++ b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java
@@ -4,7 +4,8 @@
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/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java
similarity index 51%
rename from modules/post/src/test/java/kr/spot/infrastructure/redis/RedisPostViewHistoryCounterTest.java
rename to modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java
index bb79ba2e..2df4a232 100644
--- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisPostViewHistoryCounterTest.java
+++ b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java
@@ -3,7 +3,8 @@
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/settings.gradle b/settings.gradle
index d804bddb..b9948059 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -6,6 +6,7 @@ include 'app:config'
include 'common:snowflake'
include 'common:api'
include 'common:bucket'
+include 'common:view'
include 'modules:shared'
include 'modules:member'
From a8e8978326e7cab36823279d99f420ea87c849d9 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 23:00:28 +0900
Subject: [PATCH 014/126] =?UTF-8?q?[Refactor]=20Redis=20=EC=A1=B0=ED=9A=8C?=
=?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84=EC=B2=B4=EB=A5=BC=20infra/view-r?=
=?UTF-8?q?edis=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=20?=
=?UTF-8?q?=20-=20infra/view-redis=20=EB=AA=A8=EB=93=88=20=EC=83=9D?=
=?UTF-8?q?=EC=84=B1=20=20=20-=20RedisViewAbuseGuard,=20RedisViewCounter?=
=?UTF-8?q?=EB=A5=BC=20post=20=EB=AA=A8=EB=93=88=EC=97=90=EC=84=9C=20?=
=?UTF-8?q?=EC=9D=B4=EB=8F=99=20=20=20-=20post=20=EB=AA=A8=EB=93=88?=
=?UTF-8?q?=EC=9D=B4=20infra:view-redis=20=EC=9D=98=EC=A1=B4=ED=95=98?=
=?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20=20=20-=20study=20?=
=?UTF-8?q?=EB=AA=A8=EB=93=88=EC=97=90=EC=84=9C=EB=8F=84=20=EC=9E=AC?=
=?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?=
=?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
modules/post/build.gradle | 1 +
.../redis/RedisViewAbuseGuard.java | 30 -----
.../redis/RedisViewCounter.java | 32 -----
.../redis/RedisViewAbuseGuardTest.java | 87 --------------
.../redis/RedisViewCounterTest.java | 109 ------------------
settings.gradle | 1 +
6 files changed, 2 insertions(+), 258 deletions(-)
delete mode 100644 modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java
delete mode 100644 modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java
delete mode 100644 modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java
delete mode 100644 modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java
diff --git a/modules/post/build.gradle b/modules/post/build.gradle
index 0a955723..f2d9ac25 100644
--- a/modules/post/build.gradle
+++ b/modules/post/build.gradle
@@ -9,6 +9,7 @@ dependencies {
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/infrastructure/redis/RedisViewAbuseGuard.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java
deleted file mode 100644
index 8c1d0140..00000000
--- a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewAbuseGuard.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package kr.spot.infrastructure.redis;
-
-import java.time.Duration;
-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;
-
-@Component
-@RequiredArgsConstructor
-public class RedisViewAbuseGuard implements ViewAbuseGuard {
-
- 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(ViewableType type, long targetId, long viewerId) {
- return GUARD_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId, viewerId);
- }
-
- @Override
- 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/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java b/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java
deleted file mode 100644
index b50e6c6b..00000000
--- a/modules/post/src/main/java/kr/spot/infrastructure/redis/RedisViewCounter.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package kr.spot.infrastructure.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/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java
deleted file mode 100644
index d5a896f9..00000000
--- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewAbuseGuardTest.java
+++ /dev/null
@@ -1,87 +0,0 @@
-package kr.spot.infrastructure.redis;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.when;
-
-import java.time.Duration;
-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;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.data.redis.core.ValueOperations;
-
-class RedisViewAbuseGuardTest {
-
- private static final Duration TIMEOUT = Duration.ofMinutes(10);
- private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d";
-
- @Mock
- StringRedisTemplate redis;
-
- @Mock
- ValueOperations valueOps;
-
- ViewAbuseGuard guard;
-
- @BeforeEach
- void setUp() {
- MockitoAnnotations.openMocks(this);
- when(redis.opsForValue()).thenReturn(valueOps);
- guard = new RedisViewAbuseGuard(redis);
- }
-
- @Test
- @DisplayName("์ด์ ์ ์กฐํํ ์ ์๋ ์ฌ์ฉ์๋ true๋ฅผ ๋ฐํํ๋ค.")
- void should_return_true_for_new_viewer() {
- // given
- long targetId = 1L;
- long viewerId = 2L;
- String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId);
-
- when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true);
-
- // when
- boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId);
-
- // then
- assertThat(result).isTrue();
- }
-
- @Test
- @DisplayName("์ด์ ์ ์กฐํํ ์ ์๋ ์ฌ์ฉ์๋ false๋ฅผ ๋ฐํํ๋ค.")
- void should_return_false_for_existing_viewer() {
- // given
- long targetId = 1L;
- long viewerId = 2L;
- String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId);
-
- when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(false);
-
- // when
- 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/RedisViewCounterTest.java b/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java
deleted file mode 100644
index 2df4a232..00000000
--- a/modules/post/src/test/java/kr/spot/infrastructure/redis/RedisViewCounterTest.java
+++ /dev/null
@@ -1,109 +0,0 @@
-package kr.spot.infrastructure.redis;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.when;
-
-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;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.data.redis.core.ValueOperations;
-
-class RedisViewCounterTest {
-
- private static final String DELTA_KEY_FORMAT = "view:%s:%d";
-
- @Mock
- StringRedisTemplate redis;
-
- @Mock
- ValueOperations valueOps;
-
- ViewCounter counter;
-
- @BeforeEach
- void setUp() {
- MockitoAnnotations.openMocks(this);
- when(redis.opsForValue()).thenReturn(valueOps);
- counter = new RedisViewCounter(redis);
- }
-
- @Test
- @DisplayName("์กฐํ์ ๋ธํ๋ฅผ ์ฆ๊ฐ์ํค๊ณ , ์ฆ๊ฐ๋ ๊ฐ์ ๋ฐํํ๋ค.")
- void should_increment_and_get_delta() {
- // given
- long targetId = 1L;
- String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId);
- when(valueOps.increment(key)).thenReturn(5L);
-
- // when
- long delta = counter.incrementAndGet(ViewableType.POST, targetId);
-
- // then
- assertThat(delta).isEqualTo(5L);
- }
-
- @Test
- @DisplayName("๋ธํ ๊ฐ ์ฆ๊ฐ ์, ํค์ ํด๋นํ๋ ๊ฐ์ด ์์ผ๋ฉด 0์ ๋ฐํํ๋ค.")
- void should_return_0_if_no_value() {
- // given
- long targetId = 1L;
- String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId);
- when(valueOps.increment(key)).thenReturn(null);
-
- // when
- long delta = counter.incrementAndGet(ViewableType.POST, targetId);
-
- // then
- assertThat(delta).isEqualTo(0L);
- }
-
- @Test
- @DisplayName("ํค์ ํด๋นํ๋ ๊ฐ์ long ํ์
์ผ๋ก ๋ณํํ์ฌ ๋ฐํํ๋ค.")
- void should_return_current_delta() {
- // given
- long targetId = 1L;
- String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId);
- when(valueOps.get(key)).thenReturn("10");
-
- // when
- long delta = counter.currentDelta(ViewableType.POST, targetId);
-
- // then
- assertThat(delta).isEqualTo(10L);
- }
-
- @Test
- @DisplayName("ํ์ฌ ๋ธํ ๊ฐ ์กฐํ ์, ํค์ ํด๋นํ๋ ๊ฐ์ด ์์ผ๋ฉด 0์ ๋ฐํํ๋ค.")
- void should_return_0_if_no_current_value() {
- // given
- long targetId = 1L;
- String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId);
- when(valueOps.get(key)).thenReturn(null);
-
- // when
- 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/settings.gradle b/settings.gradle
index b9948059..4b247681 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -22,3 +22,4 @@ include 'modules:study-api'
include 'modules:region-api'
include 'infra:bucket-s3'
+include 'infra:view-redis'
From c0d1a1dcdd163ad86f33f4c1581985fa378bd34a Mon Sep 17 00:00:00 2001
From: msk226
Date: Sat, 27 Dec 2025 23:00:32 +0900
Subject: [PATCH 015/126] =?UTF-8?q?[Refactor]=20Redis=20=EC=A1=B0=ED=9A=8C?=
=?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84=EC=B2=B4=EB=A5=BC=20infra/view-r?=
=?UTF-8?q?edis=20=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=20?=
=?UTF-8?q?=20-=20infra/view-redis=20=EB=AA=A8=EB=93=88=20=EC=83=9D?=
=?UTF-8?q?=EC=84=B1=20=20=20-=20RedisViewAbuseGuard,=20RedisViewCounter?=
=?UTF-8?q?=EB=A5=BC=20post=20=EB=AA=A8=EB=93=88=EC=97=90=EC=84=9C=20?=
=?UTF-8?q?=EC=9D=B4=EB=8F=99=20=20=20-=20post=20=EB=AA=A8=EB=93=88?=
=?UTF-8?q?=EC=9D=B4=20infra:view-redis=20=EC=9D=98=EC=A1=B4=ED=95=98?=
=?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20=20=20-=20study=20?=
=?UTF-8?q?=EB=AA=A8=EB=93=88=EC=97=90=EC=84=9C=EB=8F=84=20=EC=9E=AC?=
=?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?=
=?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
infra/view-redis/build.gradle | 15 +++
.../spot/view/redis/RedisViewAbuseGuard.java | 30 +++++
.../kr/spot/view/redis/RedisViewCounter.java | 32 +++++
.../view/redis/RedisViewAbuseGuardTest.java | 87 ++++++++++++++
.../spot/view/redis/RedisViewCounterTest.java | 109 ++++++++++++++++++
5 files changed, 273 insertions(+)
create mode 100644 infra/view-redis/build.gradle
create mode 100644 infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java
create mode 100644 infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewCounter.java
create mode 100644 infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java
create mode 100644 infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java
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/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java
new file mode 100644
index 00000000..3d075cdb
--- /dev/null
+++ b/infra/view-redis/src/main/java/kr/spot/view/redis/RedisViewAbuseGuard.java
@@ -0,0 +1,30 @@
+package kr.spot.view.redis;
+
+import java.time.Duration;
+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;
+
+@Component
+@RequiredArgsConstructor
+public class RedisViewAbuseGuard implements ViewAbuseGuard {
+
+ 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(ViewableType type, long targetId, long viewerId) {
+ return GUARD_KEY_FORMAT.formatted(type.getKeyPrefix(), targetId, viewerId);
+ }
+
+ @Override
+ 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/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java
new file mode 100644
index 00000000..35a90a67
--- /dev/null
+++ b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewAbuseGuardTest.java
@@ -0,0 +1,87 @@
+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.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;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+
+class RedisViewAbuseGuardTest {
+
+ private static final Duration TIMEOUT = Duration.ofMinutes(10);
+ private static final String GUARD_KEY_FORMAT = "view:guard:%s:%d:%d";
+
+ @Mock
+ StringRedisTemplate redis;
+
+ @Mock
+ ValueOperations valueOps;
+
+ ViewAbuseGuard guard;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ when(redis.opsForValue()).thenReturn(valueOps);
+ guard = new RedisViewAbuseGuard(redis);
+ }
+
+ @Test
+ @DisplayName("์ด์ ์ ์กฐํํ ์ ์๋ ์ฌ์ฉ์๋ true๋ฅผ ๋ฐํํ๋ค.")
+ void should_return_true_for_new_viewer() {
+ // given
+ long targetId = 1L;
+ long viewerId = 2L;
+ String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId);
+
+ when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(true);
+
+ // when
+ boolean result = guard.shouldCount(ViewableType.POST, targetId, viewerId);
+
+ // then
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ @DisplayName("์ด์ ์ ์กฐํํ ์ ์๋ ์ฌ์ฉ์๋ false๋ฅผ ๋ฐํํ๋ค.")
+ void should_return_false_for_existing_viewer() {
+ // given
+ long targetId = 1L;
+ long viewerId = 2L;
+ String key = GUARD_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId, viewerId);
+
+ when(valueOps.setIfAbsent(key, "1", TIMEOUT)).thenReturn(false);
+
+ // when
+ 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/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java
new file mode 100644
index 00000000..57335230
--- /dev/null
+++ b/infra/view-redis/src/test/java/kr/spot/view/redis/RedisViewCounterTest.java
@@ -0,0 +1,109 @@
+package kr.spot.view.redis;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+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;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+
+class RedisViewCounterTest {
+
+ private static final String DELTA_KEY_FORMAT = "view:%s:%d";
+
+ @Mock
+ StringRedisTemplate redis;
+
+ @Mock
+ ValueOperations valueOps;
+
+ ViewCounter counter;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ when(redis.opsForValue()).thenReturn(valueOps);
+ counter = new RedisViewCounter(redis);
+ }
+
+ @Test
+ @DisplayName("์กฐํ์ ๋ธํ๋ฅผ ์ฆ๊ฐ์ํค๊ณ , ์ฆ๊ฐ๋ ๊ฐ์ ๋ฐํํ๋ค.")
+ void should_increment_and_get_delta() {
+ // given
+ long targetId = 1L;
+ String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId);
+ when(valueOps.increment(key)).thenReturn(5L);
+
+ // when
+ long delta = counter.incrementAndGet(ViewableType.POST, targetId);
+
+ // then
+ assertThat(delta).isEqualTo(5L);
+ }
+
+ @Test
+ @DisplayName("๋ธํ ๊ฐ ์ฆ๊ฐ ์, ํค์ ํด๋นํ๋ ๊ฐ์ด ์์ผ๋ฉด 0์ ๋ฐํํ๋ค.")
+ void should_return_0_if_no_value() {
+ // given
+ long targetId = 1L;
+ String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId);
+ when(valueOps.increment(key)).thenReturn(null);
+
+ // when
+ long delta = counter.incrementAndGet(ViewableType.POST, targetId);
+
+ // then
+ assertThat(delta).isEqualTo(0L);
+ }
+
+ @Test
+ @DisplayName("ํค์ ํด๋นํ๋ ๊ฐ์ long ํ์
์ผ๋ก ๋ณํํ์ฌ ๋ฐํํ๋ค.")
+ void should_return_current_delta() {
+ // given
+ long targetId = 1L;
+ String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId);
+ when(valueOps.get(key)).thenReturn("10");
+
+ // when
+ long delta = counter.currentDelta(ViewableType.POST, targetId);
+
+ // then
+ assertThat(delta).isEqualTo(10L);
+ }
+
+ @Test
+ @DisplayName("ํ์ฌ ๋ธํ ๊ฐ ์กฐํ ์, ํค์ ํด๋นํ๋ ๊ฐ์ด ์์ผ๋ฉด 0์ ๋ฐํํ๋ค.")
+ void should_return_0_if_no_current_value() {
+ // given
+ long targetId = 1L;
+ String key = DELTA_KEY_FORMAT.formatted(ViewableType.POST.getKeyPrefix(), targetId);
+ when(valueOps.get(key)).thenReturn(null);
+
+ // when
+ 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);
+ }
+}
From 03020b928b8b0e83eac7f1cd0d9f833757f849d9 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 13:29:43 +0900
Subject: [PATCH 016/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EA=B4=80=EB=A0=A8=20=EB=8F=84?=
=?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A0=95?=
=?UTF-8?q?=EC=9D=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/main/java/kr/spot/post/Comment.java | 44 +++++++++++++++++++
.../src/main/java/kr/spot/post/Post.java | 44 +++++++++++++++++++
.../src/main/java/kr/spot/post/PostStats.java | 37 ++++++++++++++++
.../main/java/kr/spot/post/vo/WriterInfo.java | 39 ++++++++++++++++
4 files changed, 164 insertions(+)
create mode 100644 modules/study/src/main/java/kr/spot/post/Comment.java
create mode 100644 modules/study/src/main/java/kr/spot/post/Post.java
create mode 100644 modules/study/src/main/java/kr/spot/post/PostStats.java
create mode 100644 modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java
diff --git a/modules/study/src/main/java/kr/spot/post/Comment.java b/modules/study/src/main/java/kr/spot/post/Comment.java
new file mode 100644
index 00000000..2dd40801
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/Comment.java
@@ -0,0 +1,44 @@
+package kr.spot.post;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import kr.spot.domain.BaseEntity;
+import kr.spot.post.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/Post.java b/modules/study/src/main/java/kr/spot/post/Post.java
new file mode 100644
index 00000000..b63b713f
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/Post.java
@@ -0,0 +1,44 @@
+package kr.spot.post;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import kr.spot.domain.BaseEntity;
+import kr.spot.post.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;
+
+ @Embedded
+ private WriterInfo writerInfo;
+
+ @Column(nullable = false)
+ private String title;
+
+ @Column(nullable = false)
+ private String content;
+
+ private boolean isPinned;
+
+ private boolean isPrivate;
+
+ public static Post of(Long id, WriterInfo writerInfo, String title, String content,
+ boolean isPinned, boolean isPrivate) {
+ return new Post(id, writerInfo, title, content, isPinned, isPrivate);
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/post/PostStats.java b/modules/study/src/main/java/kr/spot/post/PostStats.java
new file mode 100644
index 00000000..01e14762
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/PostStats.java
@@ -0,0 +1,37 @@
+package kr.spot.post;
+
+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(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 PostStats extends BaseEntity {
+
+ @Id
+ private Long postId;
+
+ private Long viewCount;
+
+ private Long likeCount;
+
+ private Long commentCount;
+
+ 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/vo/WriterInfo.java b/modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java
new file mode 100644
index 00000000..5c69c637
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java
@@ -0,0 +1,39 @@
+package kr.spot.post.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);
+ }
+}
From 857cd458fbf3c726204208f77114f13701cbbc64 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 13:30:47 +0900
Subject: [PATCH 017/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EA=B4=80=EB=A0=A8=20=EB=8F=84?=
=?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20domain=20?=
=?UTF-8?q?=ED=95=98=EC=9C=84=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=A1=9C=20?=
=?UTF-8?q?=EC=9D=B4=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/main/java/kr/spot/post/{ => domain}/Comment.java | 4 ++--
.../study/src/main/java/kr/spot/post/{ => domain}/Post.java | 4 ++--
.../src/main/java/kr/spot/post/{ => domain}/PostStats.java | 2 +-
.../main/java/kr/spot/post/{ => domain}/vo/WriterInfo.java | 2 +-
4 files changed, 6 insertions(+), 6 deletions(-)
rename modules/study/src/main/java/kr/spot/post/{ => domain}/Comment.java (94%)
rename modules/study/src/main/java/kr/spot/post/{ => domain}/Post.java (94%)
rename modules/study/src/main/java/kr/spot/post/{ => domain}/PostStats.java (97%)
rename modules/study/src/main/java/kr/spot/post/{ => domain}/vo/WriterInfo.java (96%)
diff --git a/modules/study/src/main/java/kr/spot/post/Comment.java b/modules/study/src/main/java/kr/spot/post/domain/Comment.java
similarity index 94%
rename from modules/study/src/main/java/kr/spot/post/Comment.java
rename to modules/study/src/main/java/kr/spot/post/domain/Comment.java
index 2dd40801..56e08d18 100644
--- a/modules/study/src/main/java/kr/spot/post/Comment.java
+++ b/modules/study/src/main/java/kr/spot/post/domain/Comment.java
@@ -1,9 +1,9 @@
-package kr.spot.post;
+package kr.spot.post.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import kr.spot.domain.BaseEntity;
-import kr.spot.post.vo.WriterInfo;
+import kr.spot.post.domain.vo.WriterInfo;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
diff --git a/modules/study/src/main/java/kr/spot/post/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java
similarity index 94%
rename from modules/study/src/main/java/kr/spot/post/Post.java
rename to modules/study/src/main/java/kr/spot/post/domain/Post.java
index b63b713f..79a45f57 100644
--- a/modules/study/src/main/java/kr/spot/post/Post.java
+++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java
@@ -1,11 +1,11 @@
-package kr.spot.post;
+package kr.spot.post.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import kr.spot.domain.BaseEntity;
-import kr.spot.post.vo.WriterInfo;
+import kr.spot.post.domain.vo.WriterInfo;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
diff --git a/modules/study/src/main/java/kr/spot/post/PostStats.java b/modules/study/src/main/java/kr/spot/post/domain/PostStats.java
similarity index 97%
rename from modules/study/src/main/java/kr/spot/post/PostStats.java
rename to modules/study/src/main/java/kr/spot/post/domain/PostStats.java
index 01e14762..b7be7ce7 100644
--- a/modules/study/src/main/java/kr/spot/post/PostStats.java
+++ b/modules/study/src/main/java/kr/spot/post/domain/PostStats.java
@@ -1,4 +1,4 @@
-package kr.spot.post;
+package kr.spot.post.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
diff --git a/modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java b/modules/study/src/main/java/kr/spot/post/domain/vo/WriterInfo.java
similarity index 96%
rename from modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java
rename to modules/study/src/main/java/kr/spot/post/domain/vo/WriterInfo.java
index 5c69c637..8437abb6 100644
--- a/modules/study/src/main/java/kr/spot/post/vo/WriterInfo.java
+++ b/modules/study/src/main/java/kr/spot/post/domain/vo/WriterInfo.java
@@ -1,4 +1,4 @@
-package kr.spot.post.vo;
+package kr.spot.post.domain.vo;
import jakarta.persistence.Embeddable;
import java.util.Objects;
From 0c59e9f0ef3a56fdcd9681976fd3ad3a1eff6b3e Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 14:01:25 +0900
Subject: [PATCH 018/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=83=9D=EC=84=B1/=EC=88=98?=
=?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/kr/spot/code/status/ErrorStatus.java | 1 +
.../command/ManagePostService.java | 65 +++++++++++++++++++
.../main/java/kr/spot/post/domain/Post.java | 30 ++++++++-
.../infrastructure/jpa/CommentRepository.java | 8 +++
.../infrastructure/jpa/PostRepository.java | 14 ++++
.../jpa/PostStatsRepository.java | 8 +++
.../command/PostCommandController.java | 61 +++++++++++++++++
.../command/dto/ManagePostRequest.java | 6 ++
8 files changed, 190 insertions(+), 3 deletions(-)
create mode 100644 modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java
create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java
create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManagePostRequest.java
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 d0d5e295..33b64d73 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
@@ -48,6 +48,7 @@ public enum ErrorStatus implements BaseErrorCode {
"์
ํ ์น์ธ์ ์ํ ์ ํจํ์ง ์์ ์คํฐ๋ ๋ฉค๋ฒ ์ํ์
๋๋ค."),
_STUDY_ALREADY_APPLIED(400, "STUDY4006", "์ด๋ฏธ ํด๋น ์คํฐ๋์ ์ง์ํ ์ํ์
๋๋ค."),
_STUDY_ACCESS_DENIED(403, "STUDY4032", "์คํฐ๋์ ์ ๊ทผํ ๊ถํ์ด ์์ต๋๋ค."),
+ _INVALID_STUDY_ACCESS(403, "STUDY4033", "์ ํจํ์ง ์์ ์คํฐ๋ ์ ๊ทผ์
๋๋ค."),
// ๊ฒ์๊ธ ๊ด๋ จ
_POST_NOT_FOUND(404, "POST404", "๊ฒ์๊ธ์ ์ฐพ์ ์ ์์ต๋๋ค."),
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..0460e382
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/application/command/ManagePostService.java
@@ -0,0 +1,65 @@
+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 lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class ManagePostService {
+
+ private final IdGenerator idGenerator;
+ private final GetWriterInfoPort getWriterInfoPort;
+ private final PostRepository postRepository;
+ private final PostStatsRepository postStatsRepository;
+
+ public void createPost(long studyId, long writerId, ManagePostRequest request) {
+ WriterInfo writerInfo = getWriterInfo(writerId);
+ Post post = createAndSavePost(request, studyId, writerInfo);
+ initializeAndSavePostStats(post);
+ }
+
+ public void updatePost(long studyId, long postId, ManagePostRequest request, long 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) {
+ Post post = postRepository.getById(postId);
+ post.delete(writerId, studyId);
+ }
+
+ public void pinPost(long studyId, long postId) {
+ Post post = postRepository.getById(postId);
+
+ }
+
+ 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/domain/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java
index 79a45f57..ba34b96e 100644
--- a/modules/study/src/main/java/kr/spot/post/domain/Post.java
+++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java
@@ -4,7 +4,9 @@
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
+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;
@@ -24,6 +26,8 @@ public class Post extends BaseEntity {
@Id
private Long id;
+ private Long studyId;
+
@Embedded
private WriterInfo writerInfo;
@@ -37,8 +41,28 @@ public class Post extends BaseEntity {
private boolean isPrivate;
- public static Post of(Long id, WriterInfo writerInfo, String title, String content,
- boolean isPinned, boolean isPrivate) {
- return new Post(id, writerInfo, title, content, isPinned, 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, false, 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();
+ }
+
+ 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/infrastructure/jpa/CommentRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java
new file mode 100644
index 00000000..66a6ced9
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java
@@ -0,0 +1,8 @@
+package kr.spot.post.infrastructure.jpa;
+
+import kr.spot.post.domain.Comment;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface CommentRepository extends JpaRepository {
+
+}
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..2ae79226
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostRepository.java
@@ -0,0 +1,14 @@
+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;
+
+public interface PostRepository extends JpaRepository {
+
+ default Post getById(long postId) {
+ return findById(postId).orElseThrow(
+ () -> new GeneralException(ErrorStatus._POST_NOT_FOUND));
+ }
+}
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..982bc361
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java
@@ -0,0 +1,8 @@
+package kr.spot.post.infrastructure.jpa;
+
+import kr.spot.post.domain.PostStats;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PostStatsRepository extends JpaRepository {
+
+}
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..df8ae38a
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java
@@ -0,0 +1,61 @@
+package kr.spot.post.presentation.command;
+
+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.ManagePostService;
+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;
+
+ @PostMapping
+ public ResponseEntity> createPost(
+ @PathVariable Long studyId,
+ @CurrentMember Long writerId,
+ @RequestBody ManagePostRequest request) {
+ managePostService.createPost(studyId, writerId, request);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ }
+
+ @PutMapping("/{postId}")
+ public ResponseEntity> updatePost(
+ @PathVariable Long studyId,
+ @PathVariable Long postId,
+ @CurrentMember Long writerId,
+ @RequestBody ManagePostRequest request) {
+ managePostService.updatePost(studyId, postId, request, writerId);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ }
+
+ @DeleteMapping("/{postId}")
+ public ResponseEntity> deletePost(
+ @PathVariable Long studyId,
+ @PathVariable Long postId,
+ @CurrentMember Long writerId) {
+ managePostService.deletePost(studyId, postId, writerId);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ }
+
+ @PostMapping("/{postId}/pin")
+ public ResponseEntity> pinPost(
+ @PathVariable Long studyId,
+ @PathVariable Long postId) {
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ }
+}
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) {
+
+}
From 55d9391cf24d9fd8707d92b8e3842f239c84a2f6 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 14:07:56 +0900
Subject: [PATCH 019/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=ED=95=80=20API=20=EA=B5=AC?=
=?UTF-8?q?=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManagePostService.java | 5 +++++
.../main/java/kr/spot/post/domain/Post.java | 19 +++++++++++++++++--
.../command/PostCommandController.java | 11 ++++++++++-
3 files changed, 32 insertions(+), 3 deletions(-)
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
index 0460e382..10b6bf7f 100644
--- 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
@@ -41,7 +41,12 @@ public void deletePost(long studyId, long postId, long writerId) {
public void pinPost(long studyId, long postId) {
Post post = postRepository.getById(postId);
+ post.pin(studyId);
+ }
+ public void unpinPost(long studyId, long postId) {
+ Post post = postRepository.getById(postId);
+ post.unpin(studyId);
}
private WriterInfo getWriterInfo(long writerId) {
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
index ba34b96e..91048e9f 100644
--- a/modules/study/src/main/java/kr/spot/post/domain/Post.java
+++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java
@@ -4,6 +4,7 @@
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;
@@ -37,13 +38,13 @@ public class Post extends BaseEntity {
@Column(nullable = false)
private String content;
- private boolean isPinned;
+ 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, false, isPrivate);
+ return new Post(id, studyId, writerInfo, title, content, null, isPrivate);
}
public void update(String title, String content, boolean isPrivate, long memberId, long studyId) {
@@ -60,6 +61,20 @@ public void delete(long memberId, long studyId) {
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;
+ }
+
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/presentation/command/PostCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java
index df8ae38a..cdf3f3d2 100644
--- 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
@@ -56,6 +56,15 @@ public ResponseEntity> deletePost(
public ResponseEntity> pinPost(
@PathVariable Long studyId,
@PathVariable Long postId) {
- return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ managePostService.pinPost(studyId, postId);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK));
+ }
+
+ @DeleteMapping("/{postId}/pin")
+ public ResponseEntity> unpinPost(
+ @PathVariable Long studyId,
+ @PathVariable Long postId) {
+ managePostService.unpinPost(studyId, postId);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK));
}
}
From 47f1e9fd93ca4631c28419a518404a18d2ca4026 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 14:12:30 +0900
Subject: [PATCH 020/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20command=20=EB=A1=9C=EC=A7=81?=
=?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?=
=?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManagePostServiceTest.java | 373 ++++++++++++++++++
.../java/kr/spot/post/common/PostFixture.java | 46 +++
.../java/kr/spot/post/domain/PostTest.java | 239 +++++++++++
3 files changed, 658 insertions(+)
create mode 100644 modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/post/common/PostFixture.java
create mode 100644 modules/study/src/test/java/kr/spot/post/domain/PostTest.java
diff --git a/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java
new file mode 100644
index 00000000..e2804ada
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java
@@ -0,0 +1,373 @@
+package kr.spot.post.application.command;
+
+import static kr.spot.post.common.PostFixture.CONTENT;
+import static kr.spot.post.common.PostFixture.IS_PRIVATE;
+import static kr.spot.post.common.PostFixture.STUDY_ID;
+import static kr.spot.post.common.PostFixture.TITLE;
+import static kr.spot.post.common.PostFixture.WRITER_ID;
+import static kr.spot.post.common.PostFixture.WRITER_NAME;
+import static kr.spot.post.common.PostFixture.WRITER_PROFILE_IMAGE_URL;
+import static kr.spot.post.common.PostFixture.managePostRequest;
+import static kr.spot.post.common.PostFixture.post;
+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.verify;
+import static org.mockito.Mockito.when;
+
+import kr.spot.IdGenerator;
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+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.infrastructure.jpa.PostRepository;
+import kr.spot.post.infrastructure.jpa.PostStatsRepository;
+import kr.spot.post.presentation.command.dto.ManagePostRequest;
+import org.junit.jupiter.api.BeforeEach;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class ManagePostServiceTest {
+
+ @Mock
+ IdGenerator idGenerator;
+
+ @Mock
+ GetWriterInfoPort getWriterInfoPort;
+
+ @Mock
+ PostRepository postRepository;
+
+ @Mock
+ PostStatsRepository postStatsRepository;
+
+ @Captor
+ ArgumentCaptor postCaptor;
+
+ @Captor
+ ArgumentCaptor postStatsCaptor;
+
+ ManagePostService managePostService;
+
+ @BeforeEach
+ void setUp() {
+ managePostService = new ManagePostService(
+ idGenerator, getWriterInfoPort, postRepository, postStatsRepository);
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ์์ฑ (createPost)")
+ class CreatePost {
+
+ @Test
+ @DisplayName("๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_post_successfully() {
+ // given
+ Long generatedId = 1L;
+ ManagePostRequest request = managePostRequest();
+ WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(
+ WRITER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL);
+
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(getWriterInfoPort.get(WRITER_ID)).thenReturn(writerInfoResponse);
+ when(postRepository.save(any(Post.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ managePostService.createPost(STUDY_ID, WRITER_ID, request);
+
+ // then
+ verify(postRepository).save(postCaptor.capture());
+ verify(postStatsRepository).save(postStatsCaptor.capture());
+
+ Post capturedPost = postCaptor.getValue();
+ assertThat(capturedPost.getId()).isEqualTo(generatedId);
+ assertThat(capturedPost.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(capturedPost.getWriterInfo().getWriterId()).isEqualTo(WRITER_ID);
+ assertThat(capturedPost.getTitle()).isEqualTo(TITLE);
+ assertThat(capturedPost.getContent()).isEqualTo(CONTENT);
+ assertThat(capturedPost.isPrivate()).isEqualTo(IS_PRIVATE);
+
+ PostStats capturedPostStats = postStatsCaptor.getValue();
+ assertThat(capturedPostStats.getPostId()).isEqualTo(generatedId);
+ assertThat(capturedPostStats.getViewCount()).isZero();
+ assertThat(capturedPostStats.getLikeCount()).isZero();
+ assertThat(capturedPostStats.getCommentCount()).isZero();
+ }
+
+ @Test
+ @DisplayName("๋น๊ณต๊ฐ ๊ฒ์๊ธ์ ์์ฑํ ์ ์๋ค")
+ void should_create_private_post() {
+ // given
+ Long generatedId = 1L;
+ ManagePostRequest request = managePostRequest("์ ๋ชฉ", "๋ด์ฉ", true);
+ WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(
+ WRITER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL);
+
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(getWriterInfoPort.get(WRITER_ID)).thenReturn(writerInfoResponse);
+ when(postRepository.save(any(Post.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ managePostService.createPost(STUDY_ID, WRITER_ID, request);
+
+ // then
+ verify(postRepository).save(postCaptor.capture());
+
+ Post capturedPost = postCaptor.getValue();
+ assertThat(capturedPost.isPrivate()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ์์ (updatePost)")
+ class UpdatePost {
+
+ @Test
+ @DisplayName("๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ์์ ํ ์ ์๋ค")
+ void should_update_post_successfully() {
+ // given
+ long postId = 1L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+ ManagePostRequest request = managePostRequest("์์ ๋ ์ ๋ชฉ", "์์ ๋ ๋ด์ฉ", true);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when
+ managePostService.updatePost(STUDY_ID, postId, request, WRITER_ID);
+
+ // then
+ assertThat(post.getTitle()).isEqualTo("์์ ๋ ์ ๋ชฉ");
+ assertThat(post.getContent()).isEqualTo("์์ ๋ ๋ด์ฉ");
+ assertThat(post.isPrivate()).isTrue();
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_author_updates() {
+ // given
+ long postId = 1L;
+ long otherMemberId = 999L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+ ManagePostRequest request = managePostRequest();
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(
+ () -> managePostService.updatePost(STUDY_ID, postId, request, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_from_different_study() {
+ // given
+ long postId = 1L;
+ long otherStudyId = 999L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+ ManagePostRequest request = managePostRequest();
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(
+ () -> managePostService.updatePost(otherStudyId, postId, request, WRITER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ๊ฒ์๊ธ์ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_post_not_found() {
+ // given
+ long postId = 999L;
+ ManagePostRequest request = managePostRequest();
+
+ when(postRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(
+ () -> managePostService.updatePost(STUDY_ID, postId, request, WRITER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND);
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ์ญ์ (deletePost)")
+ class DeletePost {
+
+ @Test
+ @DisplayName("๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_post_successfully() {
+ // given
+ long postId = 1L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when
+ managePostService.deletePost(STUDY_ID, postId, WRITER_ID);
+
+ // then
+ verify(postRepository).getById(postId);
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_author_deletes() {
+ // given
+ long postId = 1L;
+ long otherMemberId = 999L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(
+ () -> managePostService.deletePost(STUDY_ID, postId, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_from_different_study() {
+ // given
+ long postId = 1L;
+ long otherStudyId = 999L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(
+ () -> managePostService.deletePost(otherStudyId, postId, WRITER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ํ (pinPost)")
+ class PinPost {
+
+ @Test
+ @DisplayName("๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ํํ ์ ์๋ค")
+ void should_pin_post_successfully() {
+ // given
+ long postId = 1L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when
+ managePostService.pinPost(STUDY_ID, postId);
+
+ // then
+ assertThat(post.isPinned()).isTrue();
+ assertThat(post.getPinnedAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_pin_from_different_study() {
+ // given
+ long postId = 1L;
+ long otherStudyId = 999L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(() -> managePostService.pinPost(otherStudyId, postId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ๊ฒ์๊ธ์ ํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_post_not_found() {
+ // given
+ long postId = 999L;
+
+ when(postRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(() -> managePostService.pinPost(STUDY_ID, postId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND);
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ์ธํ (unpinPost)")
+ class UnpinPost {
+
+ @Test
+ @DisplayName("ํ๋ ๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ์ธํํ ์ ์๋ค")
+ void should_unpin_post_successfully() {
+ // given
+ long postId = 1L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+ post.pin(STUDY_ID);
+ assertThat(post.isPinned()).isTrue();
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when
+ managePostService.unpinPost(STUDY_ID, postId);
+
+ // then
+ assertThat(post.isPinned()).isFalse();
+ assertThat(post.getPinnedAt()).isNull();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ์ธํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_unpin_from_different_study() {
+ // given
+ long postId = 1L;
+ long otherStudyId = 999L;
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+ post.pin(STUDY_ID);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(() -> managePostService.unpinPost(otherStudyId, postId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ๊ฒ์๊ธ์ ์ธํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_post_not_found() {
+ // given
+ long postId = 999L;
+
+ when(postRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(() -> managePostService.unpinPost(STUDY_ID, postId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND);
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/post/common/PostFixture.java b/modules/study/src/test/java/kr/spot/post/common/PostFixture.java
new file mode 100644
index 00000000..d00440f4
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/common/PostFixture.java
@@ -0,0 +1,46 @@
+package kr.spot.post.common;
+
+import kr.spot.post.domain.Post;
+import kr.spot.post.domain.vo.WriterInfo;
+import kr.spot.post.presentation.command.dto.ManagePostRequest;
+
+public class PostFixture {
+
+ public static final Long ID = 1L;
+ public static final Long STUDY_ID = 100L;
+ public static final Long WRITER_ID = 200L;
+ public static final String WRITER_NAME = "ํ
์คํธ์ ์ ";
+ public static final String WRITER_PROFILE_IMAGE_URL = "https://example.com/profile.jpg";
+ public static final String TITLE = "ํ
์คํธ ๊ฒ์๊ธ ์ ๋ชฉ";
+ public static final String CONTENT = "ํ
์คํธ ๊ฒ์๊ธ ๋ด์ฉ์
๋๋ค.";
+ public static final boolean IS_PRIVATE = false;
+
+ public static WriterInfo writerInfo() {
+ return WriterInfo.of(WRITER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL);
+ }
+
+ public static WriterInfo writerInfo(Long writerId) {
+ return WriterInfo.of(writerId, WRITER_NAME, WRITER_PROFILE_IMAGE_URL);
+ }
+
+ public static Post post() {
+ return Post.of(ID, STUDY_ID, writerInfo(), TITLE, CONTENT, IS_PRIVATE);
+ }
+
+ public static Post post(Long id, Long studyId) {
+ return Post.of(id, studyId, writerInfo(), TITLE, CONTENT, IS_PRIVATE);
+ }
+
+ public static Post post(Long id, Long studyId, Long writerId) {
+ return Post.of(id, studyId, writerInfo(writerId), TITLE, CONTENT, IS_PRIVATE);
+ }
+
+ public static ManagePostRequest managePostRequest() {
+ return new ManagePostRequest(TITLE, CONTENT, IS_PRIVATE);
+ }
+
+ public static ManagePostRequest managePostRequest(String title, String content,
+ boolean isPrivate) {
+ return new ManagePostRequest(title, content, isPrivate);
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/post/domain/PostTest.java b/modules/study/src/test/java/kr/spot/post/domain/PostTest.java
new file mode 100644
index 00000000..fef1e8e3
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/domain/PostTest.java
@@ -0,0 +1,239 @@
+package kr.spot.post.domain;
+
+import static kr.spot.post.common.PostFixture.CONTENT;
+import static kr.spot.post.common.PostFixture.ID;
+import static kr.spot.post.common.PostFixture.IS_PRIVATE;
+import static kr.spot.post.common.PostFixture.STUDY_ID;
+import static kr.spot.post.common.PostFixture.TITLE;
+import static kr.spot.post.common.PostFixture.WRITER_ID;
+import static kr.spot.post.common.PostFixture.post;
+import static kr.spot.post.common.PostFixture.writerInfo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class PostTest {
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ์์ฑ (of)")
+ class CreatePost {
+
+ @Test
+ @DisplayName("๊ฒ์๊ธ ๊ฐ์ฒด๋ฅผ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_post_successfully() {
+ // when
+ Post post = Post.of(ID, STUDY_ID, writerInfo(), TITLE, CONTENT, IS_PRIVATE);
+
+ // then
+ assertThat(post).isNotNull();
+ assertThat(post.getId()).isEqualTo(ID);
+ assertThat(post.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(post.getWriterInfo().getWriterId()).isEqualTo(WRITER_ID);
+ assertThat(post.getTitle()).isEqualTo(TITLE);
+ assertThat(post.getContent()).isEqualTo(CONTENT);
+ assertThat(post.isPrivate()).isEqualTo(IS_PRIVATE);
+ assertThat(post.isPinned()).isFalse();
+ }
+
+ @Test
+ @DisplayName("๋น๊ณต๊ฐ ๊ฒ์๊ธ์ ์์ฑํ ์ ์๋ค")
+ void should_create_private_post() {
+ // when
+ Post post = Post.of(ID, STUDY_ID, writerInfo(), TITLE, CONTENT, true);
+
+ // then
+ assertThat(post.isPrivate()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ์์ (update)")
+ class UpdatePost {
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ์์ ํ ์ ์๋ค")
+ void should_update_post_successfully() {
+ // given
+ Post post = post();
+ String newTitle = "์์ ๋ ์ ๋ชฉ";
+ String newContent = "์์ ๋ ๋ด์ฉ";
+ boolean newIsPrivate = true;
+
+ // when
+ post.update(newTitle, newContent, newIsPrivate, WRITER_ID, STUDY_ID);
+
+ // then
+ assertThat(post.getTitle()).isEqualTo(newTitle);
+ assertThat(post.getContent()).isEqualTo(newContent);
+ assertThat(post.isPrivate()).isEqualTo(newIsPrivate);
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_author_updates() {
+ // given
+ Post post = post();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(
+ () -> post.update("์ ์ ๋ชฉ", "์ ๋ด์ฉ", false, otherMemberId, STUDY_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_from_different_study() {
+ // given
+ Post post = post();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(
+ () -> post.update("์ ์ ๋ชฉ", "์ ๋ด์ฉ", false, WRITER_ID, otherStudyId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ์ญ์ (delete)")
+ class DeletePost {
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_post_successfully() {
+ // given
+ Post post = post();
+
+ // when & then (์์ธ๊ฐ ๋ฐ์ํ์ง ์์ผ๋ฉด ์ฑ๊ณต)
+ post.delete(WRITER_ID, STUDY_ID);
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_author_deletes() {
+ // given
+ Post post = post();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> post.delete(otherMemberId, STUDY_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_from_different_study() {
+ // given
+ Post post = post();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> post.delete(WRITER_ID, otherStudyId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ํ (pin)")
+ class PinPost {
+
+ @Test
+ @DisplayName("๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ํํ ์ ์๋ค")
+ void should_pin_post_successfully() {
+ // given
+ Post post = post();
+ assertThat(post.isPinned()).isFalse();
+
+ // when
+ post.pin(STUDY_ID);
+
+ // then
+ assertThat(post.isPinned()).isTrue();
+ assertThat(post.getPinnedAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_pin_from_different_study() {
+ // given
+ Post post = post();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> post.pin(otherStudyId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ์ธํ (unpin)")
+ class UnpinPost {
+
+ @Test
+ @DisplayName("ํ๋ ๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ์ธํํ ์ ์๋ค")
+ void should_unpin_post_successfully() {
+ // given
+ Post post = post();
+ post.pin(STUDY_ID);
+ assertThat(post.isPinned()).isTrue();
+
+ // when
+ post.unpin(STUDY_ID);
+
+ // then
+ assertThat(post.isPinned()).isFalse();
+ assertThat(post.getPinnedAt()).isNull();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ์ธํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_unpin_from_different_study() {
+ // given
+ Post post = post();
+ post.pin(STUDY_ID);
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> post.unpin(otherStudyId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํ ์ํ ํ์ธ (isPinned)")
+ class IsPinned {
+
+ @Test
+ @DisplayName("ํ๋์ง ์์ ๊ฒ์๊ธ์ false๋ฅผ ๋ฐํํ๋ค")
+ void should_return_false_when_not_pinned() {
+ // given
+ Post post = post();
+
+ // when & then
+ assertThat(post.isPinned()).isFalse();
+ }
+
+ @Test
+ @DisplayName("ํ๋ ๊ฒ์๊ธ์ true๋ฅผ ๋ฐํํ๋ค")
+ void should_return_true_when_pinned() {
+ // given
+ Post post = post();
+ post.pin(STUDY_ID);
+
+ // when & then
+ assertThat(post.isPinned()).isTrue();
+ }
+ }
+}
From 2effadc56d8fb397dbb8a33240e2be1f8cf8fc4e Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 14:15:50 +0900
Subject: [PATCH 021/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20command=20=EB=A1=9C=EC=A7=81?=
=?UTF-8?q?=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EB=AA=85=EC=84=B8=EC=84=9C=20?=
=?UTF-8?q?=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/PostCommandController.java | 61 +++++++++++++++----
1 file changed, 50 insertions(+), 11 deletions(-)
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
index cdf3f3d2..6169c576 100644
--- 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
@@ -1,5 +1,10 @@
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;
@@ -24,46 +29,80 @@ public class PostCommandController {
private final ManagePostService managePostService;
+ @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(
- @PathVariable Long studyId,
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
@CurrentMember Long writerId,
@RequestBody ManagePostRequest request) {
managePostService.createPost(studyId, writerId, request);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
}
+ @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(
- @PathVariable Long studyId,
- @PathVariable Long postId,
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "๊ฒ์๊ธ ID", required = true) @PathVariable Long postId,
@CurrentMember Long writerId,
@RequestBody ManagePostRequest request) {
managePostService.updatePost(studyId, postId, request, writerId);
- return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ 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(
- @PathVariable Long studyId,
- @PathVariable Long postId,
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "๊ฒ์๊ธ ID", required = true) @PathVariable Long postId,
@CurrentMember Long writerId) {
managePostService.deletePost(studyId, postId, writerId);
- return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ 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(
- @PathVariable Long studyId,
- @PathVariable Long postId) {
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "๊ฒ์๊ธ ID", required = true) @PathVariable Long postId) {
managePostService.pinPost(studyId, postId);
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(
- @PathVariable Long studyId,
- @PathVariable Long postId) {
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "๊ฒ์๊ธ ID", required = true) @PathVariable Long postId) {
managePostService.unpinPost(studyId, postId);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK));
}
From 7e1ffd955e98e61b4cd336e7d90db822dc0a4f7a Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 14:21:16 +0900
Subject: [PATCH 022/126] =?UTF-8?q?[Feature]=20post=20=EB=AA=A8=EB=93=88?=
=?UTF-8?q?=EA=B3=BC=20=EB=B9=88=20=EB=84=A4=EC=9E=84=20=EC=B6=A9=EB=8F=8C?=
=?UTF-8?q?=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EB=B3=84?=
=?UTF-8?q?=EB=8F=84=20=EB=B9=88=20=EB=84=A4=EC=9E=84=20=EC=A7=80=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../kr/spot/post/application/command/ManagePostService.java | 2 +-
.../java/kr/spot/post/infrastructure/jpa/CommentRepository.java | 2 ++
.../java/kr/spot/post/infrastructure/jpa/PostRepository.java | 2 ++
.../kr/spot/post/infrastructure/jpa/PostStatsRepository.java | 2 ++
4 files changed, 7 insertions(+), 1 deletion(-)
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
index 10b6bf7f..2678d759 100644
--- 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
@@ -13,7 +13,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-@Service
+@Service(value = "ManageStudyPostService")
@Transactional
@RequiredArgsConstructor
public class ManagePostService {
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
index 66a6ced9..89057078 100644
--- 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
@@ -2,7 +2,9 @@
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 {
}
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
index 2ae79226..61fa9cf4 100644
--- 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
@@ -4,7 +4,9 @@
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) {
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
index 982bc361..e6c5a7f3 100644
--- 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
@@ -2,7 +2,9 @@
import kr.spot.post.domain.PostStats;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+@Repository(value = "studyPostStatsRepository")
public interface PostStatsRepository extends JpaRepository {
}
From 713d9d1c46b64e7d31476e981e0f1c53e292c513 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 14:27:32 +0900
Subject: [PATCH 023/126] =?UTF-8?q?[Feature]=20=EC=9E=91=EC=84=B1=EC=9E=90?=
=?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EA=B4=80=EB=A0=A8=20=EC=95=A0=EB=85=B8?=
=?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?=
=?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=88=A8=EA=B8=B0=EB=8F=84=EB=A1=9D=20?=
=?UTF-8?q?=EC=84=A4=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../post/presentation/command/PostCommandController.java | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
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
index 6169c576..94247ef3 100644
--- 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
@@ -38,7 +38,7 @@ public class PostCommandController {
@PostMapping
public ResponseEntity> createPost(
@Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
- @CurrentMember Long writerId,
+ @CurrentMember @Parameter(hidden = true) Long writerId,
@RequestBody ManagePostRequest request) {
managePostService.createPost(studyId, writerId, request);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
@@ -55,7 +55,7 @@ public ResponseEntity> createPost(
public ResponseEntity> updatePost(
@Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
@Parameter(description = "๊ฒ์๊ธ ID", required = true) @PathVariable Long postId,
- @CurrentMember Long writerId,
+ @CurrentMember @Parameter(hidden = true) Long writerId,
@RequestBody ManagePostRequest request) {
managePostService.updatePost(studyId, postId, request, writerId);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK));
@@ -72,7 +72,7 @@ public ResponseEntity> updatePost(
public ResponseEntity> deletePost(
@Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
@Parameter(description = "๊ฒ์๊ธ ID", required = true) @PathVariable Long postId,
- @CurrentMember Long writerId) {
+ @CurrentMember @Parameter(hidden = true) Long writerId) {
managePostService.deletePost(studyId, postId, writerId);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK));
}
From 8b62a810238c900ff1d8738fac6ecae626023540 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 14:32:26 +0900
Subject: [PATCH 024/126] =?UTF-8?q?[Feature]=20=ED=95=B4=EB=8B=B9=20?=
=?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=94=94=EC=9D=98=20=ED=9A=8C=EC=9B=90?=
=?UTF-8?q?=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=EB=A7=8C=20=EA=B2=8C=EC=8B=9C?=
=?UTF-8?q?=ED=8C=90=EC=97=90=20=EC=A0=91=EA=B7=BC=ED=95=A0=20=EC=88=98=20?=
=?UTF-8?q?=EC=9E=88=EB=8F=84=EB=A1=9D=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?=
=?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManagePostService.java | 11 +++-
.../command/PostCommandController.java | 10 ++--
.../command/ManagePostServiceTest.java | 53 ++++++++++++++++---
3 files changed, 61 insertions(+), 13 deletions(-)
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
index 2678d759..c17f212a 100644
--- 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
@@ -9,6 +9,7 @@
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;
@@ -22,29 +23,35 @@ public class ManagePostService {
private final GetWriterInfoPort getWriterInfoPort;
private final PostRepository postRepository;
private final PostStatsRepository postStatsRepository;
+ private final StudyAccessValidator studyAccessValidator;
public void createPost(long studyId, long writerId, ManagePostRequest request) {
+ studyAccessValidator.validateStudyMember(studyId, writerId);
WriterInfo writerInfo = getWriterInfo(writerId);
Post post = createAndSavePost(request, studyId, writerInfo);
initializeAndSavePostStats(post);
}
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) {
+ 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) {
+ public void unpinPost(long studyId, long postId, long memberId) {
+ studyAccessValidator.validateStudyMember(studyId, memberId);
Post post = postRepository.getById(postId);
post.unpin(studyId);
}
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
index 94247ef3..3cf4fc82 100644
--- 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
@@ -87,8 +87,9 @@ public ResponseEntity> deletePost(
@PostMapping("/{postId}/pin")
public ResponseEntity> pinPost(
@Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
- @Parameter(description = "๊ฒ์๊ธ ID", required = true) @PathVariable Long postId) {
- managePostService.pinPost(studyId, postId);
+ @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));
}
@@ -102,8 +103,9 @@ public ResponseEntity> pinPost(
@DeleteMapping("/{postId}/pin")
public ResponseEntity> unpinPost(
@Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
- @Parameter(description = "๊ฒ์๊ธ ID", required = true) @PathVariable Long postId) {
- managePostService.unpinPost(studyId, postId);
+ @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));
}
}
diff --git a/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java
index e2804ada..8d617bd9 100644
--- a/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/post/application/command/ManagePostServiceTest.java
@@ -16,6 +16,9 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+
import kr.spot.IdGenerator;
import kr.spot.code.status.ErrorStatus;
import kr.spot.exception.GeneralException;
@@ -26,6 +29,7 @@
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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@@ -51,6 +55,9 @@ class ManagePostServiceTest {
@Mock
PostStatsRepository postStatsRepository;
+ @Mock
+ StudyAccessValidator studyAccessValidator;
+
@Captor
ArgumentCaptor postCaptor;
@@ -62,7 +69,7 @@ class ManagePostServiceTest {
@BeforeEach
void setUp() {
managePostService = new ManagePostService(
- idGenerator, getWriterInfoPort, postRepository, postStatsRepository);
+ idGenerator, getWriterInfoPort, postRepository, postStatsRepository, studyAccessValidator);
}
@Nested
@@ -276,13 +283,29 @@ void should_pin_post_successfully() {
when(postRepository.getById(postId)).thenReturn(post);
// when
- managePostService.pinPost(STUDY_ID, postId);
+ managePostService.pinPost(STUDY_ID, postId, WRITER_ID);
// then
assertThat(post.isPinned()).isTrue();
assertThat(post.getPinnedAt()).isNotNull();
}
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ์๋ ์ฌ๋์ด ํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_study_member_pins() {
+ // given
+ long postId = 1L;
+ long nonMemberId = 999L;
+
+ doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED))
+ .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId);
+
+ // when & then
+ assertThatThrownBy(() -> managePostService.pinPost(STUDY_ID, postId, nonMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED);
+ }
+
@Test
@DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
void should_throw_exception_when_pin_from_different_study() {
@@ -294,7 +317,7 @@ void should_throw_exception_when_pin_from_different_study() {
when(postRepository.getById(postId)).thenReturn(post);
// when & then
- assertThatThrownBy(() -> managePostService.pinPost(otherStudyId, postId))
+ assertThatThrownBy(() -> managePostService.pinPost(otherStudyId, postId, WRITER_ID))
.isInstanceOf(GeneralException.class)
.hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
}
@@ -309,7 +332,7 @@ void should_throw_exception_when_post_not_found() {
.thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND));
// when & then
- assertThatThrownBy(() -> managePostService.pinPost(STUDY_ID, postId))
+ assertThatThrownBy(() -> managePostService.pinPost(STUDY_ID, postId, WRITER_ID))
.isInstanceOf(GeneralException.class)
.hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND);
}
@@ -331,13 +354,29 @@ void should_unpin_post_successfully() {
when(postRepository.getById(postId)).thenReturn(post);
// when
- managePostService.unpinPost(STUDY_ID, postId);
+ managePostService.unpinPost(STUDY_ID, postId, WRITER_ID);
// then
assertThat(post.isPinned()).isFalse();
assertThat(post.getPinnedAt()).isNull();
}
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ์๋ ์ฌ๋์ด ์ธํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_study_member_unpins() {
+ // given
+ long postId = 1L;
+ long nonMemberId = 999L;
+
+ doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED))
+ .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId);
+
+ // when & then
+ assertThatThrownBy(() -> managePostService.unpinPost(STUDY_ID, postId, nonMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED);
+ }
+
@Test
@DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ์ธํํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
void should_throw_exception_when_unpin_from_different_study() {
@@ -350,7 +389,7 @@ void should_throw_exception_when_unpin_from_different_study() {
when(postRepository.getById(postId)).thenReturn(post);
// when & then
- assertThatThrownBy(() -> managePostService.unpinPost(otherStudyId, postId))
+ assertThatThrownBy(() -> managePostService.unpinPost(otherStudyId, postId, WRITER_ID))
.isInstanceOf(GeneralException.class)
.hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
}
@@ -365,7 +404,7 @@ void should_throw_exception_when_post_not_found() {
.thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND));
// when & then
- assertThatThrownBy(() -> managePostService.unpinPost(STUDY_ID, postId))
+ assertThatThrownBy(() -> managePostService.unpinPost(STUDY_ID, postId, WRITER_ID))
.isInstanceOf(GeneralException.class)
.hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND);
}
From 4ec853d4081949f89eb07dae51bb7edd73386e66 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 15:03:06 +0900
Subject: [PATCH 025/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EB=8C=93=EA=B8=80=20=EC=83=9D?=
=?UTF-8?q?=EC=84=B1/=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EA=B4=80?=
=?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManageCommentService.java | 73 ++++++++++++++++
.../main/java/kr/spot/post/domain/Post.java | 4 +
.../infrastructure/jpa/CommentRepository.java | 7 ++
.../jpa/PostStatsRepository.java | 10 +++
.../command/CommentCommandController.java | 86 +++++++++++++++++++
.../command/dto/CreateCommentResponse.java | 8 ++
.../command/dto/ManageCommentRequest.java | 5 ++
7 files changed, 193 insertions(+)
create mode 100644 modules/study/src/main/java/kr/spot/post/application/command/ManageCommentService.java
create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/CommentCommandController.java
create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/dto/CreateCommentResponse.java
create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/command/dto/ManageCommentRequest.java
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/domain/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java
index 91048e9f..faf17b45 100644
--- a/modules/study/src/main/java/kr/spot/post/domain/Post.java
+++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java
@@ -75,6 +75,10 @@ public boolean isPinned() {
return pinnedAt != null;
}
+ public void validateBelongsToStudy(long studyId) {
+ validateStudyId(studyId);
+ }
+
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/infrastructure/jpa/CommentRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/CommentRepository.java
index 89057078..d0e9a0aa 100644
--- 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
@@ -1,5 +1,7 @@
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;
@@ -7,4 +9,9 @@
@Repository(value = "studyCommentRepository")
public interface CommentRepository extends JpaRepository {
+ default Comment getById(long id) {
+ return findById(id)
+ .orElseThrow(() -> new GeneralException(ErrorStatus._COMMENT_NOT_FOUND));
+ }
+
}
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
index e6c5a7f3..e9f8a112 100644
--- 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
@@ -2,9 +2,19 @@
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);
}
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..1d4a5710
--- /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/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/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) {
+
+}
From 33ce5a2cce40b62e4dd5a72a34dae8adda890a30 Mon Sep 17 00:00:00 2001
From: msk226
Date: Sun, 28 Dec 2025 15:03:14 +0900
Subject: [PATCH 026/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EB=8C=93=EA=B8=80=20=EC=83=9D?=
=?UTF-8?q?=EC=84=B1/=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20=EA=B2=80?=
=?UTF-8?q?=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?=
=?UTF-8?q?=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManageCommentServiceTest.java | 344 ++++++++++++++++++
.../kr/spot/post/common/CommentFixture.java | 33 ++
.../java/kr/spot/post/domain/CommentTest.java | 98 +++++
3 files changed, 475 insertions(+)
create mode 100644 modules/study/src/test/java/kr/spot/post/application/command/ManageCommentServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/post/common/CommentFixture.java
create mode 100644 modules/study/src/test/java/kr/spot/post/domain/CommentTest.java
diff --git a/modules/study/src/test/java/kr/spot/post/application/command/ManageCommentServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/command/ManageCommentServiceTest.java
new file mode 100644
index 00000000..6990eb91
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/application/command/ManageCommentServiceTest.java
@@ -0,0 +1,344 @@
+package kr.spot.post.application.command;
+
+import static kr.spot.post.common.CommentFixture.CONTENT;
+import static kr.spot.post.common.CommentFixture.POST_ID;
+import static kr.spot.post.common.CommentFixture.comment;
+import static kr.spot.post.common.CommentFixture.manageCommentRequest;
+import static kr.spot.post.common.PostFixture.STUDY_ID;
+import static kr.spot.post.common.PostFixture.WRITER_ID;
+import static kr.spot.post.common.PostFixture.WRITER_NAME;
+import static kr.spot.post.common.PostFixture.WRITER_PROFILE_IMAGE_URL;
+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.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import static kr.spot.post.common.PostFixture.post;
+
+import kr.spot.IdGenerator;
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+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.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 org.junit.jupiter.api.BeforeEach;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class ManageCommentServiceTest {
+
+ @Mock
+ IdGenerator idGenerator;
+
+ @Mock
+ GetWriterInfoPort getWriterInfoPort;
+
+ @Mock
+ CommentRepository commentRepository;
+
+ @Mock
+ PostRepository postRepository;
+
+ @Mock
+ PostStatsRepository postStatsRepository;
+
+ @Mock
+ StudyAccessValidator studyAccessValidator;
+
+ @Captor
+ ArgumentCaptor commentCaptor;
+
+ ManageCommentService manageCommentService;
+
+ @BeforeEach
+ void setUp() {
+ manageCommentService = new ManageCommentService(
+ idGenerator, getWriterInfoPort, commentRepository, postRepository, postStatsRepository,
+ studyAccessValidator);
+ }
+
+ @Nested
+ @DisplayName("๋๊ธ ์์ฑ (createComment)")
+ class CreateComment {
+
+ @Test
+ @DisplayName("๋๊ธ์ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_comment_successfully() {
+ // given
+ Long generatedId = 1L;
+ Post post = post(POST_ID, STUDY_ID, WRITER_ID);
+ ManageCommentRequest request = manageCommentRequest();
+ WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(
+ WRITER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL);
+
+ when(postRepository.getById(anyLong())).thenReturn(post);
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(getWriterInfoPort.get(WRITER_ID)).thenReturn(writerInfoResponse);
+ when(commentRepository.save(any(Comment.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ CreateCommentResponse response = manageCommentService.createComment(
+ WRITER_ID, POST_ID, STUDY_ID, request);
+
+ // then
+ assertThat(response.commentId()).isEqualTo(generatedId);
+
+ verify(commentRepository).save(commentCaptor.capture());
+ verify(postStatsRepository).increaseCommentCount(POST_ID);
+
+ Comment capturedComment = commentCaptor.getValue();
+ assertThat(capturedComment.getId()).isEqualTo(generatedId);
+ assertThat(capturedComment.getPostId()).isEqualTo(POST_ID);
+ assertThat(capturedComment.getWriterInfo().getWriterId()).isEqualTo(WRITER_ID);
+ assertThat(capturedComment.getContent()).isEqualTo(CONTENT);
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ์๋ ์ฌ๋์ด ๋๊ธ์ ์์ฑํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_study_member_creates() {
+ // given
+ long nonMemberId = 999L;
+ ManageCommentRequest request = manageCommentRequest();
+
+ doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED))
+ .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.createComment(nonMemberId, POST_ID, STUDY_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ๊ฒ์๊ธ์ ๋๊ธ์ ์์ฑํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_post_belongs_to_different_study() {
+ // given
+ long otherStudyId = 999L;
+ Post postInOtherStudy = post(POST_ID, otherStudyId, WRITER_ID);
+ ManageCommentRequest request = manageCommentRequest();
+
+ when(postRepository.getById(POST_ID)).thenReturn(postInOtherStudy);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.createComment(WRITER_ID, POST_ID, STUDY_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+ }
+
+ @Nested
+ @DisplayName("๋๊ธ ์์ (updateComment)")
+ class UpdateComment {
+
+ @Test
+ @DisplayName("๋๊ธ์ ์ ์์ ์ผ๋ก ์์ ํ ์ ์๋ค")
+ void should_update_comment_successfully() {
+ // given
+ long commentId = 1L;
+ Post post = post(POST_ID, STUDY_ID, WRITER_ID);
+ Comment comment = comment(commentId, POST_ID, WRITER_ID);
+ ManageCommentRequest request = manageCommentRequest("์์ ๋ ๋๊ธ ๋ด์ฉ");
+
+ when(commentRepository.getById(commentId)).thenReturn(comment);
+ when(postRepository.getById(POST_ID)).thenReturn(post);
+
+ // when
+ manageCommentService.updateComment(WRITER_ID, commentId, STUDY_ID, request);
+
+ // then
+ assertThat(comment.getContent()).isEqualTo("์์ ๋ ๋๊ธ ๋ด์ฉ");
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_author_updates() {
+ // given
+ long commentId = 1L;
+ long otherMemberId = 999L;
+ Post post = post(POST_ID, STUDY_ID, WRITER_ID);
+ Comment comment = comment(commentId, POST_ID, WRITER_ID);
+ ManageCommentRequest request = manageCommentRequest();
+
+ when(commentRepository.getById(commentId)).thenReturn(comment);
+ when(postRepository.getById(POST_ID)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.updateComment(otherMemberId, commentId, STUDY_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ์๋ ์ฌ๋์ด ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_study_member_updates() {
+ // given
+ long commentId = 1L;
+ long nonMemberId = 999L;
+ ManageCommentRequest request = manageCommentRequest();
+
+ doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED))
+ .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.updateComment(nonMemberId, commentId, STUDY_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ๊ฒ์๊ธ ๋๊ธ์ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_comment_belongs_to_different_study() {
+ // given
+ long commentId = 1L;
+ long otherStudyId = 999L;
+ Post postInOtherStudy = post(POST_ID, otherStudyId, WRITER_ID);
+ Comment comment = comment(commentId, POST_ID, WRITER_ID);
+ ManageCommentRequest request = manageCommentRequest();
+
+ when(commentRepository.getById(commentId)).thenReturn(comment);
+ when(postRepository.getById(POST_ID)).thenReturn(postInOtherStudy);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.updateComment(WRITER_ID, commentId, STUDY_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ๋๊ธ์ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_comment_not_found() {
+ // given
+ long commentId = 999L;
+ ManageCommentRequest request = manageCommentRequest();
+
+ when(commentRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._COMMENT_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.updateComment(WRITER_ID, commentId, STUDY_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._COMMENT_NOT_FOUND);
+ }
+ }
+
+ @Nested
+ @DisplayName("๋๊ธ ์ญ์ (deleteComment)")
+ class DeleteComment {
+
+ @Test
+ @DisplayName("๋๊ธ์ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_comment_successfully() {
+ // given
+ long commentId = 1L;
+ Post post = post(POST_ID, STUDY_ID, WRITER_ID);
+ Comment comment = comment(commentId, POST_ID, WRITER_ID);
+
+ when(commentRepository.getById(commentId)).thenReturn(comment);
+ when(postRepository.getById(POST_ID)).thenReturn(post);
+
+ // when
+ manageCommentService.deleteComment(WRITER_ID, commentId, STUDY_ID);
+
+ // then
+ verify(commentRepository).getById(commentId);
+ verify(postStatsRepository).decreaseCommentCount(POST_ID);
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_author_deletes() {
+ // given
+ long commentId = 1L;
+ long otherMemberId = 999L;
+ Post post = post(POST_ID, STUDY_ID, WRITER_ID);
+ Comment comment = comment(commentId, POST_ID, WRITER_ID);
+
+ when(commentRepository.getById(commentId)).thenReturn(comment);
+ when(postRepository.getById(POST_ID)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.deleteComment(otherMemberId, commentId, STUDY_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ์๋ ์ฌ๋์ด ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_study_member_deletes() {
+ // given
+ long commentId = 1L;
+ long nonMemberId = 999L;
+
+ doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED))
+ .when(studyAccessValidator).validateStudyMember(STUDY_ID, nonMemberId);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.deleteComment(nonMemberId, commentId, STUDY_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ๋๊ธ์ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_comment_not_found() {
+ // given
+ long commentId = 999L;
+
+ when(commentRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._COMMENT_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.deleteComment(WRITER_ID, commentId, STUDY_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._COMMENT_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ๊ฒ์๊ธ ๋๊ธ์ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_comment_belongs_to_different_study() {
+ // given
+ long commentId = 1L;
+ long otherStudyId = 999L;
+ Post postInOtherStudy = post(POST_ID, otherStudyId, WRITER_ID);
+ Comment comment = comment(commentId, POST_ID, WRITER_ID);
+
+ when(commentRepository.getById(commentId)).thenReturn(comment);
+ when(postRepository.getById(POST_ID)).thenReturn(postInOtherStudy);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageCommentService.deleteComment(WRITER_ID, commentId, STUDY_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/post/common/CommentFixture.java b/modules/study/src/test/java/kr/spot/post/common/CommentFixture.java
new file mode 100644
index 00000000..6831e47a
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/common/CommentFixture.java
@@ -0,0 +1,33 @@
+package kr.spot.post.common;
+
+import static kr.spot.post.common.PostFixture.writerInfo;
+
+import kr.spot.post.domain.Comment;
+import kr.spot.post.presentation.command.dto.ManageCommentRequest;
+
+public class CommentFixture {
+
+ public static final long COMMENT_ID = 1L;
+ public static final long POST_ID = 100L;
+ public static final String CONTENT = "ํ
์คํธ ๋๊ธ ๋ด์ฉ์
๋๋ค.";
+
+ public static Comment comment() {
+ return Comment.of(COMMENT_ID, POST_ID, writerInfo(), CONTENT);
+ }
+
+ public static Comment comment(long id, long postId) {
+ return Comment.of(id, postId, writerInfo(), CONTENT);
+ }
+
+ public static Comment comment(long id, long postId, long writerId) {
+ return Comment.of(id, postId, writerInfo(writerId), CONTENT);
+ }
+
+ public static ManageCommentRequest manageCommentRequest() {
+ return new ManageCommentRequest(CONTENT);
+ }
+
+ public static ManageCommentRequest manageCommentRequest(String content) {
+ return new ManageCommentRequest(content);
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/post/domain/CommentTest.java b/modules/study/src/test/java/kr/spot/post/domain/CommentTest.java
new file mode 100644
index 00000000..24b775bc
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/domain/CommentTest.java
@@ -0,0 +1,98 @@
+package kr.spot.post.domain;
+
+import static kr.spot.post.common.CommentFixture.COMMENT_ID;
+import static kr.spot.post.common.CommentFixture.CONTENT;
+import static kr.spot.post.common.CommentFixture.POST_ID;
+import static kr.spot.post.common.CommentFixture.comment;
+import static kr.spot.post.common.PostFixture.WRITER_ID;
+import static kr.spot.post.common.PostFixture.writerInfo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class CommentTest {
+
+ @Nested
+ @DisplayName("๋๊ธ ์์ฑ (of)")
+ class CreateComment {
+
+ @Test
+ @DisplayName("๋๊ธ ๊ฐ์ฒด๋ฅผ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_comment_successfully() {
+ // when
+ Comment comment = Comment.of(COMMENT_ID, POST_ID, writerInfo(), CONTENT);
+
+ // then
+ assertThat(comment).isNotNull();
+ assertThat(comment.getId()).isEqualTo(COMMENT_ID);
+ assertThat(comment.getPostId()).isEqualTo(POST_ID);
+ assertThat(comment.getWriterInfo().getWriterId()).isEqualTo(WRITER_ID);
+ assertThat(comment.getContent()).isEqualTo(CONTENT);
+ }
+ }
+
+ @Nested
+ @DisplayName("๋๊ธ ์์ (update)")
+ class UpdateComment {
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ๋๊ธ์ ์ ์์ ์ผ๋ก ์์ ํ ์ ์๋ค")
+ void should_update_comment_successfully() {
+ // given
+ Comment comment = comment();
+ String newContent = "์์ ๋ ๋๊ธ ๋ด์ฉ";
+
+ // when
+ comment.update(WRITER_ID, newContent);
+
+ // then
+ assertThat(comment.getContent()).isEqualTo(newContent);
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_author_updates() {
+ // given
+ Comment comment = comment();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> comment.update(otherMemberId, "์ ๋ด์ฉ"))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("๋๊ธ ์ญ์ (delete)")
+ class DeleteComment {
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ๋๊ธ์ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_comment_successfully() {
+ // given
+ Comment comment = comment();
+
+ // when & then (์์ธ๊ฐ ๋ฐ์ํ์ง ์์ผ๋ฉด ์ฑ๊ณต)
+ comment.delete(WRITER_ID);
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_author_deletes() {
+ // given
+ Comment comment = comment();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> comment.delete(otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+ }
+ }
+}
From 103928db08077fc1332a13b981c7c4930117aa89 Mon Sep 17 00:00:00 2001
From: msk226
Date: Mon, 29 Dec 2025 14:19:51 +0900
Subject: [PATCH 027/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=A1=B0=ED=9A=8C=20API=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/kr/spot/code/status/ErrorStatus.java | 1 +
.../dto/response/PostDetailResponse.java | 1 -
modules/study/build.gradle | 1 +
.../application/query/GetPostService.java | 147 ++++++++++++++++++
.../main/java/kr/spot/post/domain/Post.java | 10 ++
.../jpa/querydsl/PostQueryRepository.java | 83 ++++++++++
.../command/CommentCommandController.java | 2 +-
.../query/PostQueryController.java | 69 ++++++++
.../query/dto/PostDetailResponse.java | 49 ++++++
.../query/dto/PostListResponse.java | 47 ++++++
.../query/dto/PostStatsResponse.java | 12 ++
.../validator/StudyAccessValidator.java | 8 +
.../java/kr/spot/post/common/PostFixture.java | 33 ++++
13 files changed, 461 insertions(+), 2 deletions(-)
create mode 100644 modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java
create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/query/PostQueryController.java
create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java
create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java
create mode 100644 modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostStatsResponse.java
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 33b64d73..49965980 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
@@ -54,6 +54,7 @@ public enum ErrorStatus implements BaseErrorCode {
_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", "์ ํจํ์ง ์์ ์ ๋ ฌ ํ์
์
๋๋ค."),
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..14e6bc54 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
@@ -44,5 +44,4 @@ public static WriterInfoResponse of(Long writerId, String nickname, String profi
return new WriterInfoResponse(writerId, nickname, profileImageUrl);
}
}
-
}
diff --git a/modules/study/build.gradle b/modules/study/build.gradle
index f7b9623f..ce7b6717 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")
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..90cf37e2
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java
@@ -0,0 +1,147 @@
+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 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);
+ accessValidator.validateStudyMember(studyId, viewerId);
+
+ PostStats stats = postQueryRepository.findStatsByPostId(postId);
+ List comments = postQueryRepository.findCommentsByPostId(postId);
+ long displayViewCount = calculateDisplayViewCount(stats, postId, viewerId);
+
+ return toDetailResponse(post, stats, displayViewCount, comments, viewerId);
+ }
+
+ 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);
+
+ List postItems = mapToPostItems(allPosts, statsMap, isStudyMember);
+
+ 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 List mapToPostItems(List posts, Map statsMap,
+ boolean isStudyMember) {
+ return posts.stream()
+ .map(post -> toPostItem(post, statsMap.get(post.getId()), DEFAULT_MAX_CONTENT_LENGTH,
+ isStudyMember))
+ .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/domain/Post.java b/modules/study/src/main/java/kr/spot/post/domain/Post.java
index faf17b45..1608c21b 100644
--- a/modules/study/src/main/java/kr/spot/post/domain/Post.java
+++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java
@@ -75,10 +75,20 @@ 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() {
+ if (this.isPrivate) {
+ 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/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..9266c3d2
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/querydsl/PostQueryRepository.java
@@ -0,0 +1,83 @@
+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.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+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.QPostStats;
+import kr.spot.post.domain.Comment;
+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;
+
+ 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();
+ }
+}
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
index 1d4a5710..508852e6 100644
--- 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
@@ -22,7 +22,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
-@Tag(name = "๋๊ธ")
+@Tag(name = "์คํฐ๋ ๊ฒ์๊ธ - ๋๊ธ")
@RestController("studyCommentCommandController")
@RequestMapping("/api/studies/{studyId}/posts")
@RequiredArgsConstructor
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..bc766a6f
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java
@@ -0,0 +1,49 @@
+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,
+ 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..34713c89
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostListResponse.java
@@ -0,0 +1,47 @@
+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,
+ PostStatsResponse stats,
+ WriterInfoResponse writer,
+ LocalDateTime createdAt
+ ) {
+
+ public static PostItem of(
+ Long postId,
+ String title,
+ String content,
+ boolean isPinned,
+ PostStatsResponse stats,
+ WriterInfoResponse writer,
+ LocalDateTime createdAt
+ ) {
+ return new PostItem(postId, title, content, isPinned, stats, 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/PostStatsResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostStatsResponse.java
new file mode 100644
index 00000000..b7a8ff2a
--- /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/study/application/validator/StudyAccessValidator.java b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java
index c5f4b40c..a8428812 100644
--- a/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java
+++ b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java
@@ -33,4 +33,12 @@ public void validateStudyMember(Long studyId, Long memberId) {
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)
+ );
+ }
}
diff --git a/modules/study/src/test/java/kr/spot/post/common/PostFixture.java b/modules/study/src/test/java/kr/spot/post/common/PostFixture.java
index d00440f4..b5d1b65e 100644
--- a/modules/study/src/test/java/kr/spot/post/common/PostFixture.java
+++ b/modules/study/src/test/java/kr/spot/post/common/PostFixture.java
@@ -1,6 +1,7 @@
package kr.spot.post.common;
import kr.spot.post.domain.Post;
+import kr.spot.post.domain.PostStats;
import kr.spot.post.domain.vo.WriterInfo;
import kr.spot.post.presentation.command.dto.ManagePostRequest;
@@ -35,6 +36,38 @@ public static Post post(Long id, Long studyId, Long writerId) {
return Post.of(id, studyId, writerInfo(writerId), TITLE, CONTENT, IS_PRIVATE);
}
+ public static Post privatePost(Long id, Long studyId) {
+ return Post.of(id, studyId, writerInfo(), TITLE, CONTENT, true);
+ }
+
+ public static Post privatePost(Long id, Long studyId, Long writerId) {
+ return Post.of(id, studyId, writerInfo(writerId), TITLE, CONTENT, true);
+ }
+
+ public static Post pinnedPost(Long id, Long studyId) {
+ Post post = Post.of(id, studyId, writerInfo(), TITLE, CONTENT, IS_PRIVATE);
+ post.pin(studyId);
+ return post;
+ }
+
+ public static Post pinnedPost(Long id, Long studyId, Long writerId) {
+ Post post = Post.of(id, studyId, writerInfo(writerId), TITLE, CONTENT, IS_PRIVATE);
+ post.pin(studyId);
+ return post;
+ }
+
+ public static Post postWithContent(Long id, Long studyId, String content) {
+ return Post.of(id, studyId, writerInfo(), TITLE, content, IS_PRIVATE);
+ }
+
+ public static PostStats postStats(Long postId) {
+ return PostStats.of(postId);
+ }
+
+ public static PostStats postStats(Long postId, Long viewCount, Long likeCount, Long commentCount) {
+ return PostStats.of(postId, viewCount, likeCount, commentCount);
+ }
+
public static ManagePostRequest managePostRequest() {
return new ManagePostRequest(TITLE, CONTENT, IS_PRIVATE);
}
From 783b51f417e839c1986b9019288a6094dac45f50 Mon Sep 17 00:00:00 2001
From: msk226
Date: Mon, 29 Dec 2025 14:20:00 +0900
Subject: [PATCH 028/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=A1=B0=ED=9A=8C=20API=20?=
=?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?=
=?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../query/mapper/PostResponseMapper.java | 137 ++++++
.../application/query/GetPostServiceTest.java | 416 ++++++++++++++++++
2 files changed, 553 insertions(+)
create mode 100644 modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java
create mode 100644 modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java
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..2508bcdf
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/application/query/mapper/PostResponseMapper.java
@@ -0,0 +1,137 @@
+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.PostListResponse.WriterInfoResponse;
+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
+ ) {
+ return PostDetailResponse.builder()
+ .postId(post.getId())
+ .title(post.getTitle())
+ .content(post.getContent())
+ .isPinned(post.isPinned())
+ .isOwner(post.isOwnedBy(viewerId))
+ .writer(toDetailWriterInfoResponse(post.getWriterInfo()))
+ .stats(toStatsResponse(stats, displayViewCount))
+ .createdAt(post.getCreatedAt())
+ .comments(toCommentResponses(comments, viewerId))
+ .build();
+ }
+
+ public static PostItem toPostItem(Post post, PostStats stats, int maxContentLength, boolean isStudyMember) {
+ if (post.isPrivate() && !isStudyMember) {
+ return toMaskedPostItem(post, stats);
+ }
+ return toPublicPostItem(post, stats, maxContentLength);
+ }
+
+ private static PostItem toPublicPostItem(Post post, PostStats stats, int maxContentLength) {
+ return PostItem.of(
+ post.getId(),
+ post.getTitle(),
+ summarize(post.getContent(), maxContentLength),
+ post.isPinned(),
+ toStatsResponse(stats),
+ toWriterInfoResponse(post.getWriterInfo()),
+ post.getCreatedAt()
+ );
+ }
+
+ private static PostItem toMaskedPostItem(Post post, PostStats stats) {
+ return PostItem.of(
+ post.getId(),
+ PRIVATE_POST_MESSAGE,
+ PRIVATE_POST_MESSAGE,
+ post.isPinned(),
+ toStatsResponse(stats),
+ toWriterInfoResponse(post.getWriterInfo()),
+ 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 WriterInfoResponse toWriterInfoResponse(WriterInfo writerInfo) {
+ return WriterInfoResponse.of(
+ writerInfo.getWriterId(),
+ writerInfo.getWriterName(),
+ writerInfo.getWriterProfileImageUrl()
+ );
+ }
+
+ 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/test/java/kr/spot/post/application/query/GetPostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java
new file mode 100644
index 00000000..09ffd31a
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java
@@ -0,0 +1,416 @@
+package kr.spot.post.application.query;
+
+import static kr.spot.post.common.CommentFixture.comment;
+import static kr.spot.post.common.PostFixture.STUDY_ID;
+import static kr.spot.post.common.PostFixture.WRITER_ID;
+import static kr.spot.post.common.PostFixture.pinnedPost;
+import static kr.spot.post.common.PostFixture.post;
+import static kr.spot.post.common.PostFixture.postStats;
+import static kr.spot.post.common.PostFixture.privatePost;
+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.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Map;
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+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.study.application.validator.StudyAccessValidator;
+import kr.spot.view.ViewAbuseGuard;
+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.Nested;
+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 GetPostServiceTest {
+
+ @Mock
+ ViewCounter viewCounter;
+
+ @Mock
+ ViewAbuseGuard viewAbuseGuard;
+
+ @Mock
+ StudyAccessValidator accessValidator;
+
+ @Mock
+ PostRepository postRepository;
+
+ @Mock
+ PostQueryRepository postQueryRepository;
+
+ GetPostService getPostService;
+
+ @BeforeEach
+ void setUp() {
+ getPostService = new GetPostService(
+ viewCounter, viewAbuseGuard, accessValidator, postRepository, postQueryRepository);
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ์์ธ ์กฐํ (getPostDetail)")
+ class GetPostDetail {
+
+ @Test
+ @DisplayName("๊ฒ์๊ธ์ ์ ์์ ์ผ๋ก ์กฐํํ ์ ์๋ค")
+ void should_get_post_detail_successfully() {
+ // given
+ long postId = 1L;
+ long viewerId = WRITER_ID;
+ Post post = post(postId, STUDY_ID, viewerId);
+ PostStats stats = postStats(postId, 10L, 5L, 3L);
+ List comments = List.of(comment(1L, postId), comment(2L, postId));
+
+ when(postRepository.getById(postId)).thenReturn(post);
+ when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats);
+ when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(comments);
+ when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)).thenReturn(true);
+ when(viewCounter.incrementAndGet(ViewableType.STUDY_BOARD, postId)).thenReturn(1L);
+
+ // when
+ PostDetailResponse response = getPostService.getPostDetail(STUDY_ID, postId, viewerId);
+
+ // then
+ assertThat(response.postId()).isEqualTo(postId);
+ assertThat(response.stats().viewCount()).isEqualTo(11L); // 10 + 1
+ assertThat(response.stats().likeCount()).isEqualTo(5L);
+ assertThat(response.comments()).hasSize(2);
+ assertThat(response.isOwner()).isTrue();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ๊ฒ์๊ธ์ ์กฐํํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_post_belongs_to_different_study() {
+ // given
+ long postId = 1L;
+ long otherStudyId = 999L;
+ Post post = post(postId, STUDY_ID);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(() -> getPostService.getPostDetail(otherStudyId, postId, WRITER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._INVALID_STUDY_ACCESS);
+ }
+
+ @Test
+ @DisplayName("๋น๊ณต๊ฐ ๊ฒ์๊ธ์ ์กฐํํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_post_is_private() {
+ // given
+ long postId = 1L;
+ Post post = privatePost(postId, STUDY_ID);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+
+ // when & then
+ assertThatThrownBy(() -> getPostService.getPostDetail(STUDY_ID, postId, WRITER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._PRIVATE_POST_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ๊ฒ์๊ธ์ ์กฐํํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_post_not_found() {
+ // given
+ long postId = 999L;
+
+ when(postRepository.getById(postId))
+ .thenThrow(new GeneralException(ErrorStatus._POST_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(() -> getPostService.getPostDetail(STUDY_ID, postId, WRITER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._POST_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("์ค๋ณต ์กฐํ ์ ์กฐํ์๊ฐ ์ฆ๊ฐํ์ง ์๋๋ค")
+ void should_not_increment_view_when_already_viewed() {
+ // given
+ long postId = 1L;
+ long viewerId = WRITER_ID;
+ Post post = post(postId, STUDY_ID);
+ PostStats stats = postStats(postId, 10L, 5L, 3L);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+ when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats);
+ when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(List.of());
+ when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)).thenReturn(false);
+ when(viewCounter.currentDelta(ViewableType.STUDY_BOARD, postId)).thenReturn(5L);
+
+ // when
+ PostDetailResponse response = getPostService.getPostDetail(STUDY_ID, postId, viewerId);
+
+ // then
+ assertThat(response.stats().viewCount()).isEqualTo(15L); // 10 + 5 (๊ธฐ์กด delta)
+ verify(viewCounter, never()).incrementAndGet(any(), anyLong());
+ }
+
+ @Test
+ @DisplayName("Redis ์ฅ์ ์์๋ ๊ธฐ์กด ์กฐํ์๋ก ์ ์ ์๋ตํ๋ค")
+ void should_return_base_view_count_when_redis_fails() {
+ // given
+ long postId = 1L;
+ long viewerId = WRITER_ID;
+ Post post = post(postId, STUDY_ID);
+ PostStats stats = postStats(postId, 10L, 5L, 3L);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+ when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats);
+ when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(List.of());
+ when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId))
+ .thenThrow(new RuntimeException("Redis connection failed"));
+
+ // when
+ PostDetailResponse response = getPostService.getPostDetail(STUDY_ID, postId, viewerId);
+
+ // then
+ assertThat(response.stats().viewCount()).isEqualTo(10L); // ๊ธฐ์กด ์กฐํ์๋ง
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋ ๊ฒฝ์ฐ isOwner๊ฐ false์ด๋ค")
+ void should_return_is_owner_false_when_not_author() {
+ // given
+ long postId = 1L;
+ long viewerId = 999L; // ๋ค๋ฅธ ์ฌ์ฉ์
+ Post post = post(postId, STUDY_ID, WRITER_ID);
+ PostStats stats = postStats(postId);
+
+ when(postRepository.getById(postId)).thenReturn(post);
+ when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats);
+ when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(List.of());
+ when(viewAbuseGuard.shouldCount(any(), anyLong(), anyLong())).thenReturn(false);
+ when(viewCounter.currentDelta(any(), anyLong())).thenReturn(0L);
+
+ // when
+ PostDetailResponse response = getPostService.getPostDetail(STUDY_ID, postId, viewerId);
+
+ // then
+ assertThat(response.isOwner()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ฒ์๊ธ ๋ฆฌ์คํธ ์กฐํ (getPostList)")
+ class GetPostList {
+
+ @Test
+ @DisplayName("์ฒซ ํ์ด์ง์์ ํ ๊ฒ์๊ธ์ด ๋จผ์ ๋์จ๋ค")
+ void should_show_pinned_posts_first_on_first_page() {
+ // given
+ Post pinned1 = pinnedPost(1L, STUDY_ID);
+ Post pinned2 = pinnedPost(2L, STUDY_ID);
+ Post normal1 = post(3L, STUDY_ID);
+ Post normal2 = post(4L, STUDY_ID);
+
+ when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
+ when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of(pinned1, pinned2));
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt()))
+ .thenReturn(List.of(normal1, normal2));
+ when(postQueryRepository.findStatsByPostIds(any()))
+ .thenReturn(Map.of(1L, postStats(1L), 2L, postStats(2L), 3L, postStats(3L), 4L, postStats(4L)));
+
+ // when
+ PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10);
+
+ // then
+ assertThat(response.posts()).hasSize(4);
+ assertThat(response.posts().get(0).isPinned()).isTrue();
+ assertThat(response.posts().get(1).isPinned()).isTrue();
+ assertThat(response.posts().get(2).isPinned()).isFalse();
+ assertThat(response.posts().get(3).isPinned()).isFalse();
+ }
+
+ @Test
+ @DisplayName("๋ ๋ฒ์งธ ํ์ด์ง์์๋ ํ ๊ฒ์๊ธ์ด ์กฐํ๋์ง ์๋๋ค")
+ void should_not_show_pinned_posts_on_second_page() {
+ // given
+ Long cursor = 3L;
+ Post normal1 = post(2L, STUDY_ID);
+ Post normal2 = post(1L, STUDY_ID);
+
+ when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(cursor), anyInt()))
+ .thenReturn(List.of(normal1, normal2));
+ when(postQueryRepository.findStatsByPostIds(any()))
+ .thenReturn(Map.of(2L, postStats(2L), 1L, postStats(1L)));
+
+ // when
+ PostListResponse response = getPostService.getPostList(STUDY_ID, cursor, WRITER_ID, 10);
+
+ // then
+ assertThat(response.posts()).hasSize(2);
+ verify(postQueryRepository, never()).findPinnedPosts(anyLong());
+ }
+
+ @Test
+ @DisplayName("ํ์ด์ง๋ค์ด์
์ด ์ ์ ๋์ํ๋ค")
+ void should_handle_pagination_correctly() {
+ // given
+ Post post1 = post(5L, STUDY_ID);
+ Post post2 = post(4L, STUDY_ID);
+ Post post3 = post(3L, STUDY_ID); // ๋ค์ ํ์ด์ง ์กด์ฌ ํ์ธ์ฉ
+
+ when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
+ when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of());
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(3))) // size + 1
+ .thenReturn(List.of(post1, post2, post3));
+ when(postQueryRepository.findStatsByPostIds(any()))
+ .thenReturn(Map.of(5L, postStats(5L), 4L, postStats(4L)));
+
+ // when
+ PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 2);
+
+ // then
+ assertThat(response.posts()).hasSize(2);
+ assertThat(response.hasNext()).isTrue();
+ assertThat(response.nextCursor()).isEqualTo(4L);
+ }
+
+ @Test
+ @DisplayName("๋ง์ง๋ง ํ์ด์ง์์๋ hasNext๊ฐ false์ด๋ค")
+ void should_return_has_next_false_on_last_page() {
+ // given
+ Post post1 = post(2L, STUDY_ID);
+ Post post2 = post(1L, STUDY_ID);
+
+ when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
+ when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of());
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(11)))
+ .thenReturn(List.of(post1, post2)); // size๋ณด๋ค ์ ์
+ when(postQueryRepository.findStatsByPostIds(any()))
+ .thenReturn(Map.of(2L, postStats(2L), 1L, postStats(1L)));
+
+ // when
+ PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10);
+
+ // then
+ assertThat(response.hasNext()).isFalse();
+ assertThat(response.nextCursor()).isNull();
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋์์ธ ๊ฒฝ์ฐ ๋น๊ณต๊ฐ ๊ฒ์๊ธ์ด ์ ์ ํ์๋๋ค")
+ void should_show_private_post_content_for_study_member() {
+ // given
+ Post privatePostItem = privatePost(1L, STUDY_ID);
+
+ when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
+ when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of());
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt()))
+ .thenReturn(List.of(privatePostItem));
+ when(postQueryRepository.findStatsByPostIds(any()))
+ .thenReturn(Map.of(1L, postStats(1L)));
+
+ // when
+ PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10);
+
+ // then
+ assertThat(response.posts().get(0).title()).isNotEqualTo("์ด ๊ธ์ ์คํฐ๋์์๊ฒ๋ง ๋
ธ์ถ๋ฉ๋๋ค.");
+ }
+
+ @Test
+ @DisplayName("๋น์คํฐ๋์์ธ ๊ฒฝ์ฐ ๋น๊ณต๊ฐ ๊ฒ์๊ธ์ด ๋ง์คํน๋๋ค")
+ void should_mask_private_post_content_for_non_study_member() {
+ // given
+ long nonMemberId = 999L;
+ Post privatePostItem = privatePost(1L, STUDY_ID);
+
+ when(accessValidator.isStudyMember(STUDY_ID, nonMemberId)).thenReturn(false);
+ when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of());
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt()))
+ .thenReturn(List.of(privatePostItem));
+ when(postQueryRepository.findStatsByPostIds(any()))
+ .thenReturn(Map.of(1L, postStats(1L)));
+
+ // when
+ PostListResponse response = getPostService.getPostList(STUDY_ID, null, nonMemberId, 10);
+
+ // then
+ assertThat(response.posts().get(0).title()).isEqualTo("์ด ๊ธ์ ์คํฐ๋์์๊ฒ๋ง ๋
ธ์ถ๋ฉ๋๋ค.");
+ assertThat(response.posts().get(0).content()).isEqualTo("์ด ๊ธ์ ์คํฐ๋์์๊ฒ๋ง ๋
ธ์ถ๋ฉ๋๋ค.");
+ }
+
+ @Test
+ @DisplayName("๊ฒ์๊ธ์ด ์์ผ๋ฉด ๋น ๋ฆฌ์คํธ๋ฅผ ๋ฐํํ๋ค")
+ void should_return_empty_list_when_no_posts() {
+ // given
+ when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
+ when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of());
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt()))
+ .thenReturn(List.of());
+ when(postQueryRepository.findStatsByPostIds(any())).thenReturn(Map.of());
+
+ // when
+ PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10);
+
+ // then
+ assertThat(response.posts()).isEmpty();
+ assertThat(response.hasNext()).isFalse();
+ assertThat(response.nextCursor()).isNull();
+ }
+
+ @Test
+ @DisplayName("size๊ฐ MAX_PAGE_SIZE๋ฅผ ์ด๊ณผํ๋ฉด MAX_PAGE_SIZE๋ก ์ ํ๋๋ค")
+ void should_limit_page_size_to_max() {
+ // given
+ when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
+ when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of());
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(51))) // MAX_PAGE_SIZE + 1
+ .thenReturn(List.of());
+ when(postQueryRepository.findStatsByPostIds(any())).thenReturn(Map.of());
+
+ // when
+ getPostService.getPostList(STUDY_ID, null, WRITER_ID, 100);
+
+ // then
+ verify(postQueryRepository).findPageByIdDesc(STUDY_ID, null, 51);
+ }
+
+ @Test
+ @DisplayName("ํ ๊ฒ์๊ธ๊ณผ ์ผ๋ฐ ๊ฒ์๊ธ์ด ํจ๊ป ์์ ๋ ์ฌ๋ฐ๋ฅธ ํ์ด์ง ํฌ๊ธฐ๊ฐ ์ ์ฉ๋๋ค")
+ void should_apply_correct_page_size_with_pinned_posts() {
+ // given
+ Post pinned = pinnedPost(10L, STUDY_ID);
+ Post normal1 = post(5L, STUDY_ID);
+ Post normal2 = post(4L, STUDY_ID);
+ Post normal3 = post(3L, STUDY_ID); // ๋ค์ ํ์ด์ง ์กด์ฌ ํ์ธ์ฉ
+
+ when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
+ when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of(pinned));
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(3))) // size(3) - pinned(1) + 1
+ .thenReturn(List.of(normal1, normal2, normal3));
+ when(postQueryRepository.findStatsByPostIds(any()))
+ .thenReturn(Map.of(10L, postStats(10L), 5L, postStats(5L), 4L, postStats(4L)));
+
+ // when
+ PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 3);
+
+ // then
+ assertThat(response.posts()).hasSize(3); // pinned(1) + normal(2)
+ assertThat(response.hasNext()).isTrue();
+ assertThat(response.nextCursor()).isEqualTo(4L);
+ }
+ }
+}
From bdfe01589cb89355794cd8e26b5a63c8c18fed95 Mon Sep 17 00:00:00 2001
From: msk226
Date: Mon, 29 Dec 2025 14:29:52 +0900
Subject: [PATCH 029/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=83=81=EC=84=B8=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81?=
=?UTF-8?q?=20=EA=B5=AC=EC=B2=B4=ED=99=94=20=EC=A7=84=ED=96=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../spot/post/application/query/GetPostService.java | 2 +-
.../src/main/java/kr/spot/post/domain/Post.java | 4 ++--
.../application/validator/StudyAccessValidator.java | 8 ++------
.../post/application/query/GetPostServiceTest.java | 13 +++++++++----
4 files changed, 14 insertions(+), 13 deletions(-)
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
index 90cf37e2..d2f9c7eb 100644
--- 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
@@ -40,7 +40,7 @@ public class GetPostService {
public PostDetailResponse getPostDetail(long studyId, long postId, long viewerId) {
Post post = findAndValidatePost(studyId, postId);
- accessValidator.validateStudyMember(studyId, viewerId);
+ post.validatePublicAccess(accessValidator.isStudyMember(studyId, viewerId));
PostStats stats = postQueryRepository.findStatsByPostId(postId);
List comments = postQueryRepository.findCommentsByPostId(postId);
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
index 1608c21b..6553e3f2 100644
--- a/modules/study/src/main/java/kr/spot/post/domain/Post.java
+++ b/modules/study/src/main/java/kr/spot/post/domain/Post.java
@@ -83,8 +83,8 @@ public void validateBelongsToStudy(long studyId) {
validateStudyId(studyId);
}
- public void validatePublicAccess() {
- if (this.isPrivate) {
+ public void validatePublicAccess(boolean isStudyMember) {
+ if (this.isPrivate && !isStudyMember) {
throw new GeneralException(ErrorStatus._PRIVATE_POST_ACCESS_DENIED);
}
}
diff --git a/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java
index a8428812..7ad9d3ae 100644
--- a/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java
+++ b/modules/study/src/main/java/kr/spot/study/application/validator/StudyAccessValidator.java
@@ -23,12 +23,8 @@ public void validateStudyLeader(Long studyId, Long memberId) {
}
public void validateStudyMember(Long studyId, Long memberId) {
- boolean isMember = studyMemberRepository.existsByStudyIdAndMemberIdAndStudyMemberStatusIn(
- studyId,
- memberId,
- List.of(StudyMemberStatus.OWNER, StudyMemberStatus.APPROVED)
- );
-
+ boolean isMember = isStudyMember(studyId, memberId);
+
if (!isMember) {
throw new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED);
}
diff --git a/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java
index 09ffd31a..21c93e0a 100644
--- a/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/post/application/query/GetPostServiceTest.java
@@ -121,6 +121,7 @@ void should_throw_exception_when_post_is_private() {
Post post = privatePost(postId, STUDY_ID);
when(postRepository.getById(postId)).thenReturn(post);
+ when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(false);
// when & then
assertThatThrownBy(() -> getPostService.getPostDetail(STUDY_ID, postId, WRITER_ID))
@@ -155,7 +156,8 @@ void should_not_increment_view_when_already_viewed() {
when(postRepository.getById(postId)).thenReturn(post);
when(postQueryRepository.findStatsByPostId(postId)).thenReturn(stats);
when(postQueryRepository.findCommentsByPostId(postId)).thenReturn(List.of());
- when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)).thenReturn(false);
+ when(viewAbuseGuard.shouldCount(ViewableType.STUDY_BOARD, postId, viewerId)).thenReturn(
+ false);
when(viewCounter.currentDelta(ViewableType.STUDY_BOARD, postId)).thenReturn(5L);
// when
@@ -229,7 +231,8 @@ void should_show_pinned_posts_first_on_first_page() {
when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), anyInt()))
.thenReturn(List.of(normal1, normal2));
when(postQueryRepository.findStatsByPostIds(any()))
- .thenReturn(Map.of(1L, postStats(1L), 2L, postStats(2L), 3L, postStats(3L), 4L, postStats(4L)));
+ .thenReturn(
+ Map.of(1L, postStats(1L), 2L, postStats(2L), 3L, postStats(3L), 4L, postStats(4L)));
// when
PostListResponse response = getPostService.getPostList(STUDY_ID, null, WRITER_ID, 10);
@@ -377,7 +380,8 @@ void should_limit_page_size_to_max() {
// given
when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of());
- when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(51))) // MAX_PAGE_SIZE + 1
+ when(
+ postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(51))) // MAX_PAGE_SIZE + 1
.thenReturn(List.of());
when(postQueryRepository.findStatsByPostIds(any())).thenReturn(Map.of());
@@ -399,7 +403,8 @@ void should_apply_correct_page_size_with_pinned_posts() {
when(accessValidator.isStudyMember(STUDY_ID, WRITER_ID)).thenReturn(true);
when(postQueryRepository.findPinnedPosts(STUDY_ID)).thenReturn(List.of(pinned));
- when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null), eq(3))) // size(3) - pinned(1) + 1
+ when(postQueryRepository.findPageByIdDesc(eq(STUDY_ID), eq(null),
+ eq(3))) // size(3) - pinned(1) + 1
.thenReturn(List.of(normal1, normal2, normal3));
when(postQueryRepository.findStatsByPostIds(any()))
.thenReturn(Map.of(10L, postStats(10L), 5L, postStats(5L), 4L, postStats(4L)));
From 08027ab62658071fc233f0830caee991c03df88d Mon Sep 17 00:00:00 2001
From: msk226
Date: Mon, 29 Dec 2025 14:53:43 +0900
Subject: [PATCH 030/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A1=B0=ED=9A=8C=EC=88=98=20?=
=?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EB=B0=98=EC=98=81=20=EB=A1=9C=EC=A7=81=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../infrastructure/batch/PostViewFlusher.java | 2 +-
modules/study/build.gradle | 1 +
.../batch/StudyPostViewFlushJob.java | 157 ++++++++++++++++++
.../batch/StudyPostViewFlusher.java | 22 +++
.../jpa/PostStatsRepository.java | 9 +
.../batch/StudyPostViewFlushJobTest.java | 94 +++++++++++
.../batch/StudyPostViewFlusherTest.java | 38 +++++
7 files changed, 322 insertions(+), 1 deletion(-)
create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJob.java
create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusher.java
create mode 100644 modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJobTest.java
create mode 100644 modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusherTest.java
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/study/build.gradle b/modules/study/build.gradle
index ce7b6717..2eaa92b6 100644
--- a/modules/study/build.gradle
+++ b/modules/study/build.gradle
@@ -18,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"
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/PostStatsRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostStatsRepository.java
index e9f8a112..ea15849c 100644
--- 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
@@ -17,4 +17,13 @@ public interface PostStatsRepository extends JpaRepository {
@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);
}
diff --git a/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJobTest.java b/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJobTest.java
new file mode 100644
index 00000000..4be2c686
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlushJobTest.java
@@ -0,0 +1,94 @@
+package kr.spot.post.infrastructure.batch;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.data.redis.core.RedisCallback;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+
+class StudyPostViewFlushJobTest {
+
+ private static final String LOCK_KEY = "lock:study-post-view-flush";
+
+ @Mock
+ StringRedisTemplate redis;
+
+ @Mock
+ ValueOperations valueOps;
+
+ @Mock
+ StudyPostViewFlusher studyPostViewFlusher;
+
+ StudyPostViewFlushJob job;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ when(redis.opsForValue()).thenReturn(valueOps);
+ job = new StudyPostViewFlushJob(redis, studyPostViewFlusher);
+ }
+
+ @Nested
+ @DisplayName("๋ถ์ฐ ๋ฝ ํ
์คํธ")
+ class DistributedLockTest {
+
+ @Test
+ @DisplayName("๋ฝ ํ๋ ์คํจ ์ ๋ฐฐ์น๋ฅผ ์คํํ์ง ์๋๋ค")
+ void should_not_execute_when_lock_not_acquired() {
+ // given
+ when(valueOps.setIfAbsent(eq(LOCK_KEY), eq("1"), eq(55L), eq(TimeUnit.SECONDS)))
+ .thenReturn(false);
+
+ // when
+ job.flush();
+
+ // then
+ verify(redis, never()).execute(any(RedisCallback.class));
+ }
+
+ @Test
+ @DisplayName("๋ฝ ํ๋ ์ฑ๊ณต ์ ๋ฐฐ์น๋ฅผ ์คํํ๊ณ ๋ฝ์ ํด์ ํ๋ค")
+ void should_execute_and_release_lock_when_acquired() {
+ // given
+ when(valueOps.setIfAbsent(eq(LOCK_KEY), eq("1"), eq(55L), eq(TimeUnit.SECONDS)))
+ .thenReturn(true);
+
+ // when
+ job.flush();
+
+ // then
+ verify(redis).execute(any(RedisCallback.class));
+ verify(redis).delete(LOCK_KEY);
+ }
+
+ @Test
+ @DisplayName("๋ฐฐ์น ์คํ ์ค ์์ธ๊ฐ ๋ฐ์ํด๋ ๋ฝ์ ํด์ ํ๋ค")
+ void should_release_lock_even_when_exception_occurs() {
+ // given
+ when(valueOps.setIfAbsent(eq(LOCK_KEY), eq("1"), eq(55L), eq(TimeUnit.SECONDS)))
+ .thenReturn(true);
+ when(redis.execute(any(RedisCallback.class)))
+ .thenThrow(new RuntimeException("Redis error"));
+
+ // when
+ try {
+ job.flush();
+ } catch (Exception ignored) {
+ }
+
+ // then
+ verify(redis).delete(LOCK_KEY);
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusherTest.java b/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusherTest.java
new file mode 100644
index 00000000..cdd9cad3
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/infrastructure/batch/StudyPostViewFlusherTest.java
@@ -0,0 +1,38 @@
+package kr.spot.post.infrastructure.batch;
+
+import static org.mockito.Mockito.verify;
+
+import kr.spot.post.infrastructure.jpa.PostStatsRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+class StudyPostViewFlusherTest {
+
+ @Mock
+ PostStatsRepository postStatsRepository;
+
+ StudyPostViewFlusher flusher;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ flusher = new StudyPostViewFlusher(postStatsRepository);
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋ ๊ฒ์๊ธ ์กฐํ์ ๋ธํ๋ฅผ DB์ ๋ฐ์ํ๋ค")
+ void should_update_view_count_in_db() {
+ // given
+ Long postId = 123L;
+ long delta = 50L;
+
+ // when
+ flusher.updateViewCount(postId, delta);
+
+ // then
+ verify(postStatsRepository).increaseViewBy(postId, delta);
+ }
+}
From 041bca79576ac655795ddf78ffce3494d01e14e9 Mon Sep 17 00:00:00 2001
From: msk226
Date: Mon, 29 Dec 2025 23:34:03 +0900
Subject: [PATCH 031/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A2=8B=EC=95=84=EC=9A=94=20?=
=?UTF-8?q?=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?=
=?UTF-8?q?=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../application/command/LikePostService.java | 45 ++++++
.../application/query/GetPostService.java | 16 ++-
.../query/mapper/PostResponseMapper.java | 18 ++-
.../java/kr/spot/post/domain/PostLike.java | 36 +++++
.../jpa/PostLikeRepository.java | 31 ++++
.../jpa/PostStatsRepository.java | 8 ++
.../jpa/querydsl/PostQueryRepository.java | 29 +++-
.../command/PostCommandController.java | 34 +++++
.../query/dto/PostDetailResponse.java | 1 +
.../query/dto/PostListResponse.java | 4 +-
.../command/LikePostServiceTest.java | 132 ++++++++++++++++++
11 files changed, 342 insertions(+), 12 deletions(-)
create mode 100644 modules/study/src/main/java/kr/spot/post/application/command/LikePostService.java
create mode 100644 modules/study/src/main/java/kr/spot/post/domain/PostLike.java
create mode 100644 modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java
create mode 100644 modules/study/src/test/java/kr/spot/post/application/command/LikePostServiceTest.java
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/query/GetPostService.java b/modules/study/src/main/java/kr/spot/post/application/query/GetPostService.java
index d2f9c7eb..9e28c9a9 100644
--- 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
@@ -7,6 +7,7 @@
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;
@@ -45,8 +46,9 @@ public PostDetailResponse getPostDetail(long studyId, long postId, long 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);
+ return toDetailResponse(post, stats, displayViewCount, comments, viewerId, isLiked);
}
public PostListResponse getPostList(long studyId, Long cursor, long viewerId, int size) {
@@ -59,8 +61,9 @@ public PostListResponse getPostList(long studyId, Long cursor, long viewerId, in
List allPosts = mergePosts(pinnedPosts, paginationResult.posts());
Map statsMap = fetchStatsForPosts(allPosts);
+ Set likedPostIds = fetchLikedPostIds(allPosts, viewerId);
- List postItems = mapToPostItems(allPosts, statsMap, isStudyMember);
+ List postItems = mapToPostItems(allPosts, statsMap, isStudyMember, likedPostIds);
return buildListResponse(postItems, paginationResult);
}
@@ -106,11 +109,16 @@ private Map fetchStatsForPosts(List posts) {
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) {
+ boolean isStudyMember, Set likedPostIds) {
return posts.stream()
.map(post -> toPostItem(post, statsMap.get(post.getId()), DEFAULT_MAX_CONTENT_LENGTH,
- isStudyMember))
+ isStudyMember, likedPostIds.contains(post.getId())))
.toList();
}
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
index 2508bcdf..d00e12de 100644
--- 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
@@ -24,7 +24,8 @@ public static PostDetailResponse toDetailResponse(
PostStats stats,
long displayViewCount,
List comments,
- long viewerId
+ long viewerId,
+ boolean isLiked
) {
return PostDetailResponse.builder()
.postId(post.getId())
@@ -32,6 +33,7 @@ public static PostDetailResponse toDetailResponse(
.content(post.getContent())
.isPinned(post.isPinned())
.isOwner(post.isOwnedBy(viewerId))
+ .isLiked(isLiked)
.writer(toDetailWriterInfoResponse(post.getWriterInfo()))
.stats(toStatsResponse(stats, displayViewCount))
.createdAt(post.getCreatedAt())
@@ -39,31 +41,35 @@ public static PostDetailResponse toDetailResponse(
.build();
}
- public static PostItem toPostItem(Post post, PostStats stats, int maxContentLength, boolean isStudyMember) {
+ public static PostItem toPostItem(Post post, PostStats stats, int maxContentLength,
+ boolean isStudyMember, boolean isLiked) {
if (post.isPrivate() && !isStudyMember) {
- return toMaskedPostItem(post, stats);
+ return toMaskedPostItem(post, stats, isLiked);
}
- return toPublicPostItem(post, stats, maxContentLength);
+ return toPublicPostItem(post, stats, maxContentLength, isLiked);
}
- private static PostItem toPublicPostItem(Post post, PostStats stats, int maxContentLength) {
+ 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),
toWriterInfoResponse(post.getWriterInfo()),
post.getCreatedAt()
);
}
- private static PostItem toMaskedPostItem(Post post, PostStats stats) {
+ 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),
toWriterInfoResponse(post.getWriterInfo()),
post.getCreatedAt()
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/post/infrastructure/jpa/PostLikeRepository.java b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java
new file mode 100644
index 00000000..4bb4ee2b
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/post/infrastructure/jpa/PostLikeRepository.java
@@ -0,0 +1,31 @@
+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);
+}
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
index ea15849c..f65e1ac8 100644
--- 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
@@ -26,4 +26,12 @@ public interface PostStatsRepository extends JpaRepository {
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
index 9266c3d2..b4e74e75 100644
--- 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
@@ -3,15 +3,18 @@
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 kr.spot.post.domain.Comment;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@@ -24,6 +27,7 @@ public class PostQueryRepository {
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
@@ -80,4 +84,27 @@ public List findCommentsByPostId(long 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/PostCommandController.java b/modules/study/src/main/java/kr/spot/post/presentation/command/PostCommandController.java
index 3cf4fc82..610210c1 100644
--- 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
@@ -9,6 +9,7 @@
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.ManagePostRequest;
import lombok.RequiredArgsConstructor;
@@ -28,6 +29,7 @@
public class PostCommandController {
private final ManagePostService managePostService;
+ private final LikePostService likePostService;
@Operation(summary = "์คํฐ๋ ๊ฒ์๊ธ ์์ฑ", description = "์คํฐ๋์ ์๋ก์ด ๊ฒ์๊ธ์ ์์ฑํฉ๋๋ค.")
@ApiResponses({
@@ -108,4 +110,36 @@ public ResponseEntity> unpinPost(
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/query/dto/PostDetailResponse.java b/modules/study/src/main/java/kr/spot/post/presentation/query/dto/PostDetailResponse.java
index bc766a6f..4c729b70 100644
--- 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
@@ -11,6 +11,7 @@ public record PostDetailResponse(
String content,
boolean isPinned,
boolean isOwner,
+ boolean isLiked,
WriterInfoResponse writer,
PostStatsResponse stats,
LocalDateTime createdAt,
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
index 34713c89..c84eff4e 100644
--- 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
@@ -16,6 +16,7 @@ public record PostItem(
String title,
String content,
boolean isPinned,
+ boolean isLiked,
PostStatsResponse stats,
WriterInfoResponse writer,
LocalDateTime createdAt
@@ -26,11 +27,12 @@ public static PostItem of(
String title,
String content,
boolean isPinned,
+ boolean isLiked,
PostStatsResponse stats,
WriterInfoResponse writer,
LocalDateTime createdAt
) {
- return new PostItem(postId, title, content, isPinned, stats, writer, createdAt);
+ return new PostItem(postId, title, content, isPinned, isLiked, stats, writer, createdAt);
}
}
diff --git a/modules/study/src/test/java/kr/spot/post/application/command/LikePostServiceTest.java b/modules/study/src/test/java/kr/spot/post/application/command/LikePostServiceTest.java
new file mode 100644
index 00000000..8982f9e1
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/post/application/command/LikePostServiceTest.java
@@ -0,0 +1,132 @@
+package kr.spot.post.application.command;
+
+import static kr.spot.post.common.PostFixture.STUDY_ID;
+import static kr.spot.post.common.PostFixture.post;
+import static org.mockito.ArgumentMatchers.anyLong;
+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.post.domain.Post;
+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 org.junit.jupiter.api.BeforeEach;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class LikePostServiceTest {
+
+ static final long POST_ID = 1L;
+ static final long MEMBER_ID = 200L;
+ static final long GENERATED_ID = 999L;
+
+ @Mock
+ IdGenerator idGenerator;
+
+ @Mock
+ PostRepository postRepository;
+
+ @Mock
+ PostLikeRepository postLikeRepository;
+
+ @Mock
+ PostStatsRepository postStatsRepository;
+
+ @Mock
+ StudyAccessValidator accessValidator;
+
+ LikePostService likePostService;
+
+ @BeforeEach
+ void setUp() {
+ likePostService = new LikePostService(
+ idGenerator, postRepository, postLikeRepository, postStatsRepository, accessValidator
+ );
+ }
+
+ @Nested
+ @DisplayName("์ข์์ ์ถ๊ฐ (likePost)")
+ class LikePost {
+
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ๊ฒ์๊ธ์ ์ข์์๋ฅผ ์ถ๊ฐํ๋ค")
+ void should_like_post_when_member() {
+ // given
+ Post post = post(POST_ID, STUDY_ID, MEMBER_ID);
+ when(postRepository.getById(POST_ID)).thenReturn(post);
+ when(idGenerator.nextId()).thenReturn(GENERATED_ID);
+ when(postLikeRepository.savePostLike(GENERATED_ID, POST_ID, MEMBER_ID)).thenReturn(1);
+
+ // when
+ likePostService.likePost(STUDY_ID, POST_ID, MEMBER_ID);
+
+ // then
+ verify(accessValidator).validateStudyMember(STUDY_ID, MEMBER_ID);
+ verify(postLikeRepository).savePostLike(GENERATED_ID, POST_ID, MEMBER_ID);
+ verify(postStatsRepository).increaseLike(POST_ID);
+ }
+
+ @Test
+ @DisplayName("์ด๋ฏธ ์ข์์ํ ๊ฒ์๊ธ์ ๋ค์ ์ข์์ํ๋ฉด ์นด์ดํธ๊ฐ ์ฆ๊ฐํ์ง ์๋๋ค")
+ void should_not_increase_like_count_when_already_liked() {
+ // given
+ Post post = post(POST_ID, STUDY_ID, MEMBER_ID);
+ when(postRepository.getById(POST_ID)).thenReturn(post);
+ when(idGenerator.nextId()).thenReturn(GENERATED_ID);
+ when(postLikeRepository.savePostLike(GENERATED_ID, POST_ID, MEMBER_ID)).thenReturn(0);
+
+ // when
+ likePostService.likePost(STUDY_ID, POST_ID, MEMBER_ID);
+
+ // then
+ verify(postLikeRepository).savePostLike(GENERATED_ID, POST_ID, MEMBER_ID);
+ verify(postStatsRepository, never()).increaseLike(anyLong());
+ }
+ }
+
+ @Nested
+ @DisplayName("์ข์์ ์ทจ์ (unlikePost)")
+ class UnlikePost {
+
+ @Test
+ @DisplayName("์ข์์๋ฅผ ์ทจ์ํ๋ฉด ์นด์ดํธ๊ฐ ๊ฐ์ํ๋ค")
+ void should_unlike_post_and_decrease_count() {
+ // given
+ Post post = post(POST_ID, STUDY_ID, MEMBER_ID);
+ when(postRepository.getById(POST_ID)).thenReturn(post);
+ when(postLikeRepository.hardDelete(POST_ID, MEMBER_ID)).thenReturn(1);
+
+ // when
+ likePostService.unlikePost(STUDY_ID, POST_ID, MEMBER_ID);
+
+ // then
+ verify(accessValidator).validateStudyMember(STUDY_ID, MEMBER_ID);
+ verify(postLikeRepository).hardDelete(POST_ID, MEMBER_ID);
+ verify(postStatsRepository).decreaseLike(POST_ID);
+ }
+
+ @Test
+ @DisplayName("์ข์์ํ์ง ์์ ๊ฒ์๊ธ์ ์ทจ์ํด๋ ์นด์ดํธ๊ฐ ๋ณ๊ฒฝ๋์ง ์๋๋ค")
+ void should_not_decrease_count_when_not_liked() {
+ // given
+ Post post = post(POST_ID, STUDY_ID, MEMBER_ID);
+ when(postRepository.getById(POST_ID)).thenReturn(post);
+ when(postLikeRepository.hardDelete(POST_ID, MEMBER_ID)).thenReturn(0);
+
+ // when
+ likePostService.unlikePost(STUDY_ID, POST_ID, MEMBER_ID);
+
+ // then
+ verify(postLikeRepository).hardDelete(POST_ID, MEMBER_ID);
+ verify(postStatsRepository, never()).decreaseLike(anyLong());
+ }
+ }
+}
From 475767233cfcbea05bfaecf282983f065e88f40c Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 00:12:50 +0900
Subject: [PATCH 032/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=88=AC=EB=91=90=20=EA=B4=80=EB=A0=A8=20API=20=EA=B5=AC?=
=?UTF-8?q?=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/kr/spot/code/status/ErrorStatus.java | 5 +
.../command/ManageTodoService.java | 50 ++++++++
.../application/query/GetTodoService.java | 49 ++++++++
.../main/java/kr/spot/todo/domain/Todo.java | 77 ++++++++++++
.../infrastructure/jpa/TodoRepository.java | 14 +++
.../jpa/querydsl/TodoQueryRepository.java | 34 ++++++
.../command/TodoCommandController.java | 112 ++++++++++++++++++
.../command/dto/CreateTodoRequest.java | 15 +++
.../command/dto/UpdateTodoRequest.java | 15 +++
.../query/TodoQueryController.java | 46 +++++++
.../query/dto/GetTodoListResponse.java | 40 +++++++
11 files changed, 457 insertions(+)
create mode 100644 modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/domain/Todo.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java
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 49965980..8d8e4600 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
@@ -66,6 +66,11 @@ public enum ErrorStatus implements BaseErrorCode {
// ์ผ์ ๊ด๋ จ
_SCHEDULE_NOT_FOUND(404, "SCHEDULE404", "์ผ์ ์ ์ฐพ์ ์ ์์ต๋๋ค."),
_SCHEDULE_ACCESS_DENIED(403, "SCHEDULE403", "ํด๋น ์คํฐ๋์ ์ํ๋ ์ผ์ ์ด ์๋๋๋ค."),
+
+ // ํฌ๋ ๊ด๋ จ
+ _TODO_NOT_FOUND(404, "TODO404", "ํฌ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค."),
+ _TODO_ACCESS_DENIED(403, "TODO403", "ํด๋น ์คํฐ๋์ ์ํ๋ ํฌ๋๊ฐ ์๋๋๋ค."),
+ _ONLY_TODO_OWNER_CAN_MODIFY(403, "TODO4030", "ํฌ๋ ์์ ์ ์์ฑ์๋ง ๊ฐ๋ฅํฉ๋๋ค."),
;
private final int httpStatus;
diff --git a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
new file mode 100644
index 00000000..e2aedb79
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
@@ -0,0 +1,50 @@
+package kr.spot.todo.application.command;
+
+import kr.spot.IdGenerator;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.infrastructure.jpa.TodoRepository;
+import kr.spot.todo.presentation.command.dto.CreateTodoRequest;
+import kr.spot.todo.presentation.command.dto.UpdateTodoRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class ManageTodoService {
+
+ private final IdGenerator idGenerator;
+ private final TodoRepository todoRepository;
+
+ public void createTodo(long studyId, long memberId, CreateTodoRequest request) {
+ Todo todo = Todo.of(
+ idGenerator.nextId(),
+ studyId,
+ memberId,
+ request.dueDate(),
+ request.content()
+ );
+ todoRepository.save(todo);
+ }
+
+ public void updateTodo(long studyId, long todoId, long memberId, UpdateTodoRequest request) {
+ Todo todo = todoRepository.getById(todoId);
+ todo.update(request.content(), request.dueDate(), memberId);
+ }
+
+ public void completeTodo(long studyId, long todoId, long memberId) {
+ Todo todo = todoRepository.getById(todoId);
+ todo.complete(memberId);
+ }
+
+ public void uncompleteTodo(long studyId, long todoId, long memberId) {
+ Todo todo = todoRepository.getById(todoId);
+ todo.uncomplete(memberId);
+ }
+
+ public void deleteTodo(long studyId, long todoId, long memberId) {
+ Todo todo = todoRepository.getById(todoId);
+ todo.delete(studyId, memberId);
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java
new file mode 100644
index 00000000..c07c51b4
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java
@@ -0,0 +1,49 @@
+package kr.spot.todo.application.query;
+
+import java.time.LocalDate;
+import java.util.List;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.infrastructure.jpa.querydsl.TodoQueryRepository;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse.TodoResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class GetTodoService {
+
+ private final TodoQueryRepository todoQueryRepository;
+
+ public GetTodoListResponse getTodosByDate(long studyId, long memberId, LocalDate dueDate) {
+ List todos = todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(
+ studyId, memberId, dueDate);
+
+ return toResponse(todos);
+ }
+
+ private GetTodoListResponse toResponse(List todos) {
+ List pending = todos.stream()
+ .filter(todo -> !Boolean.TRUE.equals(todo.getIsCompleted()))
+ .map(this::toTodoResponse)
+ .toList();
+
+ List completed = todos.stream()
+ .filter(todo -> Boolean.TRUE.equals(todo.getIsCompleted()))
+ .map(this::toTodoResponse)
+ .toList();
+
+ return GetTodoListResponse.from(pending, completed);
+ }
+
+ private TodoResponse toTodoResponse(Todo todo) {
+ return TodoResponse.from(
+ todo.getId(),
+ todo.getContent(),
+ todo.getDueDate(),
+ todo.getIsCompleted()
+ );
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
new file mode 100644
index 00000000..a86ad8a3
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
@@ -0,0 +1,77 @@
+package kr.spot.todo.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import java.time.LocalDate;
+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 todo SET status = 'INACTIVE' WHERE id = ?")
+@SQLRestriction("status = 'ACTIVE'")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class Todo extends BaseEntity {
+
+ @Id
+ private Long id;
+
+ private Long studyId;
+
+ private Long memberId;
+
+ @Column(nullable = false)
+ private LocalDate dueDate;
+
+ @Column(nullable = false)
+ private String content;
+
+ private Boolean isCompleted;
+
+ public static Todo of(Long id, Long studyId, Long memberId, LocalDate dueDate, String content) {
+ return new Todo(id, studyId, memberId, dueDate, content, false);
+ }
+
+ public void update(String content, LocalDate dueDate, long memberId) {
+ validateOwner(memberId);
+ this.content = content;
+ this.dueDate = dueDate;
+ }
+
+ public void complete(long memberId) {
+ validateOwner(memberId);
+ this.isCompleted = true;
+ }
+
+ public void uncomplete(long memberId) {
+ validateOwner(memberId);
+ this.isCompleted = false;
+ }
+
+ public void delete(long studyId, long memberId) {
+ validateAccess(studyId);
+ validateOwner(memberId);
+ super.delete();
+ }
+
+ private void validateAccess(long studyId) {
+ if (this.studyId != studyId) {
+ throw new GeneralException(ErrorStatus._TODO_ACCESS_DENIED);
+ }
+ }
+
+ private void validateOwner(long memberId) {
+ if (this.memberId != memberId) {
+ throw new GeneralException(ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java
new file mode 100644
index 00000000..623fe3c7
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java
@@ -0,0 +1,14 @@
+package kr.spot.todo.infrastructure.jpa;
+
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import kr.spot.todo.domain.Todo;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface TodoRepository extends JpaRepository {
+
+ default Todo getById(long todoId) {
+ return findById(todoId).orElseThrow(
+ () -> new GeneralException(ErrorStatus._TODO_NOT_FOUND));
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java
new file mode 100644
index 00000000..64d21f3a
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java
@@ -0,0 +1,34 @@
+package kr.spot.todo.infrastructure.jpa.querydsl;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import java.time.LocalDate;
+import java.util.List;
+import kr.spot.todo.domain.QTodo;
+import kr.spot.todo.domain.Todo;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class TodoQueryRepository {
+
+ private final JPAQueryFactory query;
+
+ public List findByStudyIdAndMemberIdAndDueDate(Long studyId, Long memberId,
+ LocalDate dueDate) {
+ QTodo todo = QTodo.todo;
+
+ return query
+ .selectFrom(todo)
+ .where(
+ todo.studyId.eq(studyId),
+ todo.memberId.eq(memberId),
+ todo.dueDate.eq(dueDate)
+ )
+ .orderBy(
+ todo.isCompleted.asc(),
+ todo.createdAt.desc()
+ )
+ .fetch();
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java
new file mode 100644
index 00000000..4f7ab5a7
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java
@@ -0,0 +1,112 @@
+package kr.spot.todo.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.todo.application.command.ManageTodoService;
+import kr.spot.todo.presentation.command.dto.CreateTodoRequest;
+import kr.spot.todo.presentation.command.dto.UpdateTodoRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "์คํฐ๋ ํฌ๋")
+@RestController
+@RequestMapping("/api/studies/{studyId}/todos")
+@RequiredArgsConstructor
+public class TodoCommandController {
+
+ private final ManageTodoService manageTodoService;
+
+ @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> createTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @CurrentMember @Parameter(hidden = true) Long memberId,
+ @RequestBody CreateTodoRequest request) {
+ manageTodoService.createTodo(studyId, memberId, request);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ }
+
+ @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 = "`TODO4030`: ํฌ๋ ์์ ์ ์์ฑ์๋ง ๊ฐ๋ฅํฉ๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํฌ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
+ })
+ @PatchMapping("/{todoId}")
+ public ResponseEntity> updateTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "ํฌ๋ ID", required = true) @PathVariable Long todoId,
+ @CurrentMember @Parameter(hidden = true) Long memberId,
+ @RequestBody UpdateTodoRequest request) {
+ manageTodoService.updateTodo(studyId, todoId, memberId, request);
+ 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 = "`TODO4030`: ํฌ๋ ์์ ์ ์์ฑ์๋ง ๊ฐ๋ฅํฉ๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํฌ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
+ })
+ @PostMapping("/{todoId}/complete")
+ public ResponseEntity> completeTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "ํฌ๋ ID", required = true) @PathVariable Long todoId,
+ @CurrentMember @Parameter(hidden = true) Long memberId) {
+ manageTodoService.completeTodo(studyId, todoId, 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 = "`TODO4030`: ํฌ๋ ์์ ์ ์์ฑ์๋ง ๊ฐ๋ฅํฉ๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํฌ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
+ })
+ @PostMapping("/{todoId}/uncomplete")
+ public ResponseEntity> uncompleteTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "ํฌ๋ ID", required = true) @PathVariable Long todoId,
+ @CurrentMember @Parameter(hidden = true) Long memberId) {
+ manageTodoService.uncompleteTodo(studyId, todoId, 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 = "`TODO403`: ํด๋น ์คํฐ๋์ ์ํ๋ ํฌ๋๊ฐ ์๋๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํฌ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
+ })
+ @DeleteMapping("/{todoId}")
+ public ResponseEntity> deleteTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "ํฌ๋ ID", required = true) @PathVariable Long todoId,
+ @CurrentMember @Parameter(hidden = true) Long memberId) {
+ manageTodoService.deleteTodo(studyId, todoId, memberId);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK));
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java
new file mode 100644
index 00000000..72e8f4f6
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java
@@ -0,0 +1,15 @@
+package kr.spot.todo.presentation.command.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+
+@Schema(description = "ํฌ๋ ์์ฑ ์์ฒญ")
+public record CreateTodoRequest(
+ @Schema(description = "ํฌ๋ ๋ด์ฉ", example = "๊ณผ์ ์ ์ถํ๊ธฐ")
+ String content,
+
+ @Schema(description = "๋ง๊ฐ์ผ", example = "2025-01-15")
+ LocalDate dueDate
+) {
+
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java
new file mode 100644
index 00000000..33e21561
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java
@@ -0,0 +1,15 @@
+package kr.spot.todo.presentation.command.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+
+@Schema(description = "ํฌ๋ ์์ ์์ฒญ")
+public record UpdateTodoRequest(
+ @Schema(description = "ํฌ๋ ๋ด์ฉ", example = "๊ณผ์ ์ ์ถํ๊ธฐ")
+ String content,
+
+ @Schema(description = "๋ง๊ฐ์ผ", example = "2025-01-15")
+ LocalDate dueDate
+) {
+
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java b/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java
new file mode 100644
index 00000000..8d977128
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java
@@ -0,0 +1,46 @@
+package kr.spot.todo.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.code.status.SuccessStatus;
+import kr.spot.todo.application.query.GetTodoService;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse;
+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}/todos")
+@RequiredArgsConstructor
+public class TodoQueryController {
+
+ private final GetTodoService getTodoService;
+
+ @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("/members/{memberId}")
+ public ResponseEntity> getTodosByDate(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @PathVariable @Parameter(hidden = true) Long memberId,
+ @Parameter(description = "์กฐํํ ๋ ์ง (ISO 8601 ํ์ค)", example = "2025-01-15")
+ @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate date) {
+ GetTodoListResponse response = getTodoService.getTodosByDate(studyId, memberId, date);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response));
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java b/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java
new file mode 100644
index 00000000..e937d0c0
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java
@@ -0,0 +1,40 @@
+package kr.spot.todo.presentation.query.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import java.util.List;
+
+@Schema(description = "ํฌ๋ ๋ชฉ๋ก ์๋ต")
+public record GetTodoListResponse(
+ @Schema(description = "๋ฏธ์๋ฃ ํฌ๋ ๋ชฉ๋ก")
+ List pending,
+
+ @Schema(description = "์๋ฃ๋ ํฌ๋ ๋ชฉ๋ก")
+ List completed
+) {
+
+ public static GetTodoListResponse from(List pending, List completed) {
+ return new GetTodoListResponse(pending, completed);
+ }
+
+ @Schema(description = "ํฌ๋ ์์ธ ์ ๋ณด")
+ public record TodoResponse(
+ @Schema(description = "ํฌ๋ ID", example = "1234567890")
+ Long id,
+
+ @Schema(description = "ํฌ๋ ๋ด์ฉ", example = "๊ณผ์ ์ ์ถํ๊ธฐ")
+ String content,
+
+ @Schema(description = "๋ง๊ฐ์ผ", example = "2025-01-15")
+ LocalDate dueDate,
+
+ @Schema(description = "์๋ฃ ์ฌ๋ถ", example = "false")
+ Boolean isCompleted
+ ) {
+
+ public static TodoResponse from(Long id, String content, LocalDate dueDate,
+ Boolean isCompleted) {
+ return new TodoResponse(id, content, dueDate, isCompleted);
+ }
+ }
+}
From e11d3262192dee4a227375b37c03ea11bb832e9e Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 13:15:27 +0900
Subject: [PATCH 033/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=88=AC=EB=91=90=20=EA=B4=80=EB=A0=A8=20API=20=EA=B2=80?=
=?UTF-8?q?=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?=
=?UTF-8?q?=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManageTodoService.java | 6 +-
.../main/java/kr/spot/todo/domain/Todo.java | 9 +-
.../command/ManageTodoServiceTest.java | 334 ++++++++++++++++++
.../application/query/GetTodoServiceTest.java | 139 ++++++++
.../java/kr/spot/todo/common/TodoFixture.java | 49 +++
.../java/kr/spot/todo/domain/TodoTest.java | 218 ++++++++++++
6 files changed, 749 insertions(+), 6 deletions(-)
create mode 100644 modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java
create mode 100644 modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java
diff --git a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
index e2aedb79..a8631b31 100644
--- a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
+++ b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
@@ -30,17 +30,17 @@ public void createTodo(long studyId, long memberId, CreateTodoRequest request) {
public void updateTodo(long studyId, long todoId, long memberId, UpdateTodoRequest request) {
Todo todo = todoRepository.getById(todoId);
- todo.update(request.content(), request.dueDate(), memberId);
+ todo.update(studyId, request.content(), request.dueDate(), memberId);
}
public void completeTodo(long studyId, long todoId, long memberId) {
Todo todo = todoRepository.getById(todoId);
- todo.complete(memberId);
+ todo.complete(studyId, memberId);
}
public void uncompleteTodo(long studyId, long todoId, long memberId) {
Todo todo = todoRepository.getById(todoId);
- todo.uncomplete(memberId);
+ todo.uncomplete(studyId, memberId);
}
public void deleteTodo(long studyId, long todoId, long memberId) {
diff --git a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
index a86ad8a3..2058d06e 100644
--- a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
+++ b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
@@ -41,18 +41,21 @@ public static Todo of(Long id, Long studyId, Long memberId, LocalDate dueDate, S
return new Todo(id, studyId, memberId, dueDate, content, false);
}
- public void update(String content, LocalDate dueDate, long memberId) {
+ public void update(long studyId, String content, LocalDate dueDate, long memberId) {
+ validateAccess(studyId);
validateOwner(memberId);
this.content = content;
this.dueDate = dueDate;
}
- public void complete(long memberId) {
+ public void complete(long studyId, long memberId) {
+ validateAccess(studyId);
validateOwner(memberId);
this.isCompleted = true;
}
- public void uncomplete(long memberId) {
+ public void uncomplete(long studyId, long memberId) {
+ validateAccess(studyId);
validateOwner(memberId);
this.isCompleted = false;
}
diff --git a/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java b/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java
new file mode 100644
index 00000000..5f52049c
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java
@@ -0,0 +1,334 @@
+package kr.spot.todo.application.command;
+
+import static kr.spot.todo.common.TodoFixture.CONTENT;
+import static kr.spot.todo.common.TodoFixture.DUE_DATE;
+import static kr.spot.todo.common.TodoFixture.MEMBER_ID;
+import static kr.spot.todo.common.TodoFixture.STUDY_ID;
+import static kr.spot.todo.common.TodoFixture.createTodoRequest;
+import static kr.spot.todo.common.TodoFixture.todo;
+import static kr.spot.todo.common.TodoFixture.updateTodoRequest;
+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.verify;
+import static org.mockito.Mockito.when;
+
+import kr.spot.IdGenerator;
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.infrastructure.jpa.TodoRepository;
+import kr.spot.todo.presentation.command.dto.CreateTodoRequest;
+import kr.spot.todo.presentation.command.dto.UpdateTodoRequest;
+import org.junit.jupiter.api.BeforeEach;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class ManageTodoServiceTest {
+
+ @Mock
+ IdGenerator idGenerator;
+
+ @Mock
+ TodoRepository todoRepository;
+
+ @Captor
+ ArgumentCaptor todoCaptor;
+
+ ManageTodoService manageTodoService;
+
+ @BeforeEach
+ void setUp() {
+ manageTodoService = new ManageTodoService(idGenerator, todoRepository);
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์์ฑ (createTodo)")
+ class CreateTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_todo_successfully() {
+ // given
+ Long generatedId = 1L;
+ CreateTodoRequest request = createTodoRequest();
+
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(todoRepository.save(any(Todo.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ manageTodoService.createTodo(STUDY_ID, MEMBER_ID, request);
+
+ // then
+ verify(todoRepository).save(todoCaptor.capture());
+
+ Todo capturedTodo = todoCaptor.getValue();
+ assertThat(capturedTodo.getId()).isEqualTo(generatedId);
+ assertThat(capturedTodo.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(capturedTodo.getMemberId()).isEqualTo(MEMBER_ID);
+ assertThat(capturedTodo.getContent()).isEqualTo(CONTENT);
+ assertThat(capturedTodo.getDueDate()).isEqualTo(DUE_DATE);
+ assertThat(capturedTodo.getIsCompleted()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์์ (updateTodo)")
+ class UpdateTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์์ ํ ์ ์๋ค")
+ void should_update_todo_successfully() {
+ // given
+ long todoId = 1L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+ UpdateTodoRequest request = updateTodoRequest();
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when
+ manageTodoService.updateTodo(STUDY_ID, todoId, MEMBER_ID, request);
+
+ // then
+ verify(todoRepository).getById(todoId);
+ assertThat(todo.getContent()).isEqualTo(request.content());
+ assertThat(todo.getDueDate()).isEqualTo(request.dueDate());
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_other_study_todo() {
+ // given
+ long todoId = 1L;
+ long otherStudyId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+ UpdateTodoRequest request = updateTodoRequest();
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.updateTodo(otherStudyId, todoId, MEMBER_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_by_non_owner() {
+ // given
+ long todoId = 1L;
+ long otherMemberId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+ UpdateTodoRequest request = updateTodoRequest();
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.updateTodo(STUDY_ID, todoId, otherMemberId, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ํฌ๋๋ฅผ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_todo_not_found() {
+ // given
+ long todoId = 999L;
+ UpdateTodoRequest request = updateTodoRequest();
+
+ when(todoRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._TODO_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.updateTodo(STUDY_ID, todoId, MEMBER_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_NOT_FOUND);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์๋ฃ (completeTodo)")
+ class CompleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์๋ฃํ ์ ์๋ค")
+ void should_complete_todo_successfully() {
+ // given
+ long todoId = 1L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when
+ manageTodoService.completeTodo(STUDY_ID, todoId, MEMBER_ID);
+
+ // then
+ verify(todoRepository).getById(todoId);
+ assertThat(todo.getIsCompleted()).isTrue();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ์๋ฃํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_complete_other_study_todo() {
+ // given
+ long todoId = 1L;
+ long otherStudyId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.completeTodo(otherStudyId, todoId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์๋ฃํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_complete_by_non_owner() {
+ // given
+ long todoId = 1L;
+ long otherMemberId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.completeTodo(STUDY_ID, todoId, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ๋ฏธ์๋ฃ (uncompleteTodo)")
+ class UncompleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ ์ ์๋ค")
+ void should_uncomplete_todo_successfully() {
+ // given
+ long todoId = 1L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+ todo.complete(STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when
+ manageTodoService.uncompleteTodo(STUDY_ID, todoId, MEMBER_ID);
+
+ // then
+ verify(todoRepository).getById(todoId);
+ assertThat(todo.getIsCompleted()).isFalse();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_uncomplete_other_study_todo() {
+ // given
+ long todoId = 1L;
+ long otherStudyId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.uncompleteTodo(otherStudyId, todoId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_uncomplete_by_non_owner() {
+ // given
+ long todoId = 1L;
+ long otherMemberId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.uncompleteTodo(STUDY_ID, todoId, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์ญ์ (deleteTodo)")
+ class DeleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_todo_successfully() {
+ // given
+ long todoId = 1L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when
+ manageTodoService.deleteTodo(STUDY_ID, todoId, MEMBER_ID);
+
+ // then
+ verify(todoRepository).getById(todoId);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_other_study_todo() {
+ // given
+ long todoId = 1L;
+ long otherStudyId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.deleteTodo(otherStudyId, todoId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_by_non_owner() {
+ // given
+ long todoId = 1L;
+ long otherMemberId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.deleteTodo(STUDY_ID, todoId, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ํฌ๋๋ฅผ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_todo_not_found() {
+ // given
+ long todoId = 999L;
+
+ when(todoRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._TODO_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.deleteTodo(STUDY_ID, todoId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_NOT_FOUND);
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java b/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java
new file mode 100644
index 00000000..648e9043
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java
@@ -0,0 +1,139 @@
+package kr.spot.todo.application.query;
+
+import static kr.spot.todo.common.TodoFixture.DUE_DATE;
+import static kr.spot.todo.common.TodoFixture.MEMBER_ID;
+import static kr.spot.todo.common.TodoFixture.STUDY_ID;
+import static kr.spot.todo.common.TodoFixture.completedTodo;
+import static kr.spot.todo.common.TodoFixture.todo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.List;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.infrastructure.jpa.querydsl.TodoQueryRepository;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse.TodoResponse;
+import org.junit.jupiter.api.BeforeEach;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class GetTodoServiceTest {
+
+ @Mock
+ TodoQueryRepository todoQueryRepository;
+
+ GetTodoService getTodoService;
+
+ @BeforeEach
+ void setUp() {
+ getTodoService = new GetTodoService(todoQueryRepository);
+ }
+
+ @Nested
+ @DisplayName("๋ ์ง๋ณ ํฌ๋ ์กฐํ (getTodosByDate)")
+ class GetTodosByDate {
+
+ @Test
+ @DisplayName("ํน์ ๋ ์ง์ ํฌ๋ ๋ชฉ๋ก์ ์กฐํํ ์ ์๋ค")
+ void should_get_todos_by_date_successfully() {
+ // given
+ Todo pendingTodo = todo(1L);
+ Todo completedTodo = completedTodo();
+
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(List.of(pendingTodo, completedTodo));
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ verify(todoQueryRepository).findByStudyIdAndMemberIdAndDueDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+ assertThat(response.pending()).hasSize(1);
+ assertThat(response.completed()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("๋ฏธ์๋ฃ ํฌ๋๋ง ์๋ ๊ฒฝ์ฐ pending ๋ชฉ๋ก๋ง ๋ฐํ๋๋ค")
+ void should_return_only_pending_todos_when_no_completed() {
+ // given
+ Todo pendingTodo1 = todo(1L);
+ Todo pendingTodo2 = todo(2L);
+
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(List.of(pendingTodo1, pendingTodo2));
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ assertThat(response.pending()).hasSize(2);
+ assertThat(response.completed()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("์๋ฃ๋ ํฌ๋๋ง ์๋ ๊ฒฝ์ฐ completed ๋ชฉ๋ก๋ง ๋ฐํ๋๋ค")
+ void should_return_only_completed_todos_when_no_pending() {
+ // given
+ Todo completedTodo1 = todo(1L);
+ completedTodo1.complete(STUDY_ID, MEMBER_ID);
+ Todo completedTodo2 = todo(2L);
+ completedTodo2.complete(STUDY_ID, MEMBER_ID);
+
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(List.of(completedTodo1, completedTodo2));
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ assertThat(response.pending()).isEmpty();
+ assertThat(response.completed()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋๊ฐ ์๋ ๊ฒฝ์ฐ ๋น ๋ชฉ๋ก์ด ๋ฐํ๋๋ค")
+ void should_return_empty_lists_when_no_todos() {
+ // given
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(Collections.emptyList());
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ assertThat(response.pending()).isEmpty();
+ assertThat(response.completed()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์๋ต์ ์ฌ๋ฐ๋ฅธ ์ ๋ณด๊ฐ ํฌํจ๋๋ค")
+ void should_return_correct_todo_information() {
+ // given
+ Todo todo = todo(1L);
+
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(List.of(todo));
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ assertThat(response.pending()).hasSize(1);
+ TodoResponse todoResponse = response.pending().get(0);
+ assertThat(todoResponse.id()).isEqualTo(todo.getId());
+ assertThat(todoResponse.content()).isEqualTo(todo.getContent());
+ assertThat(todoResponse.dueDate()).isEqualTo(todo.getDueDate());
+ assertThat(todoResponse.isCompleted()).isEqualTo(todo.getIsCompleted());
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java b/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java
new file mode 100644
index 00000000..9580143c
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java
@@ -0,0 +1,49 @@
+package kr.spot.todo.common;
+
+import java.time.LocalDate;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.presentation.command.dto.CreateTodoRequest;
+import kr.spot.todo.presentation.command.dto.UpdateTodoRequest;
+
+public class TodoFixture {
+
+ public static final Long ID = 1L;
+ public static final Long STUDY_ID = 100L;
+ public static final Long MEMBER_ID = 200L;
+ public static final LocalDate DUE_DATE = LocalDate.of(2025, 1, 15);
+ public static final String CONTENT = "๊ณผ์ ์ ์ถํ๊ธฐ";
+
+ public static Todo todo() {
+ return Todo.of(ID, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT);
+ }
+
+ public static Todo todo(Long id) {
+ return Todo.of(id, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT);
+ }
+
+ public static Todo todo(Long id, Long studyId, Long memberId) {
+ return Todo.of(id, studyId, memberId, DUE_DATE, CONTENT);
+ }
+
+ public static Todo completedTodo() {
+ Todo todo = todo();
+ todo.complete(STUDY_ID, MEMBER_ID);
+ return todo;
+ }
+
+ public static CreateTodoRequest createTodoRequest() {
+ return new CreateTodoRequest(CONTENT, DUE_DATE);
+ }
+
+ public static CreateTodoRequest createTodoRequest(String content, LocalDate dueDate) {
+ return new CreateTodoRequest(content, dueDate);
+ }
+
+ public static UpdateTodoRequest updateTodoRequest() {
+ return new UpdateTodoRequest("์์ ๋ ๋ด์ฉ", LocalDate.of(2025, 1, 20));
+ }
+
+ public static UpdateTodoRequest updateTodoRequest(String content, LocalDate dueDate) {
+ return new UpdateTodoRequest(content, dueDate);
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java b/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java
new file mode 100644
index 00000000..c352b3c4
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java
@@ -0,0 +1,218 @@
+package kr.spot.todo.domain;
+
+import static kr.spot.todo.common.TodoFixture.CONTENT;
+import static kr.spot.todo.common.TodoFixture.DUE_DATE;
+import static kr.spot.todo.common.TodoFixture.ID;
+import static kr.spot.todo.common.TodoFixture.MEMBER_ID;
+import static kr.spot.todo.common.TodoFixture.STUDY_ID;
+import static kr.spot.todo.common.TodoFixture.todo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.LocalDate;
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class TodoTest {
+
+ @Nested
+ @DisplayName("ํฌ๋ ์์ฑ (of)")
+ class CreateTodo {
+
+ @Test
+ @DisplayName("ํฌ๋ ๊ฐ์ฒด๋ฅผ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_todo_successfully() {
+ // when
+ Todo todo = Todo.of(ID, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT);
+
+ // then
+ assertThat(todo).isNotNull();
+ assertThat(todo.getId()).isEqualTo(ID);
+ assertThat(todo.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(todo.getMemberId()).isEqualTo(MEMBER_ID);
+ assertThat(todo.getDueDate()).isEqualTo(DUE_DATE);
+ assertThat(todo.getContent()).isEqualTo(CONTENT);
+ assertThat(todo.getIsCompleted()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์์ (update)")
+ class UpdateTodo {
+
+ @Test
+ @DisplayName("ํฌ๋ ๋ด์ฉ๊ณผ ๋ง๊ฐ์ผ์ ์ ์์ ์ผ๋ก ์์ ํ ์ ์๋ค")
+ void should_update_todo_successfully() {
+ // given
+ Todo todo = todo();
+ String newContent = "์์ ๋ ๋ด์ฉ";
+ LocalDate newDueDate = LocalDate.of(2025, 1, 20);
+
+ // when
+ todo.update(STUDY_ID, newContent, newDueDate, MEMBER_ID);
+
+ // then
+ assertThat(todo.getContent()).isEqualTo(newContent);
+ assertThat(todo.getDueDate()).isEqualTo(newDueDate);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ํฌ๋๋ฅผ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_with_different_study_id() {
+ // given
+ Todo todo = todo();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.update(otherStudyId, "๋ด์ฉ", DUE_DATE, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_by_non_owner() {
+ // given
+ Todo todo = todo();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.update(STUDY_ID, "๋ด์ฉ", DUE_DATE, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์๋ฃ (complete)")
+ class CompleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์๋ฃ ์ํ๋ก ๋ณ๊ฒฝํ ์ ์๋ค")
+ void should_complete_todo_successfully() {
+ // given
+ Todo todo = todo();
+
+ // when
+ todo.complete(STUDY_ID, MEMBER_ID);
+
+ // then
+ assertThat(todo.getIsCompleted()).isTrue();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ํฌ๋๋ฅผ ์๋ฃํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_complete_with_different_study_id() {
+ // given
+ Todo todo = todo();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.complete(otherStudyId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์๋ฃํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_complete_by_non_owner() {
+ // given
+ Todo todo = todo();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.complete(STUDY_ID, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ๋ฏธ์๋ฃ (uncomplete)")
+ class UncompleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ๋ฏธ์๋ฃ ์ํ๋ก ๋ณ๊ฒฝํ ์ ์๋ค")
+ void should_uncomplete_todo_successfully() {
+ // given
+ Todo todo = todo();
+ todo.complete(STUDY_ID, MEMBER_ID);
+
+ // when
+ todo.uncomplete(STUDY_ID, MEMBER_ID);
+
+ // then
+ assertThat(todo.getIsCompleted()).isFalse();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ํฌ๋๋ฅผ ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_uncomplete_with_different_study_id() {
+ // given
+ Todo todo = todo();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.uncomplete(otherStudyId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_uncomplete_by_non_owner() {
+ // given
+ Todo todo = todo();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.uncomplete(STUDY_ID, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์ญ์ (delete)")
+ class DeleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_todo_successfully() {
+ // given
+ Todo todo = todo();
+
+ // when & then (์์ธ๊ฐ ๋ฐ์ํ์ง ์์ผ๋ฉด ์ฑ๊ณต)
+ todo.delete(STUDY_ID, MEMBER_ID);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_with_different_study_id() {
+ // given
+ Todo todo = todo();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.delete(otherStudyId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_by_non_owner() {
+ // given
+ Todo todo = todo();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.delete(STUDY_ID, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+}
From 1402fb5247e43c283745f504d227411e0afb76b4 Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 13:23:30 +0900
Subject: [PATCH 034/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?=
=?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B5=AC=EC=A1=B0=20=EC=84=A4?=
=?UTF-8?q?=EA=B3=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/kr/spot/review/domain/Review.java | 36 +++++++++++++++++
.../domain/associations/ReviewReaction.java | 34 ++++++++++++++++
.../kr/spot/review/domain/enums/Reaction.java | 8 ++++
.../kr/spot/review/domain/vo/Content.java | 32 +++++++++++++++
.../kr/spot/review/domain/vo/WriterInfo.java | 40 +++++++++++++++++++
5 files changed, 150 insertions(+)
create mode 100644 modules/study/src/main/java/kr/spot/review/domain/Review.java
create mode 100644 modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java
create mode 100644 modules/study/src/main/java/kr/spot/review/domain/enums/Reaction.java
create mode 100644 modules/study/src/main/java/kr/spot/review/domain/vo/Content.java
create mode 100644 modules/study/src/main/java/kr/spot/review/domain/vo/WriterInfo.java
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..1a0c755c
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/domain/Review.java
@@ -0,0 +1,36 @@
+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;
+
+ @Embedded
+ private WriterInfo writerInfo;
+
+ @Embedded
+ private Content content;
+
+ public static Review of(Long id, WriterInfo writerInfo, Content content) {
+ return new Review(id, writerInfo, content);
+ }
+}
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..7063b6ff
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/domain/associations/ReviewReaction.java
@@ -0,0 +1,34 @@
+package kr.spot.review.domain.associations;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+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)
+public class ReviewReaction extends BaseEntity {
+
+ @Id
+ private Long id;
+
+ private Long reviewId;
+
+ private Long memberId;
+
+ 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..8219e968
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/domain/vo/Content.java
@@ -0,0 +1,32 @@
+package kr.spot.review.domain.vo;
+
+import jakarta.persistence.Embeddable;
+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;
+
+ public static Content of(String activity, String learned,
+ String encouragement, String imageUrl) {
+ return new Content(activity, learned, encouragement, imageUrl);
+ }
+}
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);
+ }
+}
From e2b23e9ab4306ad871bf29c3f215691a1c9fedda Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 13:55:47 +0900
Subject: [PATCH 035/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EA=B4=80=EB=A0=A8=20API=20?=
=?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/kr/spot/code/status/ErrorStatus.java | 11 ++
.../command/ManageReviewReactionService.java | 52 ++++++++
.../command/ManageReviewService.java | 67 ++++++++++
.../application/query/GetReviewService.java | 108 ++++++++++++++++
.../java/kr/spot/review/domain/Review.java | 13 +-
.../jpa/ReviewReactionRepository.java | 29 +++++
.../infrastructure/jpa/ReviewRepository.java | 14 ++
.../jpa/querydsl/ReviewQueryRepository.java | 121 ++++++++++++++++++
.../command/ReviewCommandController.java | 74 +++++++++++
.../command/dto/CreateReviewRequest.java | 13 ++
.../query/ReviewQueryController.java | 38 ++++++
.../query/dto/GetReviewListResponse.java | 82 ++++++++++++
12 files changed, 620 insertions(+), 2 deletions(-)
create mode 100644 modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java
create mode 100644 modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java
create mode 100644 modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java
create mode 100644 modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/review/infrastructure/jpa/querydsl/ReviewQueryRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java
create mode 100644 modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewRequest.java
create mode 100644 modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java
create mode 100644 modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java
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 49965980..e7a8a4a3 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
@@ -66,6 +66,17 @@ public enum ErrorStatus implements BaseErrorCode {
// ์ผ์ ๊ด๋ จ
_SCHEDULE_NOT_FOUND(404, "SCHEDULE404", "์ผ์ ์ ์ฐพ์ ์ ์์ต๋๋ค."),
_SCHEDULE_ACCESS_DENIED(403, "SCHEDULE403", "ํด๋น ์คํฐ๋์ ์ํ๋ ์ผ์ ์ด ์๋๋๋ค."),
+
+ // ํฌ๋ ๊ด๋ จ
+ _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", "๋ฐ์์ ์ฐพ์ ์ ์์ต๋๋ค."),
;
private final int httpStatus;
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..5ba2a402
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewReactionService.java
@@ -0,0 +1,52 @@
+package kr.spot.review.application.command;
+
+import kr.spot.IdGenerator;
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+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);
+ validateReviewExists(reviewId);
+
+ if (reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(reviewId, memberId,
+ reaction)) {
+ throw new GeneralException(ErrorStatus._ALREADY_REACTED);
+ }
+
+ 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);
+ validateReviewExists(reviewId);
+
+ int deleted = reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name());
+ if (deleted == 0) {
+ throw new GeneralException(ErrorStatus._REACTION_NOT_FOUND);
+ }
+ }
+
+ private void validateReviewExists(long reviewId) {
+ reviewRepository.getById(reviewId);
+ }
+}
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..e8d83492
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/application/command/ManageReviewService.java
@@ -0,0 +1,67 @@
+package kr.spot.review.application.command;
+
+import kr.spot.IdGenerator;
+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 final IdGenerator idGenerator;
+ private final GetWriterInfoPort getWriterInfoPort;
+ private final FileStoragePort fileStoragePort;
+ private final ReviewRepository reviewRepository;
+ private final StudyAccessValidator studyAccessValidator;
+
+ public void createReview(long studyId, long memberId, CreateReviewRequest request,
+ MultipartFile imageFile) {
+ studyAccessValidator.validateStudyMember(studyId, memberId);
+
+ WriterInfo writerInfo = getWriterInfo(memberId);
+ String imageUrl = uploadImage(imageFile);
+ Content content = Content.of(request.activity(), request.learned(), request.encouragement(),
+ imageUrl);
+
+ Review review = Review.of(idGenerator.nextId(), studyId, writerInfo, content,
+ request.isPrivateReview());
+ reviewRepository.save(review);
+ }
+
+ 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 String uploadImage(MultipartFile imageFile) {
+ if (imageFile == null || imageFile.isEmpty()) {
+ return null;
+ }
+ UploadResult result = fileStoragePort.upload(imageFile, REVIEW_IMAGE_DIR);
+ return result.url();
+ }
+}
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..5a00b99e
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/application/query/GetReviewService.java
@@ -0,0 +1,108 @@
+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()
+ );
+ }
+
+ 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,
+ null
+ );
+ }
+ return ContentResponse.from(
+ content.getActivity(),
+ content.getLearned(),
+ content.getEncouragement(),
+ content.getImageUrl()
+ );
+ }
+}
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
index 1a0c755c..36d93735 100644
--- a/modules/study/src/main/java/kr/spot/review/domain/Review.java
+++ b/modules/study/src/main/java/kr/spot/review/domain/Review.java
@@ -24,13 +24,22 @@ public class Review extends BaseEntity {
@Id
private Long id;
+ private Long studyId;
+
@Embedded
private WriterInfo writerInfo;
@Embedded
private Content content;
- public static Review of(Long id, WriterInfo writerInfo, Content content) {
- return new Review(id, writerInfo, 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/infrastructure/jpa/ReviewReactionRepository.java b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewReactionRepository.java
new file mode 100644
index 00000000..c791345a
--- /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 java.util.Optional;
+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 {
+
+ Optional findByReviewIdAndMemberIdAndReaction(
+ Long reviewId, Long memberId, Reaction reaction);
+
+ 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);
+}
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..191788db
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/infrastructure/jpa/ReviewRepository.java
@@ -0,0 +1,14 @@
+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));
+ }
+}
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..2ddbbd33
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/presentation/command/ReviewCommandController.java
@@ -0,0 +1,74 @@
+package kr.spot.review.presentation.command;
+
+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.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 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;
+
+ @PostMapping
+ public ResponseEntity> createReview(
+ @PathVariable Long studyId,
+ @CurrentMember @Parameter(hidden = true) Long memberId,
+ @RequestPart CreateReviewRequest request,
+ @RequestPart(required = false) MultipartFile imageFile
+ ) {
+ manageReviewService.createReview(studyId, memberId, request, imageFile);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ }
+
+ @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));
+ }
+
+ @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));
+ }
+
+ @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));
+ }
+}
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..3fecef6c
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/presentation/command/dto/CreateReviewRequest.java
@@ -0,0 +1,13 @@
+package kr.spot.review.presentation.command.dto;
+
+public record CreateReviewRequest(
+ String activity,
+ String learned,
+ String encouragement,
+ Boolean isPrivate
+) {
+
+ public boolean isPrivateReview() {
+ return Boolean.TRUE.equals(isPrivate);
+ }
+}
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..b544186f
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/presentation/query/ReviewQueryController.java
@@ -0,0 +1,38 @@
+package kr.spot.review.presentation.query;
+
+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;
+
+ @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..0caf8217
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/review/presentation/query/dto/GetReviewListResponse.java
@@ -0,0 +1,82 @@
+package kr.spot.review.presentation.query.dto;
+
+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
+ ) {
+
+ public static ReviewResponse from(
+ Long reviewId,
+ WriterInfoResponse writer,
+ ContentResponse content,
+ ReactionCountResponse reactionCounts,
+ ReactionResponse reactions,
+ boolean isPrivate
+ ) {
+ return new ReviewResponse(reviewId, writer, content, reactionCounts, reactions, isPrivate);
+ }
+ }
+
+ 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,
+ String imageUrl
+ ) {
+
+ public static ContentResponse from(String activity, String learned, String encouragement,
+ String imageUrl) {
+ return new ContentResponse(activity, learned, encouragement, imageUrl);
+ }
+ }
+
+ 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);
+ }
+ }
+}
From 6d7aa36c17fba421fc2d74b940bb51f919983df8 Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 14:02:48 +0900
Subject: [PATCH 036/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EA=B4=80=EB=A0=A8=20API=20?=
=?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?=
=?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../application/query/GetReviewService.java | 3 +-
.../ManageReviewReactionServiceTest.java | 239 +++++++++++++++
.../command/ManageReviewServiceTest.java | 259 ++++++++++++++++
.../query/GetReviewServiceTest.java | 284 ++++++++++++++++++
.../kr/spot/review/common/ReviewFixture.java | 73 +++++
.../kr/spot/review/domain/ReviewTest.java | 81 +++++
.../spot/review/domain/vo/WriterInfoTest.java | 74 +++++
7 files changed, 1011 insertions(+), 2 deletions(-)
create mode 100644 modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java
create mode 100644 modules/study/src/test/java/kr/spot/review/domain/ReviewTest.java
create mode 100644 modules/study/src/test/java/kr/spot/review/domain/vo/WriterInfoTest.java
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
index 5a00b99e..b60f9446 100644
--- 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
@@ -31,8 +31,7 @@ public class GetReviewService {
private final StudyAccessValidator studyAccessValidator;
public GetReviewListResponse getReviewList(long studyId, Long viewerId, Long cursor, int size) {
- boolean isStudyMember =
- viewerId != null && studyAccessValidator.isStudyMember(studyId, viewerId);
+ boolean isStudyMember = studyAccessValidator.isStudyMember(studyId, viewerId);
List reviews = reviewQueryRepository.findByStudyIdWithCursor(studyId, cursor,
size + 1);
diff --git a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java
new file mode 100644
index 00000000..c42fb5de
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java
@@ -0,0 +1,239 @@
+package kr.spot.review.application.command;
+
+import static kr.spot.review.common.ReviewFixture.MEMBER_ID;
+import static kr.spot.review.common.ReviewFixture.STUDY_ID;
+import static kr.spot.review.common.ReviewFixture.review;
+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.ArgumentMatchers.anyString;
+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.exception.GeneralException;
+import kr.spot.review.domain.Review;
+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 org.junit.jupiter.api.BeforeEach;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class ManageReviewReactionServiceTest {
+
+ @Mock
+ IdGenerator idGenerator;
+
+ @Mock
+ ReviewRepository reviewRepository;
+
+ @Mock
+ ReviewReactionRepository reviewReactionRepository;
+
+ @Mock
+ StudyAccessValidator studyAccessValidator;
+
+ @Captor
+ ArgumentCaptor reactionCaptor;
+
+ ManageReviewReactionService manageReviewReactionService;
+
+ @BeforeEach
+ void setUp() {
+ manageReviewReactionService = new ManageReviewReactionService(
+ idGenerator, reviewRepository, reviewReactionRepository, studyAccessValidator);
+ }
+
+ @Nested
+ @DisplayName("๋ฐ์ ์ถ๊ฐ (addReaction)")
+ class AddReaction {
+
+ @Test
+ @DisplayName("๋ฐ์์ ์ ์์ ์ผ๋ก ์ถ๊ฐํ ์ ์๋ค")
+ void should_add_reaction_successfully() {
+ // given
+ long reviewId = 1L;
+ long generatedId = 100L;
+ Reaction reaction = Reaction.FIRE;
+ Review review = review(reviewId);
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(reviewRepository.getById(anyLong())).thenReturn(review);
+ when(reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(anyLong(), anyLong(),
+ any())).thenReturn(false);
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(reviewReactionRepository.save(any(ReviewReaction.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction);
+
+ // then
+ verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID);
+ verify(reviewRepository).getById(reviewId);
+ verify(reviewReactionRepository).save(reactionCaptor.capture());
+
+ ReviewReaction capturedReaction = reactionCaptor.getValue();
+ assertThat(capturedReaction.getId()).isEqualTo(generatedId);
+ assertThat(capturedReaction.getReviewId()).isEqualTo(reviewId);
+ assertThat(capturedReaction.getMemberId()).isEqualTo(MEMBER_ID);
+ assertThat(capturedReaction.getReaction()).isEqualTo(reaction);
+ }
+
+ @Test
+ @DisplayName("์ด๋ฏธ ๋์ผํ ๋ฐ์์ ๋๋ฅธ ๊ฒฝ์ฐ ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_already_reacted() {
+ // given
+ long reviewId = 1L;
+ Reaction reaction = Reaction.HEART;
+ Review review = review(reviewId);
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(reviewRepository.getById(anyLong())).thenReturn(review);
+ when(reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(anyLong(), anyLong(),
+ any())).thenReturn(true);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ALREADY_REACTED);
+
+ verify(reviewReactionRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ํ๊ณ ์ ๋ฐ์์ ์ถ๊ฐํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_review_not_found() {
+ // given
+ long reviewId = 999L;
+ Reaction reaction = Reaction.STAR;
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(reviewRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._REVIEW_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ์๋๋ฉด ๋ฐ์์ ์ถ๊ฐํ ์ ์๋ค")
+ void should_throw_exception_when_not_study_member() {
+ // given
+ long reviewId = 1L;
+ Reaction reaction = Reaction.SMILE;
+
+ doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED))
+ .when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED);
+
+ verify(reviewReactionRepository, never()).save(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("๋ฐ์ ์ ๊ฑฐ (removeReaction)")
+ class RemoveReaction {
+
+ @Test
+ @DisplayName("๋ฐ์์ ์ ์์ ์ผ๋ก ์ ๊ฑฐํ ์ ์๋ค")
+ void should_remove_reaction_successfully() {
+ // given
+ long reviewId = 1L;
+ Reaction reaction = Reaction.FIRE;
+ Review review = review(reviewId);
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(reviewRepository.getById(anyLong())).thenReturn(review);
+ when(reviewReactionRepository.hardDelete(anyLong(), anyLong(), anyString())).thenReturn(1);
+
+ // when
+ manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction);
+
+ // then
+ verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID);
+ verify(reviewRepository).getById(reviewId);
+ verify(reviewReactionRepository).hardDelete(reviewId, MEMBER_ID, reaction.name());
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ๋ฐ์์ ์ ๊ฑฐํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_reaction_not_found() {
+ // given
+ long reviewId = 1L;
+ Reaction reaction = Reaction.HEART;
+ Review review = review(reviewId);
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(reviewRepository.getById(anyLong())).thenReturn(review);
+ when(reviewReactionRepository.hardDelete(anyLong(), anyLong(), anyString())).thenReturn(0);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._REACTION_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ํ๊ณ ์ ๋ฐ์์ ์ ๊ฑฐํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_review_not_found() {
+ // given
+ long reviewId = 999L;
+ Reaction reaction = Reaction.STAR;
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(reviewRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._REVIEW_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ์๋๋ฉด ๋ฐ์์ ์ ๊ฑฐํ ์ ์๋ค")
+ void should_throw_exception_when_not_study_member() {
+ // given
+ long reviewId = 1L;
+ Reaction reaction = Reaction.SMILE;
+
+ doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED))
+ .when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED);
+
+ verify(reviewReactionRepository, never()).hardDelete(anyLong(), anyLong(), anyString());
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java
new file mode 100644
index 00000000..8fb318fa
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewServiceTest.java
@@ -0,0 +1,259 @@
+package kr.spot.review.application.command;
+
+import static kr.spot.review.common.ReviewFixture.ACTIVITY;
+import static kr.spot.review.common.ReviewFixture.ENCOURAGEMENT;
+import static kr.spot.review.common.ReviewFixture.LEARNED;
+import static kr.spot.review.common.ReviewFixture.MEMBER_ID;
+import static kr.spot.review.common.ReviewFixture.OTHER_MEMBER_ID;
+import static kr.spot.review.common.ReviewFixture.STUDY_ID;
+import static kr.spot.review.common.ReviewFixture.WRITER_NAME;
+import static kr.spot.review.common.ReviewFixture.WRITER_PROFILE_IMAGE_URL;
+import static kr.spot.review.common.ReviewFixture.createPrivateReviewRequest;
+import static kr.spot.review.common.ReviewFixture.createReviewRequest;
+import static kr.spot.review.common.ReviewFixture.review;
+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.ArgumentMatchers.anyString;
+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.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.infrastructure.jpa.ReviewRepository;
+import kr.spot.review.presentation.command.dto.CreateReviewRequest;
+import kr.spot.study.application.validator.StudyAccessValidator;
+import org.junit.jupiter.api.BeforeEach;
+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;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.web.multipart.MultipartFile;
+
+@ExtendWith(MockitoExtension.class)
+class ManageReviewServiceTest {
+
+ @Mock
+ IdGenerator idGenerator;
+
+ @Mock
+ GetWriterInfoPort getWriterInfoPort;
+
+ @Mock
+ FileStoragePort fileStoragePort;
+
+ @Mock
+ ReviewRepository reviewRepository;
+
+ @Mock
+ StudyAccessValidator studyAccessValidator;
+
+ @Captor
+ ArgumentCaptor reviewCaptor;
+
+ ManageReviewService manageReviewService;
+
+ @BeforeEach
+ void setUp() {
+ manageReviewService = new ManageReviewService(
+ idGenerator, getWriterInfoPort, fileStoragePort, reviewRepository, studyAccessValidator);
+ }
+
+ @Nested
+ @DisplayName("ํ๊ณ ์์ฑ (createReview)")
+ class CreateReview {
+
+ @Test
+ @DisplayName("ํ๊ณ ๋ฅผ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_review_successfully() {
+ // given
+ Long generatedId = 1L;
+ CreateReviewRequest request = createReviewRequest();
+ WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(MEMBER_ID, WRITER_NAME,
+ WRITER_PROFILE_IMAGE_URL);
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(getWriterInfoPort.get(anyLong())).thenReturn(writerInfoResponse);
+ when(reviewRepository.save(any(Review.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, null);
+
+ // then
+ verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID);
+ verify(reviewRepository).save(reviewCaptor.capture());
+
+ Review capturedReview = reviewCaptor.getValue();
+ assertThat(capturedReview.getId()).isEqualTo(generatedId);
+ assertThat(capturedReview.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(capturedReview.getWriterInfo().getWriterId()).isEqualTo(MEMBER_ID);
+ assertThat(capturedReview.getContent().getActivity()).isEqualTo(ACTIVITY);
+ assertThat(capturedReview.getContent().getLearned()).isEqualTo(LEARNED);
+ assertThat(capturedReview.getContent().getEncouragement()).isEqualTo(ENCOURAGEMENT);
+ assertThat(capturedReview.isPrivate()).isFalse();
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋์ ์ ์ฉ ํ๊ณ ๋ฅผ ์์ฑํ ์ ์๋ค")
+ void should_create_private_review_successfully() {
+ // given
+ Long generatedId = 1L;
+ CreateReviewRequest request = createPrivateReviewRequest();
+ WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(MEMBER_ID, WRITER_NAME,
+ WRITER_PROFILE_IMAGE_URL);
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(getWriterInfoPort.get(anyLong())).thenReturn(writerInfoResponse);
+ when(reviewRepository.save(any(Review.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, null);
+
+ // then
+ verify(reviewRepository).save(reviewCaptor.capture());
+ assertThat(reviewCaptor.getValue().isPrivate()).isTrue();
+ }
+
+ @Test
+ @DisplayName("์ด๋ฏธ์ง์ ํจ๊ป ํ๊ณ ๋ฅผ ์์ฑํ ์ ์๋ค")
+ void should_create_review_with_image() {
+ // given
+ Long generatedId = 1L;
+ CreateReviewRequest request = createReviewRequest();
+ WriterInfoResponse writerInfoResponse = WriterInfoResponse.of(MEMBER_ID, WRITER_NAME,
+ WRITER_PROFILE_IMAGE_URL);
+ MultipartFile imageFile = new MockMultipartFile("image", "test.jpg", "image/jpeg",
+ "test".getBytes());
+ String uploadedImageUrl = "https://s3.example.com/reviews/test.jpg";
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(getWriterInfoPort.get(anyLong())).thenReturn(writerInfoResponse);
+ when(fileStoragePort.upload(any(MultipartFile.class), anyString()))
+ .thenReturn(new UploadResult(uploadedImageUrl, "test.jpg"));
+ when(reviewRepository.save(any(Review.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, imageFile);
+
+ // then
+ verify(fileStoragePort).upload(imageFile, "reviews");
+ verify(reviewRepository).save(reviewCaptor.capture());
+ assertThat(reviewCaptor.getValue().getContent().getImageUrl()).isEqualTo(uploadedImageUrl);
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ์๋๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_not_study_member() {
+ // given
+ CreateReviewRequest request = createReviewRequest();
+
+ doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED))
+ .when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageReviewService.createReview(STUDY_ID, MEMBER_ID, request, null))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED);
+
+ verify(reviewRepository, never()).save(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("ํ๊ณ ์ญ์ (deleteReview)")
+ class DeleteReview {
+
+ @Test
+ @DisplayName("ํ๊ณ ๋ฅผ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_review_successfully() {
+ // given
+ long reviewId = 1L;
+ Review review = review(reviewId, STUDY_ID, MEMBER_ID);
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(reviewRepository.getById(anyLong())).thenReturn(review);
+
+ // when
+ manageReviewService.deleteReview(STUDY_ID, reviewId, MEMBER_ID);
+
+ // then
+ verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID);
+ verify(reviewRepository).getById(reviewId);
+ verify(reviewRepository).delete(review);
+ }
+
+ @Test
+ @DisplayName("์์ฑ์๊ฐ ์๋๋ฉด ์ญ์ ํ ์ ์๋ค")
+ void should_throw_exception_when_not_author() {
+ // given
+ long reviewId = 1L;
+ Review review = review(reviewId, STUDY_ID, MEMBER_ID);
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(reviewRepository.getById(anyLong())).thenReturn(review);
+
+ // when & then
+ assertThatThrownBy(
+ () -> manageReviewService.deleteReview(STUDY_ID, reviewId, OTHER_MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+
+ verify(reviewRepository, never()).delete(any());
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ํ๊ณ ๋ฅผ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_review_not_found() {
+ // given
+ long reviewId = 999L;
+
+ doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+ when(reviewRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(() -> manageReviewService.deleteReview(STUDY_ID, reviewId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._REVIEW_NOT_FOUND);
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋ ๋ฉค๋ฒ๊ฐ ์๋๋ฉด ์ญ์ ํ ์ ์๋ค")
+ void should_throw_exception_when_not_study_member() {
+ // given
+ long reviewId = 1L;
+
+ doThrow(new GeneralException(ErrorStatus._STUDY_ACCESS_DENIED))
+ .when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
+
+ // when & then
+ assertThatThrownBy(() -> manageReviewService.deleteReview(STUDY_ID, reviewId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._STUDY_ACCESS_DENIED);
+
+ verify(reviewRepository, never()).delete(any());
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java
new file mode 100644
index 00000000..2e44f7f7
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/review/application/query/GetReviewServiceTest.java
@@ -0,0 +1,284 @@
+package kr.spot.review.application.query;
+
+import static kr.spot.review.common.ReviewFixture.ACTIVITY;
+import static kr.spot.review.common.ReviewFixture.ENCOURAGEMENT;
+import static kr.spot.review.common.ReviewFixture.LEARNED;
+import static kr.spot.review.common.ReviewFixture.MEMBER_ID;
+import static kr.spot.review.common.ReviewFixture.OTHER_MEMBER_ID;
+import static kr.spot.review.common.ReviewFixture.STUDY_ID;
+import static kr.spot.review.common.ReviewFixture.privateReview;
+import static kr.spot.review.common.ReviewFixture.review;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+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.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.ReviewResponse;
+import kr.spot.study.application.validator.StudyAccessValidator;
+import org.junit.jupiter.api.BeforeEach;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class GetReviewServiceTest {
+
+ private static final String PRIVATE_CONTENT_MESSAGE = "์ด ๊ธ์ ์คํฐ๋์์๊ฒ๋ง ๋
ธ์ถ๋ฉ๋๋ค.";
+
+ @Mock
+ ReviewQueryRepository reviewQueryRepository;
+
+ @Mock
+ StudyAccessValidator studyAccessValidator;
+
+ GetReviewService getReviewService;
+
+ @BeforeEach
+ void setUp() {
+ getReviewService = new GetReviewService(reviewQueryRepository, studyAccessValidator);
+ }
+
+ @Nested
+ @DisplayName("ํ๊ณ ๋ชฉ๋ก ์กฐํ (getReviewList)")
+ class GetReviewList {
+
+ @Test
+ @DisplayName("ํ๊ณ ๋ชฉ๋ก์ ์ ์์ ์ผ๋ก ์กฐํํ ์ ์๋ค")
+ void should_get_review_list_successfully() {
+ // given
+ Review review1 = review(1L);
+ Review review2 = review(2L);
+
+ when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true);
+ when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt()))
+ .thenReturn(List.of(review1, review2));
+ when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of());
+ when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of());
+ when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(2L);
+
+ // when
+ GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null,
+ 10);
+
+ // then
+ assertThat(response.reviews()).hasSize(2);
+ assertThat(response.hasNext()).isFalse();
+ assertThat(response.nextCursor()).isNull();
+ assertThat(response.totalElements()).isEqualTo(2L);
+ }
+
+ @Test
+ @DisplayName("ํ์ด์ง๋ค์ด์
์ด ์ ์์ ์ผ๋ก ๋์ํ๋ค")
+ void should_paginate_correctly() {
+ // given
+ Review review1 = review(3L);
+ Review review2 = review(2L);
+ Review review3 = review(1L);
+
+ when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true);
+ when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt()))
+ .thenReturn(List.of(review1, review2, review3));
+ when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of());
+ when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of());
+ when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(5L);
+
+ // when
+ GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null, 2);
+
+ // then
+ assertThat(response.reviews()).hasSize(2);
+ assertThat(response.hasNext()).isTrue();
+ assertThat(response.nextCursor()).isEqualTo(2L);
+ }
+
+ @Test
+ @DisplayName("ํ๊ณ ๊ฐ ์๋ ๊ฒฝ์ฐ ๋น ๋ชฉ๋ก์ ๋ฐํํ๋ค")
+ void should_return_empty_list_when_no_reviews() {
+ // given
+ when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true);
+ when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt()))
+ .thenReturn(Collections.emptyList());
+ when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of());
+ when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of());
+ when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(0L);
+
+ // when
+ GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null,
+ 10);
+
+ // then
+ assertThat(response.reviews()).isEmpty();
+ assertThat(response.hasNext()).isFalse();
+ assertThat(response.totalElements()).isZero();
+ }
+
+ @Test
+ @DisplayName("๋ฐ์ ์นด์ดํธ๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์กฐํ๋๋ค")
+ void should_get_reaction_counts_correctly() {
+ // given
+ Review review1 = review(1L);
+ ReactionCounts counts = new ReactionCounts(5, 3, 2, 1);
+
+ when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true);
+ when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt()))
+ .thenReturn(List.of(review1));
+ when(reviewQueryRepository.findReactionCountsByReviewIds(any()))
+ .thenReturn(Map.of(1L, counts));
+ when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of());
+ when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L);
+
+ // when
+ GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null,
+ 10);
+
+ // then
+ ReviewResponse reviewResponse = response.reviews().getFirst();
+ assertThat(reviewResponse.reactionCounts().fireCount()).isEqualTo(5L);
+ assertThat(reviewResponse.reactionCounts().heartCount()).isEqualTo(3L);
+ assertThat(reviewResponse.reactionCounts().starCount()).isEqualTo(2L);
+ assertThat(reviewResponse.reactionCounts().smileCount()).isEqualTo(1L);
+ }
+
+ @Test
+ @DisplayName("์ฌ์ฉ์์ ๋ฐ์ ์ฌ๋ถ๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์กฐํ๋๋ค")
+ void should_get_member_reactions_correctly() {
+ // given
+ Review review1 = review(1L);
+
+ when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true);
+ when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt()))
+ .thenReturn(List.of(review1));
+ when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of());
+ when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any()))
+ .thenReturn(Map.of(1L, Set.of(Reaction.FIRE, Reaction.HEART)));
+ when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L);
+
+ // when
+ GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null,
+ 10);
+
+ // then
+ ReviewResponse reviewResponse = response.reviews().getFirst();
+ assertThat(reviewResponse.reactions().isFired()).isTrue();
+ assertThat(reviewResponse.reactions().isHearted()).isTrue();
+ assertThat(reviewResponse.reactions().isStarred()).isFalse();
+ assertThat(reviewResponse.reactions().isSmiled()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("๋น๊ณต๊ฐ ํ๊ณ ๋ง์คํน")
+ class PrivateReviewMasking {
+
+ @Test
+ @DisplayName("์คํฐ๋์์ด ๋น๊ณต๊ฐ ํ๊ณ ๋ฅผ ์กฐํํ๋ฉด ์๋ณธ ๋ด์ฉ์ด ๋ณด์ธ๋ค")
+ void should_show_original_content_for_study_member() {
+ // given
+ Review privateReview = privateReview(1L, STUDY_ID, MEMBER_ID);
+
+ when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(true);
+ when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt()))
+ .thenReturn(List.of(privateReview));
+ when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of());
+ when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of());
+ when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L);
+
+ // when
+ GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, MEMBER_ID, null,
+ 10);
+
+ // then
+ ReviewResponse reviewResponse = response.reviews().getFirst();
+ assertThat(reviewResponse.isPrivate()).isTrue();
+ assertThat(reviewResponse.content().activity()).isEqualTo(ACTIVITY);
+ assertThat(reviewResponse.content().learned()).isEqualTo(LEARNED);
+ assertThat(reviewResponse.content().encouragement()).isEqualTo(ENCOURAGEMENT);
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋์์ด ์๋ ์ฌ์ฉ์๊ฐ ๋น๊ณต๊ฐ ํ๊ณ ๋ฅผ ์กฐํํ๋ฉด ๋ง์คํน๋ ๋ด์ฉ์ด ๋ณด์ธ๋ค")
+ void should_mask_content_for_non_study_member() {
+ // given
+ Review privateReview = privateReview(1L, STUDY_ID, MEMBER_ID);
+
+ when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(false);
+ when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt()))
+ .thenReturn(List.of(privateReview));
+ when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of());
+ when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of());
+ when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L);
+
+ // when
+ GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, OTHER_MEMBER_ID,
+ null, 10);
+
+ // then
+ ReviewResponse reviewResponse = response.reviews().getFirst();
+ assertThat(reviewResponse.isPrivate()).isTrue();
+ assertThat(reviewResponse.content().activity()).isEqualTo(PRIVATE_CONTENT_MESSAGE);
+ assertThat(reviewResponse.content().learned()).isEqualTo(PRIVATE_CONTENT_MESSAGE);
+ assertThat(reviewResponse.content().encouragement()).isEqualTo(PRIVATE_CONTENT_MESSAGE);
+ assertThat(reviewResponse.content().imageUrl()).isNull();
+ }
+
+ @Test
+ @DisplayName("๊ณต๊ฐ ํ๊ณ ๋ ์คํฐ๋์์ด ์๋์ด๋ ์๋ณธ ๋ด์ฉ์ด ๋ณด์ธ๋ค")
+ void should_show_original_content_for_public_review() {
+ // given
+ Review publicReview = review(1L);
+
+ when(studyAccessValidator.isStudyMember(anyLong(), anyLong())).thenReturn(false);
+ when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt()))
+ .thenReturn(List.of(publicReview));
+ when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of());
+ when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of());
+ when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L);
+
+ // when
+ GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, OTHER_MEMBER_ID,
+ null, 10);
+
+ // then
+ ReviewResponse reviewResponse = response.reviews().getFirst();
+ assertThat(reviewResponse.isPrivate()).isFalse();
+ assertThat(reviewResponse.content().activity()).isEqualTo(ACTIVITY);
+ assertThat(reviewResponse.content().learned()).isEqualTo(LEARNED);
+ assertThat(reviewResponse.content().encouragement()).isEqualTo(ENCOURAGEMENT);
+ }
+
+ @Test
+ @DisplayName("๋น๋ก๊ทธ์ธ ์ฌ์ฉ์๊ฐ ๋น๊ณต๊ฐ ํ๊ณ ๋ฅผ ์กฐํํ๋ฉด ๋ง์คํน๋ ๋ด์ฉ์ด ๋ณด์ธ๋ค")
+ void should_mask_content_for_anonymous_user() {
+ // given
+ Review privateReview = privateReview(1L, STUDY_ID, MEMBER_ID);
+
+ when(reviewQueryRepository.findByStudyIdWithCursor(anyLong(), any(), anyInt()))
+ .thenReturn(List.of(privateReview));
+ when(reviewQueryRepository.findReactionCountsByReviewIds(any())).thenReturn(Map.of());
+ when(reviewQueryRepository.findMemberReactionsByReviewIds(any(), any())).thenReturn(Map.of());
+ when(reviewQueryRepository.countByStudyId(anyLong())).thenReturn(1L);
+
+ // when (viewerId๊ฐ null์ธ ๊ฒฝ์ฐ)
+ GetReviewListResponse response = getReviewService.getReviewList(STUDY_ID, null, null, 10);
+
+ // then
+ ReviewResponse reviewResponse = response.reviews().getFirst();
+ assertThat(reviewResponse.content().activity()).isEqualTo(PRIVATE_CONTENT_MESSAGE);
+ assertThat(reviewResponse.content().learned()).isEqualTo(PRIVATE_CONTENT_MESSAGE);
+ assertThat(reviewResponse.content().encouragement()).isEqualTo(PRIVATE_CONTENT_MESSAGE);
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java b/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java
new file mode 100644
index 00000000..2025d176
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/review/common/ReviewFixture.java
@@ -0,0 +1,73 @@
+package kr.spot.review.common;
+
+import kr.spot.review.domain.Review;
+import kr.spot.review.domain.associations.ReviewReaction;
+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.presentation.command.dto.CreateReviewRequest;
+
+public class ReviewFixture {
+
+ public static final Long ID = 1L;
+ public static final Long STUDY_ID = 100L;
+ public static final Long MEMBER_ID = 200L;
+ public static final Long OTHER_MEMBER_ID = 999L;
+
+ public static final String WRITER_NAME = "ํ๊ธธ๋";
+ public static final String WRITER_PROFILE_IMAGE_URL = "https://example.com/profile.jpg";
+
+ public static final String ACTIVITY = "์ค๋์ ์๊ณ ๋ฆฌ์ฆ ๋ฌธ์ 3๊ฐ๋ฅผ ํ์์ต๋๋ค.";
+ public static final String LEARNED = "๋ค์ด๋๋ฏน ํ๋ก๊ทธ๋๋ฐ์ ๊ฐ๋
์ ์ดํดํ์ต๋๋ค.";
+ public static final String ENCOURAGEMENT = "์ค๋๋ ์๊ณ ํ์ด!";
+ public static final String IMAGE_URL = "https://example.com/review-image.jpg";
+
+ public static WriterInfo writerInfo() {
+ return WriterInfo.of(MEMBER_ID, WRITER_NAME, WRITER_PROFILE_IMAGE_URL);
+ }
+
+ public static WriterInfo writerInfo(Long memberId) {
+ return WriterInfo.of(memberId, WRITER_NAME, WRITER_PROFILE_IMAGE_URL);
+ }
+
+ public static Content content() {
+ return Content.of(ACTIVITY, LEARNED, ENCOURAGEMENT, IMAGE_URL);
+ }
+
+ public static Content content(String imageUrl) {
+ return Content.of(ACTIVITY, LEARNED, ENCOURAGEMENT, imageUrl);
+ }
+
+ public static Review review() {
+ return Review.of(ID, STUDY_ID, writerInfo(), content(), false);
+ }
+
+ public static Review review(Long id) {
+ return Review.of(id, STUDY_ID, writerInfo(), content(), false);
+ }
+
+ public static Review review(Long id, Long studyId, Long memberId) {
+ return Review.of(id, studyId, writerInfo(memberId), content(), false);
+ }
+
+ public static Review privateReview() {
+ return Review.of(ID, STUDY_ID, writerInfo(), content(), true);
+ }
+
+ public static Review privateReview(Long id, Long studyId, Long memberId) {
+ return Review.of(id, studyId, writerInfo(memberId), content(), true);
+ }
+
+ public static ReviewReaction reviewReaction(Long id, Long reviewId, Long memberId,
+ Reaction reaction) {
+ return ReviewReaction.of(id, reviewId, memberId, reaction);
+ }
+
+ public static CreateReviewRequest createReviewRequest() {
+ return new CreateReviewRequest(ACTIVITY, LEARNED, ENCOURAGEMENT, false);
+ }
+
+ public static CreateReviewRequest createPrivateReviewRequest() {
+ return new CreateReviewRequest(ACTIVITY, LEARNED, ENCOURAGEMENT, true);
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/review/domain/ReviewTest.java b/modules/study/src/test/java/kr/spot/review/domain/ReviewTest.java
new file mode 100644
index 00000000..dd330bf9
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/review/domain/ReviewTest.java
@@ -0,0 +1,81 @@
+package kr.spot.review.domain;
+
+import static kr.spot.review.common.ReviewFixture.ID;
+import static kr.spot.review.common.ReviewFixture.STUDY_ID;
+import static kr.spot.review.common.ReviewFixture.content;
+import static kr.spot.review.common.ReviewFixture.privateReview;
+import static kr.spot.review.common.ReviewFixture.review;
+import static kr.spot.review.common.ReviewFixture.writerInfo;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class ReviewTest {
+
+ @Nested
+ @DisplayName("ํ๊ณ ์์ฑ (of)")
+ class CreateReview {
+
+ @Test
+ @DisplayName("ํ๊ณ ๊ฐ์ฒด๋ฅผ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_review_successfully() {
+ // when
+ Review review = Review.of(ID, STUDY_ID, writerInfo(), content(), false);
+
+ // then
+ assertThat(review).isNotNull();
+ assertThat(review.getId()).isEqualTo(ID);
+ assertThat(review.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(review.getWriterInfo()).isNotNull();
+ assertThat(review.getContent()).isNotNull();
+ assertThat(review.isPrivate()).isFalse();
+ }
+
+ @Test
+ @DisplayName("์คํฐ๋์ ์ ์ฉ ํ๊ณ ๋ฅผ ์์ฑํ ์ ์๋ค")
+ void should_create_private_review_successfully() {
+ // when
+ Review review = Review.of(ID, STUDY_ID, writerInfo(), content(), true);
+
+ // then
+ assertThat(review.isPrivate()).isTrue();
+ }
+
+ @Test
+ @DisplayName("isPrivate๊ฐ null์ด๋ฉด ๊ณต๊ฐ ํ๊ณ ๋ก ๊ฐ์ฃผํ๋ค")
+ void should_treat_null_as_public() {
+ // when
+ Review review = Review.of(ID, STUDY_ID, writerInfo(), content(), null);
+
+ // then
+ assertThat(review.isPrivate()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("๊ณต๊ฐ ์ฌ๋ถ ํ์ธ (isPrivate)")
+ class IsPrivate {
+
+ @Test
+ @DisplayName("๊ณต๊ฐ ํ๊ณ ๋ false๋ฅผ ๋ฐํํ๋ค")
+ void should_return_false_for_public_review() {
+ // given
+ Review review = review();
+
+ // when & then
+ assertThat(review.isPrivate()).isFalse();
+ }
+
+ @Test
+ @DisplayName("๋น๊ณต๊ฐ ํ๊ณ ๋ true๋ฅผ ๋ฐํํ๋ค")
+ void should_return_true_for_private_review() {
+ // given
+ Review review = privateReview();
+
+ // when & then
+ assertThat(review.isPrivate()).isTrue();
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/review/domain/vo/WriterInfoTest.java b/modules/study/src/test/java/kr/spot/review/domain/vo/WriterInfoTest.java
new file mode 100644
index 00000000..d3b6941b
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/review/domain/vo/WriterInfoTest.java
@@ -0,0 +1,74 @@
+package kr.spot.review.domain.vo;
+
+import static kr.spot.review.common.ReviewFixture.MEMBER_ID;
+import static kr.spot.review.common.ReviewFixture.OTHER_MEMBER_ID;
+import static kr.spot.review.common.ReviewFixture.writerInfo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class WriterInfoTest {
+
+ @Nested
+ @DisplayName("์์ฑ์ ๊ฒ์ฆ (validateIsOwnMember)")
+ class ValidateIsOwnMember {
+
+ @Test
+ @DisplayName("๋ณธ์ธ์ธ ๊ฒฝ์ฐ ์์ธ๊ฐ ๋ฐ์ํ์ง ์๋๋ค")
+ void should_not_throw_when_owner() {
+ // given
+ WriterInfo writerInfo = writerInfo();
+
+ // when & then (์์ธ ๋ฐ์ ์ํจ)
+ writerInfo.validateIsOwnMember(MEMBER_ID);
+ }
+
+ @Test
+ @DisplayName("๋ณธ์ธ์ด ์๋ ๊ฒฝ์ฐ ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_when_not_owner() {
+ // given
+ WriterInfo writerInfo = writerInfo();
+
+ // when & then
+ assertThatThrownBy(() -> writerInfo.validateIsOwnMember(OTHER_MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_AUTHOR_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("๋์ผ ์์ฑ์ ํ์ธ (isSameWriter)")
+ class IsSameWriter {
+
+ @Test
+ @DisplayName("๋์ผํ ์์ฑ์์ธ ๊ฒฝ์ฐ true๋ฅผ ๋ฐํํ๋ค")
+ void should_return_true_when_same_writer() {
+ // given
+ WriterInfo writerInfo = writerInfo();
+
+ // when
+ boolean result = writerInfo.isSameWriter(MEMBER_ID);
+
+ // then
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์์ฑ์์ธ ๊ฒฝ์ฐ false๋ฅผ ๋ฐํํ๋ค")
+ void should_return_false_when_different_writer() {
+ // given
+ WriterInfo writerInfo = writerInfo();
+
+ // when
+ boolean result = writerInfo.isSameWriter(OTHER_MEMBER_ID);
+
+ // then
+ assertThat(result).isFalse();
+ }
+ }
+}
From 8e3288b0705393a2c85e548dd986cfc500b85985 Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 14:08:24 +0900
Subject: [PATCH 037/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EB=A6=AC=EC=95=A1=EC=85=98=20?=
=?UTF-8?q?API=20=EB=A9=B1=EB=93=B1=EC=A0=81=20=EC=84=A4=EA=B3=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManageReviewReactionService.java | 17 ++++-------------
.../command/ManageReviewService.java | 2 +-
.../main/java/kr/spot/review/domain/Review.java | 4 ++++
.../domain/associations/ReviewReaction.java | 9 +++++++++
.../infrastructure/jpa/ReviewRepository.java | 6 ++++++
5 files changed, 24 insertions(+), 14 deletions(-)
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
index 5ba2a402..f74de2da 100644
--- 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
@@ -1,8 +1,6 @@
package kr.spot.review.application.command;
import kr.spot.IdGenerator;
-import kr.spot.code.status.ErrorStatus;
-import kr.spot.exception.GeneralException;
import kr.spot.review.domain.associations.ReviewReaction;
import kr.spot.review.domain.enums.Reaction;
import kr.spot.review.infrastructure.jpa.ReviewReactionRepository;
@@ -24,11 +22,11 @@ public class ManageReviewReactionService {
public void addReaction(long studyId, long reviewId, long memberId, Reaction reaction) {
studyAccessValidator.validateStudyMember(studyId, memberId);
- validateReviewExists(reviewId);
+ reviewRepository.validateExists(reviewId);
if (reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(reviewId, memberId,
reaction)) {
- throw new GeneralException(ErrorStatus._ALREADY_REACTED);
+ return;
}
ReviewReaction reviewReaction = ReviewReaction.of(
@@ -38,15 +36,8 @@ public void addReaction(long studyId, long reviewId, long memberId, Reaction rea
public void removeReaction(long studyId, long reviewId, long memberId, Reaction reaction) {
studyAccessValidator.validateStudyMember(studyId, memberId);
- validateReviewExists(reviewId);
+ reviewRepository.validateExists(reviewId);
- int deleted = reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name());
- if (deleted == 0) {
- throw new GeneralException(ErrorStatus._REACTION_NOT_FOUND);
- }
- }
-
- private void validateReviewExists(long reviewId) {
- reviewRepository.getById(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
index e8d83492..dd87719d 100644
--- 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
@@ -47,7 +47,7 @@ public void deleteReview(long studyId, long reviewId, long memberId) {
studyAccessValidator.validateStudyMember(studyId, memberId);
Review review = reviewRepository.getById(reviewId);
- review.getWriterInfo().validateIsOwnMember(memberId);
+ review.validateIsOwnMember(memberId);
reviewRepository.delete(review);
}
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
index 36d93735..87254f98 100644
--- a/modules/study/src/main/java/kr/spot/review/domain/Review.java
+++ b/modules/study/src/main/java/kr/spot/review/domain/Review.java
@@ -42,4 +42,8 @@ public static Review of(Long id, Long studyId, WriterInfo writerInfo, Content co
public boolean isPrivate() {
return Boolean.TRUE.equals(isPrivate);
}
+
+ public void validateIsOwnMember(Long currentUserId) {
+ writerInfo.validateIsOwnMember(currentUserId);
+ }
}
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
index 7063b6ff..70d0d7b7 100644
--- 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
@@ -2,6 +2,8 @@
import jakarta.persistence.Entity;
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;
@@ -17,6 +19,13 @@
@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
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
index 191788db..866fdcfa 100644
--- 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
@@ -11,4 +11,10 @@ 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);
+ }
+ }
}
From 0001d73007f1b4e7bb066bcfeffc086f991baf8d Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 14:09:24 +0900
Subject: [PATCH 038/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EB=A6=AC=EC=95=A1=EC=85=98=20?=
=?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=88=84=EB=9D=BD=EB=90=9C=20Enu?=
=?UTF-8?q?m=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../kr/spot/review/domain/associations/ReviewReaction.java | 3 +++
1 file changed, 3 insertions(+)
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
index 70d0d7b7..788c4be4 100644
--- 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
@@ -1,6 +1,8 @@
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;
@@ -35,6 +37,7 @@ public class ReviewReaction extends BaseEntity {
private Long memberId;
+ @Enumerated(EnumType.STRING)
private Reaction reaction;
public static ReviewReaction of(Long id, Long reviewId, Long memberId, Reaction reaction) {
From 12a7642ef736ad918261f62f74f103d77e8f7b04 Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 14:10:16 +0900
Subject: [PATCH 039/126] =?UTF-8?q?[Refactor]=20=EB=AF=B8=EC=82=AC?=
=?UTF-8?q?=EC=9A=A9=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../review/infrastructure/jpa/ReviewReactionRepository.java | 4 ----
1 file changed, 4 deletions(-)
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
index c791345a..53d81390 100644
--- 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
@@ -1,6 +1,5 @@
package kr.spot.review.infrastructure.jpa;
-import java.util.Optional;
import kr.spot.review.domain.associations.ReviewReaction;
import kr.spot.review.domain.enums.Reaction;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -10,9 +9,6 @@
public interface ReviewReactionRepository extends JpaRepository {
- Optional findByReviewIdAndMemberIdAndReaction(
- Long reviewId, Long memberId, Reaction reaction);
-
boolean existsByReviewIdAndMemberIdAndReaction(
Long reviewId, Long memberId, Reaction reaction);
From 828e23add860e472afed005a1902ae1f14a484e0 Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 00:12:50 +0900
Subject: [PATCH 040/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=88=AC=EB=91=90=20=EA=B4=80=EB=A0=A8=20API=20=EA=B5=AC?=
=?UTF-8?q?=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Conflicts:
# common/api/src/main/java/kr/spot/code/status/ErrorStatus.java
---
.../command/ManageTodoService.java | 50 ++++++++
.../application/query/GetTodoService.java | 49 ++++++++
.../main/java/kr/spot/todo/domain/Todo.java | 77 ++++++++++++
.../infrastructure/jpa/TodoRepository.java | 14 +++
.../jpa/querydsl/TodoQueryRepository.java | 34 ++++++
.../command/TodoCommandController.java | 112 ++++++++++++++++++
.../command/dto/CreateTodoRequest.java | 15 +++
.../command/dto/UpdateTodoRequest.java | 15 +++
.../query/TodoQueryController.java | 46 +++++++
.../query/dto/GetTodoListResponse.java | 40 +++++++
10 files changed, 452 insertions(+)
create mode 100644 modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/domain/Todo.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java
create mode 100644 modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java
diff --git a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
new file mode 100644
index 00000000..e2aedb79
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
@@ -0,0 +1,50 @@
+package kr.spot.todo.application.command;
+
+import kr.spot.IdGenerator;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.infrastructure.jpa.TodoRepository;
+import kr.spot.todo.presentation.command.dto.CreateTodoRequest;
+import kr.spot.todo.presentation.command.dto.UpdateTodoRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class ManageTodoService {
+
+ private final IdGenerator idGenerator;
+ private final TodoRepository todoRepository;
+
+ public void createTodo(long studyId, long memberId, CreateTodoRequest request) {
+ Todo todo = Todo.of(
+ idGenerator.nextId(),
+ studyId,
+ memberId,
+ request.dueDate(),
+ request.content()
+ );
+ todoRepository.save(todo);
+ }
+
+ public void updateTodo(long studyId, long todoId, long memberId, UpdateTodoRequest request) {
+ Todo todo = todoRepository.getById(todoId);
+ todo.update(request.content(), request.dueDate(), memberId);
+ }
+
+ public void completeTodo(long studyId, long todoId, long memberId) {
+ Todo todo = todoRepository.getById(todoId);
+ todo.complete(memberId);
+ }
+
+ public void uncompleteTodo(long studyId, long todoId, long memberId) {
+ Todo todo = todoRepository.getById(todoId);
+ todo.uncomplete(memberId);
+ }
+
+ public void deleteTodo(long studyId, long todoId, long memberId) {
+ Todo todo = todoRepository.getById(todoId);
+ todo.delete(studyId, memberId);
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java
new file mode 100644
index 00000000..c07c51b4
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/application/query/GetTodoService.java
@@ -0,0 +1,49 @@
+package kr.spot.todo.application.query;
+
+import java.time.LocalDate;
+import java.util.List;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.infrastructure.jpa.querydsl.TodoQueryRepository;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse.TodoResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@Transactional(readOnly = true)
+@RequiredArgsConstructor
+public class GetTodoService {
+
+ private final TodoQueryRepository todoQueryRepository;
+
+ public GetTodoListResponse getTodosByDate(long studyId, long memberId, LocalDate dueDate) {
+ List todos = todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(
+ studyId, memberId, dueDate);
+
+ return toResponse(todos);
+ }
+
+ private GetTodoListResponse toResponse(List todos) {
+ List pending = todos.stream()
+ .filter(todo -> !Boolean.TRUE.equals(todo.getIsCompleted()))
+ .map(this::toTodoResponse)
+ .toList();
+
+ List completed = todos.stream()
+ .filter(todo -> Boolean.TRUE.equals(todo.getIsCompleted()))
+ .map(this::toTodoResponse)
+ .toList();
+
+ return GetTodoListResponse.from(pending, completed);
+ }
+
+ private TodoResponse toTodoResponse(Todo todo) {
+ return TodoResponse.from(
+ todo.getId(),
+ todo.getContent(),
+ todo.getDueDate(),
+ todo.getIsCompleted()
+ );
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
new file mode 100644
index 00000000..a86ad8a3
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
@@ -0,0 +1,77 @@
+package kr.spot.todo.domain;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import java.time.LocalDate;
+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 todo SET status = 'INACTIVE' WHERE id = ?")
+@SQLRestriction("status = 'ACTIVE'")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class Todo extends BaseEntity {
+
+ @Id
+ private Long id;
+
+ private Long studyId;
+
+ private Long memberId;
+
+ @Column(nullable = false)
+ private LocalDate dueDate;
+
+ @Column(nullable = false)
+ private String content;
+
+ private Boolean isCompleted;
+
+ public static Todo of(Long id, Long studyId, Long memberId, LocalDate dueDate, String content) {
+ return new Todo(id, studyId, memberId, dueDate, content, false);
+ }
+
+ public void update(String content, LocalDate dueDate, long memberId) {
+ validateOwner(memberId);
+ this.content = content;
+ this.dueDate = dueDate;
+ }
+
+ public void complete(long memberId) {
+ validateOwner(memberId);
+ this.isCompleted = true;
+ }
+
+ public void uncomplete(long memberId) {
+ validateOwner(memberId);
+ this.isCompleted = false;
+ }
+
+ public void delete(long studyId, long memberId) {
+ validateAccess(studyId);
+ validateOwner(memberId);
+ super.delete();
+ }
+
+ private void validateAccess(long studyId) {
+ if (this.studyId != studyId) {
+ throw new GeneralException(ErrorStatus._TODO_ACCESS_DENIED);
+ }
+ }
+
+ private void validateOwner(long memberId) {
+ if (this.memberId != memberId) {
+ throw new GeneralException(ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java
new file mode 100644
index 00000000..623fe3c7
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/TodoRepository.java
@@ -0,0 +1,14 @@
+package kr.spot.todo.infrastructure.jpa;
+
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import kr.spot.todo.domain.Todo;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface TodoRepository extends JpaRepository {
+
+ default Todo getById(long todoId) {
+ return findById(todoId).orElseThrow(
+ () -> new GeneralException(ErrorStatus._TODO_NOT_FOUND));
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java
new file mode 100644
index 00000000..64d21f3a
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/infrastructure/jpa/querydsl/TodoQueryRepository.java
@@ -0,0 +1,34 @@
+package kr.spot.todo.infrastructure.jpa.querydsl;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import java.time.LocalDate;
+import java.util.List;
+import kr.spot.todo.domain.QTodo;
+import kr.spot.todo.domain.Todo;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class TodoQueryRepository {
+
+ private final JPAQueryFactory query;
+
+ public List findByStudyIdAndMemberIdAndDueDate(Long studyId, Long memberId,
+ LocalDate dueDate) {
+ QTodo todo = QTodo.todo;
+
+ return query
+ .selectFrom(todo)
+ .where(
+ todo.studyId.eq(studyId),
+ todo.memberId.eq(memberId),
+ todo.dueDate.eq(dueDate)
+ )
+ .orderBy(
+ todo.isCompleted.asc(),
+ todo.createdAt.desc()
+ )
+ .fetch();
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java
new file mode 100644
index 00000000..4f7ab5a7
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/TodoCommandController.java
@@ -0,0 +1,112 @@
+package kr.spot.todo.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.todo.application.command.ManageTodoService;
+import kr.spot.todo.presentation.command.dto.CreateTodoRequest;
+import kr.spot.todo.presentation.command.dto.UpdateTodoRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "์คํฐ๋ ํฌ๋")
+@RestController
+@RequestMapping("/api/studies/{studyId}/todos")
+@RequiredArgsConstructor
+public class TodoCommandController {
+
+ private final ManageTodoService manageTodoService;
+
+ @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> createTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @CurrentMember @Parameter(hidden = true) Long memberId,
+ @RequestBody CreateTodoRequest request) {
+ manageTodoService.createTodo(studyId, memberId, request);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._CREATED));
+ }
+
+ @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 = "`TODO4030`: ํฌ๋ ์์ ์ ์์ฑ์๋ง ๊ฐ๋ฅํฉ๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํฌ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
+ })
+ @PatchMapping("/{todoId}")
+ public ResponseEntity> updateTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "ํฌ๋ ID", required = true) @PathVariable Long todoId,
+ @CurrentMember @Parameter(hidden = true) Long memberId,
+ @RequestBody UpdateTodoRequest request) {
+ manageTodoService.updateTodo(studyId, todoId, memberId, request);
+ 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 = "`TODO4030`: ํฌ๋ ์์ ์ ์์ฑ์๋ง ๊ฐ๋ฅํฉ๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํฌ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
+ })
+ @PostMapping("/{todoId}/complete")
+ public ResponseEntity> completeTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "ํฌ๋ ID", required = true) @PathVariable Long todoId,
+ @CurrentMember @Parameter(hidden = true) Long memberId) {
+ manageTodoService.completeTodo(studyId, todoId, 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 = "`TODO4030`: ํฌ๋ ์์ ์ ์์ฑ์๋ง ๊ฐ๋ฅํฉ๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํฌ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
+ })
+ @PostMapping("/{todoId}/uncomplete")
+ public ResponseEntity> uncompleteTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "ํฌ๋ ID", required = true) @PathVariable Long todoId,
+ @CurrentMember @Parameter(hidden = true) Long memberId) {
+ manageTodoService.uncompleteTodo(studyId, todoId, 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 = "`TODO403`: ํด๋น ์คํฐ๋์ ์ํ๋ ํฌ๋๊ฐ ์๋๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class))),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "`TODO404`: ํฌ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.", content = @Content(schema = @Schema(implementation = ApiResponse.class)))
+ })
+ @DeleteMapping("/{todoId}")
+ public ResponseEntity> deleteTodo(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @Parameter(description = "ํฌ๋ ID", required = true) @PathVariable Long todoId,
+ @CurrentMember @Parameter(hidden = true) Long memberId) {
+ manageTodoService.deleteTodo(studyId, todoId, memberId);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK));
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java
new file mode 100644
index 00000000..72e8f4f6
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/CreateTodoRequest.java
@@ -0,0 +1,15 @@
+package kr.spot.todo.presentation.command.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+
+@Schema(description = "ํฌ๋ ์์ฑ ์์ฒญ")
+public record CreateTodoRequest(
+ @Schema(description = "ํฌ๋ ๋ด์ฉ", example = "๊ณผ์ ์ ์ถํ๊ธฐ")
+ String content,
+
+ @Schema(description = "๋ง๊ฐ์ผ", example = "2025-01-15")
+ LocalDate dueDate
+) {
+
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java
new file mode 100644
index 00000000..33e21561
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/command/dto/UpdateTodoRequest.java
@@ -0,0 +1,15 @@
+package kr.spot.todo.presentation.command.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+
+@Schema(description = "ํฌ๋ ์์ ์์ฒญ")
+public record UpdateTodoRequest(
+ @Schema(description = "ํฌ๋ ๋ด์ฉ", example = "๊ณผ์ ์ ์ถํ๊ธฐ")
+ String content,
+
+ @Schema(description = "๋ง๊ฐ์ผ", example = "2025-01-15")
+ LocalDate dueDate
+) {
+
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java b/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java
new file mode 100644
index 00000000..8d977128
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/query/TodoQueryController.java
@@ -0,0 +1,46 @@
+package kr.spot.todo.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.code.status.SuccessStatus;
+import kr.spot.todo.application.query.GetTodoService;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse;
+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}/todos")
+@RequiredArgsConstructor
+public class TodoQueryController {
+
+ private final GetTodoService getTodoService;
+
+ @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("/members/{memberId}")
+ public ResponseEntity> getTodosByDate(
+ @Parameter(description = "์คํฐ๋ ID", required = true) @PathVariable Long studyId,
+ @PathVariable @Parameter(hidden = true) Long memberId,
+ @Parameter(description = "์กฐํํ ๋ ์ง (ISO 8601 ํ์ค)", example = "2025-01-15")
+ @RequestParam @DateTimeFormat(iso = ISO.DATE) LocalDate date) {
+ GetTodoListResponse response = getTodoService.getTodosByDate(studyId, memberId, date);
+ return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response));
+ }
+}
diff --git a/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java b/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java
new file mode 100644
index 00000000..e937d0c0
--- /dev/null
+++ b/modules/study/src/main/java/kr/spot/todo/presentation/query/dto/GetTodoListResponse.java
@@ -0,0 +1,40 @@
+package kr.spot.todo.presentation.query.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import java.util.List;
+
+@Schema(description = "ํฌ๋ ๋ชฉ๋ก ์๋ต")
+public record GetTodoListResponse(
+ @Schema(description = "๋ฏธ์๋ฃ ํฌ๋ ๋ชฉ๋ก")
+ List pending,
+
+ @Schema(description = "์๋ฃ๋ ํฌ๋ ๋ชฉ๋ก")
+ List completed
+) {
+
+ public static GetTodoListResponse from(List pending, List completed) {
+ return new GetTodoListResponse(pending, completed);
+ }
+
+ @Schema(description = "ํฌ๋ ์์ธ ์ ๋ณด")
+ public record TodoResponse(
+ @Schema(description = "ํฌ๋ ID", example = "1234567890")
+ Long id,
+
+ @Schema(description = "ํฌ๋ ๋ด์ฉ", example = "๊ณผ์ ์ ์ถํ๊ธฐ")
+ String content,
+
+ @Schema(description = "๋ง๊ฐ์ผ", example = "2025-01-15")
+ LocalDate dueDate,
+
+ @Schema(description = "์๋ฃ ์ฌ๋ถ", example = "false")
+ Boolean isCompleted
+ ) {
+
+ public static TodoResponse from(Long id, String content, LocalDate dueDate,
+ Boolean isCompleted) {
+ return new TodoResponse(id, content, dueDate, isCompleted);
+ }
+ }
+}
From 71e7f59b5e345e1ada192cb173aade0f1f7c5812 Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 13:15:27 +0900
Subject: [PATCH 041/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=88=AC=EB=91=90=20=EA=B4=80=EB=A0=A8=20API=20=EA=B2=80?=
=?UTF-8?q?=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?=
=?UTF-8?q?=EC=9E=91=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManageTodoService.java | 6 +-
.../main/java/kr/spot/todo/domain/Todo.java | 9 +-
.../command/ManageTodoServiceTest.java | 334 ++++++++++++++++++
.../application/query/GetTodoServiceTest.java | 139 ++++++++
.../java/kr/spot/todo/common/TodoFixture.java | 49 +++
.../java/kr/spot/todo/domain/TodoTest.java | 218 ++++++++++++
6 files changed, 749 insertions(+), 6 deletions(-)
create mode 100644 modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java
create mode 100644 modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java
create mode 100644 modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java
diff --git a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
index e2aedb79..a8631b31 100644
--- a/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
+++ b/modules/study/src/main/java/kr/spot/todo/application/command/ManageTodoService.java
@@ -30,17 +30,17 @@ public void createTodo(long studyId, long memberId, CreateTodoRequest request) {
public void updateTodo(long studyId, long todoId, long memberId, UpdateTodoRequest request) {
Todo todo = todoRepository.getById(todoId);
- todo.update(request.content(), request.dueDate(), memberId);
+ todo.update(studyId, request.content(), request.dueDate(), memberId);
}
public void completeTodo(long studyId, long todoId, long memberId) {
Todo todo = todoRepository.getById(todoId);
- todo.complete(memberId);
+ todo.complete(studyId, memberId);
}
public void uncompleteTodo(long studyId, long todoId, long memberId) {
Todo todo = todoRepository.getById(todoId);
- todo.uncomplete(memberId);
+ todo.uncomplete(studyId, memberId);
}
public void deleteTodo(long studyId, long todoId, long memberId) {
diff --git a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
index a86ad8a3..2058d06e 100644
--- a/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
+++ b/modules/study/src/main/java/kr/spot/todo/domain/Todo.java
@@ -41,18 +41,21 @@ public static Todo of(Long id, Long studyId, Long memberId, LocalDate dueDate, S
return new Todo(id, studyId, memberId, dueDate, content, false);
}
- public void update(String content, LocalDate dueDate, long memberId) {
+ public void update(long studyId, String content, LocalDate dueDate, long memberId) {
+ validateAccess(studyId);
validateOwner(memberId);
this.content = content;
this.dueDate = dueDate;
}
- public void complete(long memberId) {
+ public void complete(long studyId, long memberId) {
+ validateAccess(studyId);
validateOwner(memberId);
this.isCompleted = true;
}
- public void uncomplete(long memberId) {
+ public void uncomplete(long studyId, long memberId) {
+ validateAccess(studyId);
validateOwner(memberId);
this.isCompleted = false;
}
diff --git a/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java b/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java
new file mode 100644
index 00000000..5f52049c
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/todo/application/command/ManageTodoServiceTest.java
@@ -0,0 +1,334 @@
+package kr.spot.todo.application.command;
+
+import static kr.spot.todo.common.TodoFixture.CONTENT;
+import static kr.spot.todo.common.TodoFixture.DUE_DATE;
+import static kr.spot.todo.common.TodoFixture.MEMBER_ID;
+import static kr.spot.todo.common.TodoFixture.STUDY_ID;
+import static kr.spot.todo.common.TodoFixture.createTodoRequest;
+import static kr.spot.todo.common.TodoFixture.todo;
+import static kr.spot.todo.common.TodoFixture.updateTodoRequest;
+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.verify;
+import static org.mockito.Mockito.when;
+
+import kr.spot.IdGenerator;
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.infrastructure.jpa.TodoRepository;
+import kr.spot.todo.presentation.command.dto.CreateTodoRequest;
+import kr.spot.todo.presentation.command.dto.UpdateTodoRequest;
+import org.junit.jupiter.api.BeforeEach;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class ManageTodoServiceTest {
+
+ @Mock
+ IdGenerator idGenerator;
+
+ @Mock
+ TodoRepository todoRepository;
+
+ @Captor
+ ArgumentCaptor todoCaptor;
+
+ ManageTodoService manageTodoService;
+
+ @BeforeEach
+ void setUp() {
+ manageTodoService = new ManageTodoService(idGenerator, todoRepository);
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์์ฑ (createTodo)")
+ class CreateTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_todo_successfully() {
+ // given
+ Long generatedId = 1L;
+ CreateTodoRequest request = createTodoRequest();
+
+ when(idGenerator.nextId()).thenReturn(generatedId);
+ when(todoRepository.save(any(Todo.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ // when
+ manageTodoService.createTodo(STUDY_ID, MEMBER_ID, request);
+
+ // then
+ verify(todoRepository).save(todoCaptor.capture());
+
+ Todo capturedTodo = todoCaptor.getValue();
+ assertThat(capturedTodo.getId()).isEqualTo(generatedId);
+ assertThat(capturedTodo.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(capturedTodo.getMemberId()).isEqualTo(MEMBER_ID);
+ assertThat(capturedTodo.getContent()).isEqualTo(CONTENT);
+ assertThat(capturedTodo.getDueDate()).isEqualTo(DUE_DATE);
+ assertThat(capturedTodo.getIsCompleted()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์์ (updateTodo)")
+ class UpdateTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์์ ํ ์ ์๋ค")
+ void should_update_todo_successfully() {
+ // given
+ long todoId = 1L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+ UpdateTodoRequest request = updateTodoRequest();
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when
+ manageTodoService.updateTodo(STUDY_ID, todoId, MEMBER_ID, request);
+
+ // then
+ verify(todoRepository).getById(todoId);
+ assertThat(todo.getContent()).isEqualTo(request.content());
+ assertThat(todo.getDueDate()).isEqualTo(request.dueDate());
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_other_study_todo() {
+ // given
+ long todoId = 1L;
+ long otherStudyId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+ UpdateTodoRequest request = updateTodoRequest();
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.updateTodo(otherStudyId, todoId, MEMBER_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_by_non_owner() {
+ // given
+ long todoId = 1L;
+ long otherMemberId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+ UpdateTodoRequest request = updateTodoRequest();
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.updateTodo(STUDY_ID, todoId, otherMemberId, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ํฌ๋๋ฅผ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_todo_not_found() {
+ // given
+ long todoId = 999L;
+ UpdateTodoRequest request = updateTodoRequest();
+
+ when(todoRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._TODO_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.updateTodo(STUDY_ID, todoId, MEMBER_ID, request))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_NOT_FOUND);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์๋ฃ (completeTodo)")
+ class CompleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์๋ฃํ ์ ์๋ค")
+ void should_complete_todo_successfully() {
+ // given
+ long todoId = 1L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when
+ manageTodoService.completeTodo(STUDY_ID, todoId, MEMBER_ID);
+
+ // then
+ verify(todoRepository).getById(todoId);
+ assertThat(todo.getIsCompleted()).isTrue();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ์๋ฃํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_complete_other_study_todo() {
+ // given
+ long todoId = 1L;
+ long otherStudyId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.completeTodo(otherStudyId, todoId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์๋ฃํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_complete_by_non_owner() {
+ // given
+ long todoId = 1L;
+ long otherMemberId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.completeTodo(STUDY_ID, todoId, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ๋ฏธ์๋ฃ (uncompleteTodo)")
+ class UncompleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ ์ ์๋ค")
+ void should_uncomplete_todo_successfully() {
+ // given
+ long todoId = 1L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+ todo.complete(STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when
+ manageTodoService.uncompleteTodo(STUDY_ID, todoId, MEMBER_ID);
+
+ // then
+ verify(todoRepository).getById(todoId);
+ assertThat(todo.getIsCompleted()).isFalse();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_uncomplete_other_study_todo() {
+ // given
+ long todoId = 1L;
+ long otherStudyId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.uncompleteTodo(otherStudyId, todoId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_uncomplete_by_non_owner() {
+ // given
+ long todoId = 1L;
+ long otherMemberId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.uncompleteTodo(STUDY_ID, todoId, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์ญ์ (deleteTodo)")
+ class DeleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_todo_successfully() {
+ // given
+ long todoId = 1L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when
+ manageTodoService.deleteTodo(STUDY_ID, todoId, MEMBER_ID);
+
+ // then
+ verify(todoRepository).getById(todoId);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_other_study_todo() {
+ // given
+ long todoId = 1L;
+ long otherStudyId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.deleteTodo(otherStudyId, todoId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_by_non_owner() {
+ // given
+ long todoId = 1L;
+ long otherMemberId = 999L;
+ Todo todo = todo(todoId, STUDY_ID, MEMBER_ID);
+
+ when(todoRepository.getById(anyLong())).thenReturn(todo);
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.deleteTodo(STUDY_ID, todoId, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+
+ @Test
+ @DisplayName("์กด์ฌํ์ง ์๋ ํฌ๋๋ฅผ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_todo_not_found() {
+ // given
+ long todoId = 999L;
+
+ when(todoRepository.getById(anyLong()))
+ .thenThrow(new GeneralException(ErrorStatus._TODO_NOT_FOUND));
+
+ // when & then
+ assertThatThrownBy(() -> manageTodoService.deleteTodo(STUDY_ID, todoId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_NOT_FOUND);
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java b/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java
new file mode 100644
index 00000000..648e9043
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/todo/application/query/GetTodoServiceTest.java
@@ -0,0 +1,139 @@
+package kr.spot.todo.application.query;
+
+import static kr.spot.todo.common.TodoFixture.DUE_DATE;
+import static kr.spot.todo.common.TodoFixture.MEMBER_ID;
+import static kr.spot.todo.common.TodoFixture.STUDY_ID;
+import static kr.spot.todo.common.TodoFixture.completedTodo;
+import static kr.spot.todo.common.TodoFixture.todo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.List;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.infrastructure.jpa.querydsl.TodoQueryRepository;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse;
+import kr.spot.todo.presentation.query.dto.GetTodoListResponse.TodoResponse;
+import org.junit.jupiter.api.BeforeEach;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class GetTodoServiceTest {
+
+ @Mock
+ TodoQueryRepository todoQueryRepository;
+
+ GetTodoService getTodoService;
+
+ @BeforeEach
+ void setUp() {
+ getTodoService = new GetTodoService(todoQueryRepository);
+ }
+
+ @Nested
+ @DisplayName("๋ ์ง๋ณ ํฌ๋ ์กฐํ (getTodosByDate)")
+ class GetTodosByDate {
+
+ @Test
+ @DisplayName("ํน์ ๋ ์ง์ ํฌ๋ ๋ชฉ๋ก์ ์กฐํํ ์ ์๋ค")
+ void should_get_todos_by_date_successfully() {
+ // given
+ Todo pendingTodo = todo(1L);
+ Todo completedTodo = completedTodo();
+
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(List.of(pendingTodo, completedTodo));
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ verify(todoQueryRepository).findByStudyIdAndMemberIdAndDueDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+ assertThat(response.pending()).hasSize(1);
+ assertThat(response.completed()).hasSize(1);
+ }
+
+ @Test
+ @DisplayName("๋ฏธ์๋ฃ ํฌ๋๋ง ์๋ ๊ฒฝ์ฐ pending ๋ชฉ๋ก๋ง ๋ฐํ๋๋ค")
+ void should_return_only_pending_todos_when_no_completed() {
+ // given
+ Todo pendingTodo1 = todo(1L);
+ Todo pendingTodo2 = todo(2L);
+
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(List.of(pendingTodo1, pendingTodo2));
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ assertThat(response.pending()).hasSize(2);
+ assertThat(response.completed()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("์๋ฃ๋ ํฌ๋๋ง ์๋ ๊ฒฝ์ฐ completed ๋ชฉ๋ก๋ง ๋ฐํ๋๋ค")
+ void should_return_only_completed_todos_when_no_pending() {
+ // given
+ Todo completedTodo1 = todo(1L);
+ completedTodo1.complete(STUDY_ID, MEMBER_ID);
+ Todo completedTodo2 = todo(2L);
+ completedTodo2.complete(STUDY_ID, MEMBER_ID);
+
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(List.of(completedTodo1, completedTodo2));
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ assertThat(response.pending()).isEmpty();
+ assertThat(response.completed()).hasSize(2);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋๊ฐ ์๋ ๊ฒฝ์ฐ ๋น ๋ชฉ๋ก์ด ๋ฐํ๋๋ค")
+ void should_return_empty_lists_when_no_todos() {
+ // given
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(Collections.emptyList());
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ assertThat(response.pending()).isEmpty();
+ assertThat(response.completed()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์๋ต์ ์ฌ๋ฐ๋ฅธ ์ ๋ณด๊ฐ ํฌํจ๋๋ค")
+ void should_return_correct_todo_information() {
+ // given
+ Todo todo = todo(1L);
+
+ when(todoQueryRepository.findByStudyIdAndMemberIdAndDueDate(anyLong(), anyLong(), any(LocalDate.class)))
+ .thenReturn(List.of(todo));
+
+ // when
+ GetTodoListResponse response = getTodoService.getTodosByDate(STUDY_ID, MEMBER_ID, DUE_DATE);
+
+ // then
+ assertThat(response.pending()).hasSize(1);
+ TodoResponse todoResponse = response.pending().get(0);
+ assertThat(todoResponse.id()).isEqualTo(todo.getId());
+ assertThat(todoResponse.content()).isEqualTo(todo.getContent());
+ assertThat(todoResponse.dueDate()).isEqualTo(todo.getDueDate());
+ assertThat(todoResponse.isCompleted()).isEqualTo(todo.getIsCompleted());
+ }
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java b/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java
new file mode 100644
index 00000000..9580143c
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/todo/common/TodoFixture.java
@@ -0,0 +1,49 @@
+package kr.spot.todo.common;
+
+import java.time.LocalDate;
+import kr.spot.todo.domain.Todo;
+import kr.spot.todo.presentation.command.dto.CreateTodoRequest;
+import kr.spot.todo.presentation.command.dto.UpdateTodoRequest;
+
+public class TodoFixture {
+
+ public static final Long ID = 1L;
+ public static final Long STUDY_ID = 100L;
+ public static final Long MEMBER_ID = 200L;
+ public static final LocalDate DUE_DATE = LocalDate.of(2025, 1, 15);
+ public static final String CONTENT = "๊ณผ์ ์ ์ถํ๊ธฐ";
+
+ public static Todo todo() {
+ return Todo.of(ID, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT);
+ }
+
+ public static Todo todo(Long id) {
+ return Todo.of(id, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT);
+ }
+
+ public static Todo todo(Long id, Long studyId, Long memberId) {
+ return Todo.of(id, studyId, memberId, DUE_DATE, CONTENT);
+ }
+
+ public static Todo completedTodo() {
+ Todo todo = todo();
+ todo.complete(STUDY_ID, MEMBER_ID);
+ return todo;
+ }
+
+ public static CreateTodoRequest createTodoRequest() {
+ return new CreateTodoRequest(CONTENT, DUE_DATE);
+ }
+
+ public static CreateTodoRequest createTodoRequest(String content, LocalDate dueDate) {
+ return new CreateTodoRequest(content, dueDate);
+ }
+
+ public static UpdateTodoRequest updateTodoRequest() {
+ return new UpdateTodoRequest("์์ ๋ ๋ด์ฉ", LocalDate.of(2025, 1, 20));
+ }
+
+ public static UpdateTodoRequest updateTodoRequest(String content, LocalDate dueDate) {
+ return new UpdateTodoRequest(content, dueDate);
+ }
+}
diff --git a/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java b/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java
new file mode 100644
index 00000000..c352b3c4
--- /dev/null
+++ b/modules/study/src/test/java/kr/spot/todo/domain/TodoTest.java
@@ -0,0 +1,218 @@
+package kr.spot.todo.domain;
+
+import static kr.spot.todo.common.TodoFixture.CONTENT;
+import static kr.spot.todo.common.TodoFixture.DUE_DATE;
+import static kr.spot.todo.common.TodoFixture.ID;
+import static kr.spot.todo.common.TodoFixture.MEMBER_ID;
+import static kr.spot.todo.common.TodoFixture.STUDY_ID;
+import static kr.spot.todo.common.TodoFixture.todo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.LocalDate;
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class TodoTest {
+
+ @Nested
+ @DisplayName("ํฌ๋ ์์ฑ (of)")
+ class CreateTodo {
+
+ @Test
+ @DisplayName("ํฌ๋ ๊ฐ์ฒด๋ฅผ ์ ์์ ์ผ๋ก ์์ฑํ ์ ์๋ค")
+ void should_create_todo_successfully() {
+ // when
+ Todo todo = Todo.of(ID, STUDY_ID, MEMBER_ID, DUE_DATE, CONTENT);
+
+ // then
+ assertThat(todo).isNotNull();
+ assertThat(todo.getId()).isEqualTo(ID);
+ assertThat(todo.getStudyId()).isEqualTo(STUDY_ID);
+ assertThat(todo.getMemberId()).isEqualTo(MEMBER_ID);
+ assertThat(todo.getDueDate()).isEqualTo(DUE_DATE);
+ assertThat(todo.getContent()).isEqualTo(CONTENT);
+ assertThat(todo.getIsCompleted()).isFalse();
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์์ (update)")
+ class UpdateTodo {
+
+ @Test
+ @DisplayName("ํฌ๋ ๋ด์ฉ๊ณผ ๋ง๊ฐ์ผ์ ์ ์์ ์ผ๋ก ์์ ํ ์ ์๋ค")
+ void should_update_todo_successfully() {
+ // given
+ Todo todo = todo();
+ String newContent = "์์ ๋ ๋ด์ฉ";
+ LocalDate newDueDate = LocalDate.of(2025, 1, 20);
+
+ // when
+ todo.update(STUDY_ID, newContent, newDueDate, MEMBER_ID);
+
+ // then
+ assertThat(todo.getContent()).isEqualTo(newContent);
+ assertThat(todo.getDueDate()).isEqualTo(newDueDate);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ํฌ๋๋ฅผ ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_with_different_study_id() {
+ // given
+ Todo todo = todo();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.update(otherStudyId, "๋ด์ฉ", DUE_DATE, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_update_by_non_owner() {
+ // given
+ Todo todo = todo();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.update(STUDY_ID, "๋ด์ฉ", DUE_DATE, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์๋ฃ (complete)")
+ class CompleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์๋ฃ ์ํ๋ก ๋ณ๊ฒฝํ ์ ์๋ค")
+ void should_complete_todo_successfully() {
+ // given
+ Todo todo = todo();
+
+ // when
+ todo.complete(STUDY_ID, MEMBER_ID);
+
+ // then
+ assertThat(todo.getIsCompleted()).isTrue();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ํฌ๋๋ฅผ ์๋ฃํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_complete_with_different_study_id() {
+ // given
+ Todo todo = todo();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.complete(otherStudyId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์๋ฃํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_complete_by_non_owner() {
+ // given
+ Todo todo = todo();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.complete(STUDY_ID, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ๋ฏธ์๋ฃ (uncomplete)")
+ class UncompleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ๋ฏธ์๋ฃ ์ํ๋ก ๋ณ๊ฒฝํ ์ ์๋ค")
+ void should_uncomplete_todo_successfully() {
+ // given
+ Todo todo = todo();
+ todo.complete(STUDY_ID, MEMBER_ID);
+
+ // when
+ todo.uncomplete(STUDY_ID, MEMBER_ID);
+
+ // then
+ assertThat(todo.getIsCompleted()).isFalse();
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์์ ํฌ๋๋ฅผ ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_uncomplete_with_different_study_id() {
+ // given
+ Todo todo = todo();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.uncomplete(otherStudyId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ๋ฏธ์๋ฃ๋ก ๋ณ๊ฒฝํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_uncomplete_by_non_owner() {
+ // given
+ Todo todo = todo();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.uncomplete(STUDY_ID, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+
+ @Nested
+ @DisplayName("ํฌ๋ ์ญ์ (delete)")
+ class DeleteTodo {
+
+ @Test
+ @DisplayName("ํฌ๋๋ฅผ ์ ์์ ์ผ๋ก ์ญ์ ํ ์ ์๋ค")
+ void should_delete_todo_successfully() {
+ // given
+ Todo todo = todo();
+
+ // when & then (์์ธ๊ฐ ๋ฐ์ํ์ง ์์ผ๋ฉด ์ฑ๊ณต)
+ todo.delete(STUDY_ID, MEMBER_ID);
+ }
+
+ @Test
+ @DisplayName("๋ค๋ฅธ ์คํฐ๋์ ํฌ๋๋ฅผ ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_with_different_study_id() {
+ // given
+ Todo todo = todo();
+ long otherStudyId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.delete(otherStudyId, MEMBER_ID))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._TODO_ACCESS_DENIED);
+ }
+
+ @Test
+ @DisplayName("ํฌ๋ ์์ฑ์๊ฐ ์๋ ์ฌ๋์ด ์ญ์ ํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
+ void should_throw_exception_when_delete_by_non_owner() {
+ // given
+ Todo todo = todo();
+ long otherMemberId = 999L;
+
+ // when & then
+ assertThatThrownBy(() -> todo.delete(STUDY_ID, otherMemberId))
+ .isInstanceOf(GeneralException.class)
+ .hasFieldOrPropertyWithValue("status", ErrorStatus._ONLY_TODO_OWNER_CAN_MODIFY);
+ }
+ }
+}
From 5ccd4e000df662bfc77a794afe30fba8c55aa90f Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 13:55:47 +0900
Subject: [PATCH 042/126] =?UTF-8?q?[Feature]=20=EC=8A=A4=ED=84=B0=EB=94=94?=
=?UTF-8?q?=20=ED=9A=8C=EA=B3=A0=EB=A1=9D=20=EA=B4=80=EB=A0=A8=20API=20?=
=?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Conflicts:
# common/api/src/main/java/kr/spot/code/status/ErrorStatus.java
---
.../java/kr/spot/code/status/ErrorStatus.java | 1 +
.../command/ManageReviewReactionService.java | 17 +++++++++++++----
.../command/ManageReviewService.java | 2 +-
.../application/query/GetReviewService.java | 3 ++-
.../main/java/kr/spot/review/domain/Review.java | 4 ----
.../jpa/ReviewReactionRepository.java | 4 ++++
.../infrastructure/jpa/ReviewRepository.java | 6 ------
7 files changed, 21 insertions(+), 16 deletions(-)
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 e7a8a4a3..280d3845 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
@@ -77,6 +77,7 @@ public enum ErrorStatus implements BaseErrorCode {
_REVIEW_ACCESS_DENIED(403, "REVIEW403", "ํด๋น ์คํฐ๋์ ์ํ๋ ํ๊ณ ๊ฐ ์๋๋๋ค."),
_ALREADY_REACTED(400, "REVIEW4000", "์ด๋ฏธ ๋ฐ์์ ๋๋ฅธ ํ๊ณ ์
๋๋ค."),
_REACTION_NOT_FOUND(404, "REVIEW4040", "๋ฐ์์ ์ฐพ์ ์ ์์ต๋๋ค."),
+
;
private final int httpStatus;
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
index f74de2da..5ba2a402 100644
--- 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
@@ -1,6 +1,8 @@
package kr.spot.review.application.command;
import kr.spot.IdGenerator;
+import kr.spot.code.status.ErrorStatus;
+import kr.spot.exception.GeneralException;
import kr.spot.review.domain.associations.ReviewReaction;
import kr.spot.review.domain.enums.Reaction;
import kr.spot.review.infrastructure.jpa.ReviewReactionRepository;
@@ -22,11 +24,11 @@ public class ManageReviewReactionService {
public void addReaction(long studyId, long reviewId, long memberId, Reaction reaction) {
studyAccessValidator.validateStudyMember(studyId, memberId);
- reviewRepository.validateExists(reviewId);
+ validateReviewExists(reviewId);
if (reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(reviewId, memberId,
reaction)) {
- return;
+ throw new GeneralException(ErrorStatus._ALREADY_REACTED);
}
ReviewReaction reviewReaction = ReviewReaction.of(
@@ -36,8 +38,15 @@ public void addReaction(long studyId, long reviewId, long memberId, Reaction rea
public void removeReaction(long studyId, long reviewId, long memberId, Reaction reaction) {
studyAccessValidator.validateStudyMember(studyId, memberId);
- reviewRepository.validateExists(reviewId);
+ validateReviewExists(reviewId);
- reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name());
+ int deleted = reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name());
+ if (deleted == 0) {
+ throw new GeneralException(ErrorStatus._REACTION_NOT_FOUND);
+ }
+ }
+
+ private void validateReviewExists(long reviewId) {
+ reviewRepository.getById(reviewId);
}
}
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
index dd87719d..e8d83492 100644
--- 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
@@ -47,7 +47,7 @@ public void deleteReview(long studyId, long reviewId, long memberId) {
studyAccessValidator.validateStudyMember(studyId, memberId);
Review review = reviewRepository.getById(reviewId);
- review.validateIsOwnMember(memberId);
+ review.getWriterInfo().validateIsOwnMember(memberId);
reviewRepository.delete(review);
}
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
index b60f9446..5a00b99e 100644
--- 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
@@ -31,7 +31,8 @@ public class GetReviewService {
private final StudyAccessValidator studyAccessValidator;
public GetReviewListResponse getReviewList(long studyId, Long viewerId, Long cursor, int size) {
- boolean isStudyMember = studyAccessValidator.isStudyMember(studyId, viewerId);
+ boolean isStudyMember =
+ viewerId != null && studyAccessValidator.isStudyMember(studyId, viewerId);
List reviews = reviewQueryRepository.findByStudyIdWithCursor(studyId, cursor,
size + 1);
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
index 87254f98..36d93735 100644
--- a/modules/study/src/main/java/kr/spot/review/domain/Review.java
+++ b/modules/study/src/main/java/kr/spot/review/domain/Review.java
@@ -42,8 +42,4 @@ public static Review of(Long id, Long studyId, WriterInfo writerInfo, Content co
public boolean isPrivate() {
return Boolean.TRUE.equals(isPrivate);
}
-
- public void validateIsOwnMember(Long currentUserId) {
- writerInfo.validateIsOwnMember(currentUserId);
- }
}
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
index 53d81390..c791345a 100644
--- 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
@@ -1,5 +1,6 @@
package kr.spot.review.infrastructure.jpa;
+import java.util.Optional;
import kr.spot.review.domain.associations.ReviewReaction;
import kr.spot.review.domain.enums.Reaction;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -9,6 +10,9 @@
public interface ReviewReactionRepository extends JpaRepository {
+ Optional findByReviewIdAndMemberIdAndReaction(
+ Long reviewId, Long memberId, Reaction reaction);
+
boolean existsByReviewIdAndMemberIdAndReaction(
Long reviewId, Long memberId, Reaction reaction);
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
index 866fdcfa..191788db 100644
--- 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
@@ -11,10 +11,4 @@ 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);
- }
- }
}
From e35c6033c36d6c3167011a6561d25125bfa3e18a Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 14:11:32 +0900
Subject: [PATCH 043/126] =?UTF-8?q?[Refactor]=20=EB=A1=9C=EC=A7=81=20?=
=?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=EC=9C=BC=EB=A1=9C=20?=
=?UTF-8?q?=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?=
=?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ManageReviewReactionServiceTest.java | 51 ++++++++-----------
1 file changed, 21 insertions(+), 30 deletions(-)
diff --git a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java
index c42fb5de..a944f0fd 100644
--- a/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java
+++ b/modules/study/src/test/java/kr/spot/review/application/command/ManageReviewReactionServiceTest.java
@@ -2,7 +2,6 @@
import static kr.spot.review.common.ReviewFixture.MEMBER_ID;
import static kr.spot.review.common.ReviewFixture.STUDY_ID;
-import static kr.spot.review.common.ReviewFixture.review;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@@ -17,7 +16,6 @@
import kr.spot.IdGenerator;
import kr.spot.code.status.ErrorStatus;
import kr.spot.exception.GeneralException;
-import kr.spot.review.domain.Review;
import kr.spot.review.domain.associations.ReviewReaction;
import kr.spot.review.domain.enums.Reaction;
import kr.spot.review.infrastructure.jpa.ReviewReactionRepository;
@@ -70,10 +68,9 @@ void should_add_reaction_successfully() {
long reviewId = 1L;
long generatedId = 100L;
Reaction reaction = Reaction.FIRE;
- Review review = review(reviewId);
doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
- when(reviewRepository.getById(anyLong())).thenReturn(review);
+ doNothing().when(reviewRepository).validateExists(anyLong());
when(reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(anyLong(), anyLong(),
any())).thenReturn(false);
when(idGenerator.nextId()).thenReturn(generatedId);
@@ -85,7 +82,7 @@ void should_add_reaction_successfully() {
// then
verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID);
- verify(reviewRepository).getById(reviewId);
+ verify(reviewRepository).validateExists(reviewId);
verify(reviewReactionRepository).save(reactionCaptor.capture());
ReviewReaction capturedReaction = reactionCaptor.getValue();
@@ -96,24 +93,21 @@ void should_add_reaction_successfully() {
}
@Test
- @DisplayName("์ด๋ฏธ ๋์ผํ ๋ฐ์์ ๋๋ฅธ ๊ฒฝ์ฐ ์์ธ๊ฐ ๋ฐ์ํ๋ค")
- void should_throw_exception_when_already_reacted() {
+ @DisplayName("์ด๋ฏธ ๋์ผํ ๋ฐ์์ด ์์ผ๋ฉด ๋ฌด์ํ๋ค (๋ฉฑ๋ฑ์ฑ)")
+ void should_ignore_when_already_reacted() {
// given
long reviewId = 1L;
Reaction reaction = Reaction.HEART;
- Review review = review(reviewId);
doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
- when(reviewRepository.getById(anyLong())).thenReturn(review);
+ doNothing().when(reviewRepository).validateExists(anyLong());
when(reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(anyLong(), anyLong(),
any())).thenReturn(true);
- // when & then
- assertThatThrownBy(
- () -> manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction))
- .isInstanceOf(GeneralException.class)
- .hasFieldOrPropertyWithValue("status", ErrorStatus._ALREADY_REACTED);
+ // when
+ manageReviewReactionService.addReaction(STUDY_ID, reviewId, MEMBER_ID, reaction);
+ // then
verify(reviewReactionRepository, never()).save(any());
}
@@ -125,8 +119,8 @@ void should_throw_exception_when_review_not_found() {
Reaction reaction = Reaction.STAR;
doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
- when(reviewRepository.getById(anyLong()))
- .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND));
+ doThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND))
+ .when(reviewRepository).validateExists(anyLong());
// when & then
assertThatThrownBy(
@@ -165,10 +159,9 @@ void should_remove_reaction_successfully() {
// given
long reviewId = 1L;
Reaction reaction = Reaction.FIRE;
- Review review = review(reviewId);
doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
- when(reviewRepository.getById(anyLong())).thenReturn(review);
+ doNothing().when(reviewRepository).validateExists(anyLong());
when(reviewReactionRepository.hardDelete(anyLong(), anyLong(), anyString())).thenReturn(1);
// when
@@ -176,27 +169,25 @@ void should_remove_reaction_successfully() {
// then
verify(studyAccessValidator).validateStudyMember(STUDY_ID, MEMBER_ID);
- verify(reviewRepository).getById(reviewId);
+ verify(reviewRepository).validateExists(reviewId);
verify(reviewReactionRepository).hardDelete(reviewId, MEMBER_ID, reaction.name());
}
@Test
- @DisplayName("์กด์ฌํ์ง ์๋ ๋ฐ์์ ์ ๊ฑฐํ๋ ค๊ณ ํ๋ฉด ์์ธ๊ฐ ๋ฐ์ํ๋ค")
- void should_throw_exception_when_reaction_not_found() {
+ @DisplayName("์กด์ฌํ์ง ์๋ ๋ฐ์์ ์ ๊ฑฐํด๋ ์์ธ๊ฐ ๋ฐ์ํ์ง ์๋๋ค (๋ฉฑ๋ฑ์ฑ)")
+ void should_not_throw_when_reaction_not_found() {
// given
long reviewId = 1L;
Reaction reaction = Reaction.HEART;
- Review review = review(reviewId);
doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
- when(reviewRepository.getById(anyLong())).thenReturn(review);
+ doNothing().when(reviewRepository).validateExists(anyLong());
when(reviewReactionRepository.hardDelete(anyLong(), anyLong(), anyString())).thenReturn(0);
- // when & then
- assertThatThrownBy(
- () -> manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction))
- .isInstanceOf(GeneralException.class)
- .hasFieldOrPropertyWithValue("status", ErrorStatus._REACTION_NOT_FOUND);
+ // when & then (์์ธ ๋ฐ์ ์ํจ)
+ manageReviewReactionService.removeReaction(STUDY_ID, reviewId, MEMBER_ID, reaction);
+
+ verify(reviewReactionRepository).hardDelete(reviewId, MEMBER_ID, reaction.name());
}
@Test
@@ -207,8 +198,8 @@ void should_throw_exception_when_review_not_found() {
Reaction reaction = Reaction.STAR;
doNothing().when(studyAccessValidator).validateStudyMember(anyLong(), anyLong());
- when(reviewRepository.getById(anyLong()))
- .thenThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND));
+ doThrow(new GeneralException(ErrorStatus._REVIEW_NOT_FOUND))
+ .when(reviewRepository).validateExists(anyLong());
// when & then
assertThatThrownBy(
From 102a733f93b768b9668ed928d4c5e4eefd879ecb Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 14:23:20 +0900
Subject: [PATCH 044/126] =?UTF-8?q?[Refactor]=20=EC=B6=A9=EB=8F=8C?=
=?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20=EB=A1=A4=EB=B0=B1=EB=90=9C=20?=
=?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20=EC=9E=AC=EB=B0=98?=
=?UTF-8?q?=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManageReviewReactionService.java | 17 ++++-------------
.../jpa/ReviewReactionRepository.java | 4 ----
.../infrastructure/jpa/ReviewRepository.java | 6 ++++++
3 files changed, 10 insertions(+), 17 deletions(-)
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
index 5ba2a402..f74de2da 100644
--- 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
@@ -1,8 +1,6 @@
package kr.spot.review.application.command;
import kr.spot.IdGenerator;
-import kr.spot.code.status.ErrorStatus;
-import kr.spot.exception.GeneralException;
import kr.spot.review.domain.associations.ReviewReaction;
import kr.spot.review.domain.enums.Reaction;
import kr.spot.review.infrastructure.jpa.ReviewReactionRepository;
@@ -24,11 +22,11 @@ public class ManageReviewReactionService {
public void addReaction(long studyId, long reviewId, long memberId, Reaction reaction) {
studyAccessValidator.validateStudyMember(studyId, memberId);
- validateReviewExists(reviewId);
+ reviewRepository.validateExists(reviewId);
if (reviewReactionRepository.existsByReviewIdAndMemberIdAndReaction(reviewId, memberId,
reaction)) {
- throw new GeneralException(ErrorStatus._ALREADY_REACTED);
+ return;
}
ReviewReaction reviewReaction = ReviewReaction.of(
@@ -38,15 +36,8 @@ public void addReaction(long studyId, long reviewId, long memberId, Reaction rea
public void removeReaction(long studyId, long reviewId, long memberId, Reaction reaction) {
studyAccessValidator.validateStudyMember(studyId, memberId);
- validateReviewExists(reviewId);
+ reviewRepository.validateExists(reviewId);
- int deleted = reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name());
- if (deleted == 0) {
- throw new GeneralException(ErrorStatus._REACTION_NOT_FOUND);
- }
- }
-
- private void validateReviewExists(long reviewId) {
- reviewRepository.getById(reviewId);
+ reviewReactionRepository.hardDelete(reviewId, memberId, reaction.name());
}
}
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
index c791345a..53d81390 100644
--- 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
@@ -1,6 +1,5 @@
package kr.spot.review.infrastructure.jpa;
-import java.util.Optional;
import kr.spot.review.domain.associations.ReviewReaction;
import kr.spot.review.domain.enums.Reaction;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -10,9 +9,6 @@
public interface ReviewReactionRepository extends JpaRepository {
- Optional findByReviewIdAndMemberIdAndReaction(
- Long reviewId, Long memberId, Reaction reaction);
-
boolean existsByReviewIdAndMemberIdAndReaction(
Long reviewId, Long memberId, Reaction reaction);
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
index 191788db..866fdcfa 100644
--- 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
@@ -11,4 +11,10 @@ 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);
+ }
+ }
}
From c20c1cc0fcceb7a338f6fb0827ffa50cb6281ddf Mon Sep 17 00:00:00 2001
From: msk226
Date: Tue, 30 Dec 2025 14:32:34 +0900
Subject: [PATCH 045/126] =?UTF-8?q?[Feature]=20=EA=B2=8C=EC=8B=9C=EA=B8=80?=
=?UTF-8?q?=20=EC=8B=A0=EA=B3=A0=20API=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../command/ManagePostService.java | 12 ++++++-
.../src/main/java/kr/spot/domain/Report.java | 33 +++++++++++++++++++
.../infrastructure/jpa/PostRepository.java | 9 +++--
.../infrastructure/jpa/ReportRepository.java | 8 +++++
.../command/PostCommandController.java | 17 ++++++++++
.../dto/request/ReportPostRequest.java | 7 ++++
6 files changed, 82 insertions(+), 4 deletions(-)
create mode 100644 modules/post/src/main/java/kr/spot/domain/Report.java
create mode 100644 modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java
create mode 100644 modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java
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/domain/Report.java b/modules/post/src/main/java/kr/spot/domain/Report.java
new file mode 100644
index 00000000..2aed4d92
--- /dev/null
+++ b/modules/post/src/main/java/kr/spot/domain/Report.java
@@ -0,0 +1,33 @@
+package kr.spot.domain;
+
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+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 report SET status = 'INACTIVE' WHERE id = ?")
+@SQLRestriction("status = 'ACTIVE'")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class Report extends BaseEntity {
+
+ @Id
+ private Long id;
+
+ private Long reportedPostId;
+
+ private Long reporterId;
+
+ private String reason;
+
+ 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/jpa/PostRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/PostRepository.java
index e78bff12..050a127c 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,10 @@ 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);
+ }
+ }
}
diff --git a/modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java b/modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java
new file mode 100644
index 00000000..73a9281a
--- /dev/null
+++ b/modules/post/src/main/java/kr/spot/infrastructure/jpa/ReportRepository.java
@@ -0,0 +1,8 @@
+package kr.spot.infrastructure.jpa;
+
+import kr.spot.domain.Report;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ReportRepository extends JpaRepository {
+
+}
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/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java b/modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java
new file mode 100644
index 00000000..c3e12f5f
--- /dev/null
+++ b/modules/post/src/main/java/kr/spot/presentation/command/dto/request/ReportPostRequest.java
@@ -0,0 +1,7 @@
+package kr.spot.presentation.command.dto.request;
+
+public record ReportPostRequest(
+ String reason
+) {
+
+}
From a49a501f20e9200cf2e92baebdfe83e11e5c643b Mon Sep 17 00:00:00 2001
From: msk226